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: "05M",
507    compression: ArchiveCompression::Gzip,
508}];
509
510const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
511    product_type: ProductType::Sp3,
512    token: "GFZ0OPSULT",
513    layout: ArchiveLayout::GfzUltraWeek,
514    span: "02D",
515    default_sample: "05M",
516    compression: ArchiveCompression::Gzip,
517}];
518
519const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
520const COD_ULT_ISSUES: [&str; 1] = ["0000"];
521const GFZ_ULT_ISSUES: [&str; 8] = [
522    "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
523];
524
525const CENTER_ORDER: [AnalysisCenter; 11] = [
526    AnalysisCenter::CodRap,
527    AnalysisCenter::CodPrd1,
528    AnalysisCenter::CodPrd2,
529    AnalysisCenter::Igs,
530    AnalysisCenter::Esa,
531    AnalysisCenter::Cod,
532    AnalysisCenter::Gfz,
533    AnalysisCenter::IgsUlt,
534    AnalysisCenter::CodUlt,
535    AnalysisCenter::EsaUlt,
536    AnalysisCenter::GfzUlt,
537];
538
539const CATALOG: [CenterCatalogEntry; 11] = [
540    CenterCatalogEntry {
541        center: AnalysisCenter::CodRap,
542        code: "cod_rap",
543        protocol: ArchiveProtocol::Http,
544        host: "ftp.aiub.unibe.ch",
545        root_url: "http://ftp.aiub.unibe.ch",
546        products: &COD_RAP_PRODUCTS,
547        issues: &[],
548    },
549    CenterCatalogEntry {
550        center: AnalysisCenter::CodPrd1,
551        code: "cod_prd1",
552        protocol: ArchiveProtocol::Http,
553        host: "ftp.aiub.unibe.ch",
554        root_url: "http://ftp.aiub.unibe.ch",
555        products: &COD_PRD_PRODUCTS,
556        issues: &[],
557    },
558    CenterCatalogEntry {
559        center: AnalysisCenter::CodPrd2,
560        code: "cod_prd2",
561        protocol: ArchiveProtocol::Http,
562        host: "ftp.aiub.unibe.ch",
563        root_url: "http://ftp.aiub.unibe.ch",
564        products: &COD_PRD_PRODUCTS,
565        issues: &[],
566    },
567    CenterCatalogEntry {
568        center: AnalysisCenter::Igs,
569        code: "igs",
570        protocol: ArchiveProtocol::Https,
571        host: "igs.bkg.bund.de",
572        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
573        products: &IGS_PRODUCTS,
574        issues: &[],
575    },
576    CenterCatalogEntry {
577        center: AnalysisCenter::Esa,
578        code: "esa",
579        protocol: ArchiveProtocol::Https,
580        host: "navigation-office.esa.int",
581        root_url: "https://navigation-office.esa.int/products/gnss-products",
582        products: &ESA_PRODUCTS,
583        issues: &[],
584    },
585    CenterCatalogEntry {
586        center: AnalysisCenter::Cod,
587        code: "cod",
588        protocol: ArchiveProtocol::Http,
589        host: "ftp.aiub.unibe.ch",
590        root_url: "http://ftp.aiub.unibe.ch",
591        products: &COD_PRODUCTS,
592        issues: &[],
593    },
594    CenterCatalogEntry {
595        center: AnalysisCenter::Gfz,
596        code: "gfz",
597        protocol: ArchiveProtocol::Https,
598        host: "isdc-data.gfz.de",
599        root_url: "https://isdc-data.gfz.de/gnss/products",
600        products: &GFZ_PRODUCTS,
601        issues: &[],
602    },
603    CenterCatalogEntry {
604        center: AnalysisCenter::IgsUlt,
605        code: "igs_ult",
606        protocol: ArchiveProtocol::Https,
607        host: "igs.bkg.bund.de",
608        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
609        products: &IGS_ULT_PRODUCTS,
610        issues: &OPSULT_ISSUES,
611    },
612    CenterCatalogEntry {
613        center: AnalysisCenter::CodUlt,
614        code: "cod_ult",
615        protocol: ArchiveProtocol::Http,
616        host: "ftp.aiub.unibe.ch",
617        root_url: "http://ftp.aiub.unibe.ch",
618        products: &COD_ULT_PRODUCTS,
619        issues: &COD_ULT_ISSUES,
620    },
621    CenterCatalogEntry {
622        center: AnalysisCenter::EsaUlt,
623        code: "esa_ult",
624        protocol: ArchiveProtocol::Https,
625        host: "navigation-office.esa.int",
626        root_url: "https://navigation-office.esa.int/products/gnss-products",
627        products: &ESA_ULT_PRODUCTS,
628        issues: &OPSULT_ISSUES,
629    },
630    CenterCatalogEntry {
631        center: AnalysisCenter::GfzUlt,
632        code: "gfz_ult",
633        protocol: ArchiveProtocol::Https,
634        host: "isdc-data.gfz.de",
635        root_url: "https://isdc-data.gfz.de/gnss/products",
636        products: &GFZ_ULT_PRODUCTS,
637        issues: &GFZ_ULT_ISSUES,
638    },
639];
640
641const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
642    protocol: ArchiveProtocol::Https,
643    host: "s3.amazonaws.com",
644    compression: ArchiveCompression::Gzip,
645    root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
646};
647
648const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
649    protocol: ArchiveProtocol::Https,
650    host: "celestrak.org",
651    compression: ArchiveCompression::None,
652    root_url: "https://celestrak.org/SpaceData",
653};
654
655const ALLOWED_HOSTS: [&str; 6] = [
656    "ftp.aiub.unibe.ch",
657    "navigation-office.esa.int",
658    "isdc-data.gfz.de",
659    "igs.bkg.bund.de",
660    "s3.amazonaws.com",
661    "celestrak.org",
662];
663
664const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
665    NoOpenMirrorProduct {
666        center: "grg",
667        product_type: "sp3",
668    },
669    NoOpenMirrorProduct {
670        center: "grg",
671        product_type: "clk",
672    },
673    NoOpenMirrorProduct {
674        center: "wum",
675        product_type: "sp3",
676    },
677    NoOpenMirrorProduct {
678        center: "wum",
679        product_type: "clk",
680    },
681    NoOpenMirrorProduct {
682        center: "grg_ult",
683        product_type: "sp3",
684    },
685    NoOpenMirrorProduct {
686        center: "grg_ult",
687        product_type: "clk",
688    },
689    NoOpenMirrorProduct {
690        center: "igs",
691        product_type: "ionex",
692    },
693];
694
695/// Error returned by the pure data-product catalog.
696#[derive(Debug, Clone, PartialEq, Eq)]
697pub enum DataCatalogError {
698    /// Unknown analysis-center code.
699    UnknownCenter(String),
700    /// Unknown product type code.
701    UnknownProductType(String),
702    /// The center does not serve the requested product type.
703    UnsupportedProduct {
704        /// Analysis center.
705        center: AnalysisCenter,
706        /// Product type.
707        product_type: ProductType,
708    },
709    /// The product has no verified anonymous HTTP(S) mirror.
710    NoOpenMirror {
711        /// Analysis-center code.
712        center: String,
713        /// Product type code.
714        product_type: String,
715    },
716    /// Bad civil date.
717    InvalidDate {
718        /// Year.
719        year: i32,
720        /// Month.
721        month: u8,
722        /// Day.
723        day: u8,
724    },
725    /// Date cannot be represented by this API.
726    DateOutOfRange,
727    /// Date precedes the GPS week epoch.
728    DateBeforeGpsEpoch(ProductDate),
729    /// GPS day-of-week must be `0..=6`.
730    InvalidGpsDayOfWeek(u8),
731    /// Sampling token is not `NNX` with an upper-case unit.
732    InvalidSample(String),
733    /// Issue time is malformed.
734    InvalidIssue(String),
735    /// The center requires an issue time.
736    MissingIssue {
737        /// Analysis center.
738        center: AnalysisCenter,
739    },
740    /// The center does not use issue times.
741    UnexpectedIssue {
742        /// Analysis center.
743        center: AnalysisCenter,
744    },
745    /// Issue time is valid text but not published by this center.
746    UnsupportedIssue {
747        /// Analysis center.
748        center: AnalysisCenter,
749        /// Issue time.
750        issue: String,
751    },
752    /// The target datetime was invalid.
753    InvalidDateTime {
754        /// Hour.
755        hour: u8,
756        /// Minute.
757        minute: u8,
758        /// Second.
759        second: u8,
760    },
761    /// No ultra-rapid issue exists at or before the requested target.
762    NoUltraIssue,
763    /// No available ultra-rapid issue exists at or before the requested target.
764    NoAvailableUltraIssue,
765    /// Station identifier is not a 9-character upper-case alphanumeric token.
766    InvalidStation(String),
767    /// Terrain lookup coordinate is non-finite or outside the reader range.
768    InvalidCoordinate {
769        /// Latitude as `f64::to_bits()`.
770        lat_deg_bits: u64,
771        /// Longitude as `f64::to_bits()`.
772        lon_deg_bits: u64,
773    },
774    /// Terrain tile index is outside the valid one-degree cell range.
775    InvalidTileIndex {
776        /// Latitude index.
777        lat_index: i32,
778        /// Longitude index.
779        lon_index: i32,
780    },
781    /// Skadi tile identifier is malformed.
782    InvalidTileId(String),
783}
784
785impl fmt::Display for DataCatalogError {
786    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
787        match self {
788            Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
789            Self::UnknownProductType(product_type) => {
790                write!(f, "unknown product type {product_type:?}")
791            }
792            Self::UnsupportedProduct {
793                center,
794                product_type,
795            } => write!(f, "{center} does not serve {product_type}"),
796            Self::NoOpenMirror {
797                center,
798                product_type,
799            } => write!(f, "{center}/{product_type} has no open mirror"),
800            Self::InvalidDate { year, month, day } => {
801                write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
802            }
803            Self::DateOutOfRange => write!(f, "product date is out of range"),
804            Self::DateBeforeGpsEpoch(date) => {
805                write!(f, "product date {date} is before the GPS week epoch")
806            }
807            Self::InvalidGpsDayOfWeek(day) => {
808                write!(f, "invalid GPS day-of-week {day}")
809            }
810            Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
811            Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
812            Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
813            Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
814            Self::UnsupportedIssue { center, issue } => {
815                write!(f, "{center} does not publish issue {issue:?}")
816            }
817            Self::InvalidDateTime {
818                hour,
819                minute,
820                second,
821            } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
822            Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
823            Self::NoAvailableUltraIssue => {
824                write!(f, "no available ultra-rapid issue at or before target")
825            }
826            Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
827            Self::InvalidCoordinate {
828                lat_deg_bits,
829                lon_deg_bits,
830            } => write!(
831                f,
832                "invalid terrain coordinate lat={} lon={}",
833                f64::from_bits(*lat_deg_bits),
834                f64::from_bits(*lon_deg_bits)
835            ),
836            Self::InvalidTileIndex {
837                lat_index,
838                lon_index,
839            } => write!(
840                f,
841                "invalid terrain tile index lat={lat_index} lon={lon_index}"
842            ),
843            Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
844        }
845    }
846}
847
848impl std::error::Error for DataCatalogError {}
849
850/// Error returned by SRTM HGT to DTED conversion.
851#[derive(Debug, Clone, PartialEq, Eq)]
852pub enum HgtConversionError {
853    /// The decompressed HGT payload is not the SRTM1 byte length.
854    BadLength {
855        /// Expected byte length.
856        expected: usize,
857        /// Actual byte length.
858        got: usize,
859    },
860    /// Terrain tile index is outside the valid one-degree cell range.
861    InvalidTileIndex {
862        /// Latitude index.
863        lat_index: i32,
864        /// Longitude index.
865        lon_index: i32,
866    },
867}
868
869impl fmt::Display for HgtConversionError {
870    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
871        match self {
872            Self::BadLength { expected, got } => {
873                write!(
874                    f,
875                    "invalid SRTM1 HGT length: expected {expected}, got {got}"
876                )
877            }
878            Self::InvalidTileIndex {
879                lat_index,
880                lon_index,
881            } => write!(
882                f,
883                "invalid terrain tile index lat={lat_index} lon={lon_index}"
884            ),
885        }
886    }
887}
888
889impl std::error::Error for HgtConversionError {}
890
891const MIN_TERRAIN_LAT_INDEX: i32 = -90;
892const MAX_TERRAIN_LAT_INDEX: i32 = 89;
893const MIN_TERRAIN_LON_INDEX: i32 = -180;
894const MAX_TERRAIN_LON_INDEX: i32 = 179;
895const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
896const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
897const MIN_TERRAIN_LON_DEG: f64 = -180.0;
898const MAX_TERRAIN_LON_DEG: f64 = 180.0;
899const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
900const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
901const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
902const DTED_SRTM1_LEN: usize =
903    terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
904
905/// Civil UTC date used by product archive names.
906#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
907pub struct ProductDate {
908    /// Year.
909    pub year: i32,
910    /// Month in `1..=12`.
911    pub month: u8,
912    /// Day of month.
913    pub day: u8,
914}
915
916impl ProductDate {
917    /// Build and validate a civil date.
918    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
919        let days = days_in_month(i64::from(year), i64::from(month));
920        if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
921            return Err(DataCatalogError::InvalidDate { year, month, day });
922        }
923        Ok(Self { year, month, day })
924    }
925
926    /// Build a date from GPS week and day-of-week (`0` = Sunday).
927    pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
928        if day_of_week > 6 {
929            return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
930        }
931        let epoch_jdn =
932            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
933        let offset_days = i64::from(week)
934            .checked_mul(7)
935            .and_then(|days| days.checked_add(i64::from(day_of_week)))
936            .ok_or(DataCatalogError::DateOutOfRange)?;
937        product_date_from_jdn(
938            epoch_jdn
939                .checked_add(offset_days)
940                .ok_or(DataCatalogError::DateOutOfRange)?,
941        )
942    }
943
944    /// GPS week for this date.
945    pub fn gps_week(self) -> Result<u32, DataCatalogError> {
946        week_from_calendar(
947            TimeScale::Gpst,
948            i64::from(self.year),
949            i64::from(self.month),
950            i64::from(self.day),
951        )
952        .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
953    }
954
955    /// Day-of-year in `1..=366`.
956    #[must_use]
957    pub fn day_of_year(self) -> u16 {
958        day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
959    }
960
961    fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
962        product_date_from_jdn(
963            self.julian_day_number()
964                .checked_add(days)
965                .ok_or(DataCatalogError::DateOutOfRange)?,
966        )
967    }
968
969    fn julian_day_number(self) -> i64 {
970        julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
971    }
972}
973
974impl fmt::Display for ProductDate {
975    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
976        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
977    }
978}
979
980/// Civil UTC date and time used for ultra-rapid issue selection.
981#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
982pub struct ProductDateTime {
983    /// Date.
984    pub date: ProductDate,
985    /// Hour in `0..=23`.
986    pub hour: u8,
987    /// Minute in `0..=59`.
988    pub minute: u8,
989    /// Second in `0..=59`.
990    pub second: u8,
991}
992
993impl ProductDateTime {
994    /// Build and validate a civil date and time.
995    pub fn new(
996        date: ProductDate,
997        hour: u8,
998        minute: u8,
999        second: u8,
1000    ) -> Result<Self, DataCatalogError> {
1001        if hour > 23 || minute > 59 || second > 59 {
1002            return Err(DataCatalogError::InvalidDateTime {
1003                hour,
1004                minute,
1005                second,
1006            });
1007        }
1008        Ok(Self {
1009            date,
1010            hour,
1011            minute,
1012            second,
1013        })
1014    }
1015
1016    fn ordering_minutes(self) -> i64 {
1017        self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1018    }
1019}
1020
1021/// Ultra-rapid issue date and `HHMM` issue time.
1022#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1023pub struct UltraIssue {
1024    /// Product date.
1025    pub date: ProductDate,
1026    /// Issue time.
1027    pub issue: String,
1028}
1029
1030impl UltraIssue {
1031    /// Build and validate an ultra-rapid issue.
1032    pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1033        validate_issue(issue)?;
1034        Ok(Self {
1035            date,
1036            issue: issue.to_string(),
1037        })
1038    }
1039}
1040
1041/// One generated ultra-rapid SP3 archive candidate.
1042#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct UltraSp3Location {
1044    /// Stable catalog label identifying the primary, alternate, or alias rule.
1045    pub pattern: String,
1046    /// Product span token used by the candidate.
1047    pub span: String,
1048    /// Sampling token used by the candidate.
1049    pub sample: String,
1050    /// Archive filename without a transport compression suffix.
1051    pub filename: String,
1052    /// Full archive URL, including its compression suffix when applicable.
1053    pub url: String,
1054    /// Archive compression for this candidate.
1055    pub compression: ArchiveCompression,
1056}
1057
1058#[derive(Debug, Clone, Copy)]
1059struct UltraSp3Pattern {
1060    label: &'static str,
1061    span: &'static str,
1062    sample: &'static str,
1063    alias_filename: Option<&'static str>,
1064}
1065
1066const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1067    UltraSp3Pattern {
1068        label: "primary_02D_15M",
1069        span: "02D",
1070        sample: "15M",
1071        alias_filename: None,
1072    },
1073    UltraSp3Pattern {
1074        label: "alternate_02D_05M",
1075        span: "02D",
1076        sample: "05M",
1077        alias_filename: None,
1078    },
1079    UltraSp3Pattern {
1080        label: "alternate_01D_15M",
1081        span: "01D",
1082        sample: "15M",
1083        alias_filename: None,
1084    },
1085];
1086
1087const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1088    UltraSp3Pattern {
1089        label: "primary_01D_05M",
1090        span: "01D",
1091        sample: "05M",
1092        alias_filename: None,
1093    },
1094    UltraSp3Pattern {
1095        label: "alternate_02D_05M",
1096        span: "02D",
1097        sample: "05M",
1098        alias_filename: None,
1099    },
1100    UltraSp3Pattern {
1101        label: "alias_latest",
1102        span: "01D",
1103        sample: "05M",
1104        alias_filename: Some("COD0OPSULT.SP3"),
1105    },
1106];
1107
1108const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1109    UltraSp3Pattern {
1110        label: "primary_02D_05M",
1111        span: "02D",
1112        sample: "05M",
1113        alias_filename: None,
1114    },
1115    UltraSp3Pattern {
1116        label: "alternate_02D_15M",
1117        span: "02D",
1118        sample: "15M",
1119        alias_filename: None,
1120    },
1121    UltraSp3Pattern {
1122        label: "alternate_01D_05M",
1123        span: "01D",
1124        sample: "05M",
1125        alias_filename: None,
1126    },
1127];
1128
1129const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1130    UltraSp3Pattern {
1131        label: "primary_02D_05M",
1132        span: "02D",
1133        sample: "05M",
1134        alias_filename: None,
1135    },
1136    UltraSp3Pattern {
1137        label: "alternate_02D_15M",
1138        span: "02D",
1139        sample: "15M",
1140        alias_filename: None,
1141    },
1142    UltraSp3Pattern {
1143        label: "alternate_01D_05M",
1144        span: "01D",
1145        sample: "05M",
1146        alias_filename: None,
1147    },
1148];
1149
1150/// A pure product specification that resolves to one archive filename and URL.
1151#[derive(Debug, Clone, PartialEq, Eq)]
1152pub struct ProductSpec {
1153    /// Analysis center.
1154    pub center: AnalysisCenter,
1155    /// Product type.
1156    pub product_type: ProductType,
1157    /// Product date.
1158    pub date: ProductDate,
1159    /// Sampling token.
1160    pub sample: String,
1161    /// Optional issue time for ultra-rapid products.
1162    pub issue: Option<String>,
1163}
1164
1165impl ProductSpec {
1166    /// Build a product specification and validate it against the catalog.
1167    pub fn new(
1168        center: AnalysisCenter,
1169        product_type: ProductType,
1170        date: ProductDate,
1171        sample: &str,
1172        issue: Option<&str>,
1173    ) -> Result<Self, DataCatalogError> {
1174        validate_product(center, product_type, sample, issue)?;
1175        Ok(Self {
1176            center,
1177            product_type,
1178            date,
1179            sample: sample.to_string(),
1180            issue: issue.map(ToOwned::to_owned),
1181        })
1182    }
1183
1184    /// GPS week for the product date.
1185    pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1186        self.date.gps_week()
1187    }
1188
1189    /// Day-of-year for the product date.
1190    #[must_use]
1191    pub fn day_of_year(&self) -> u16 {
1192        self.date.day_of_year()
1193    }
1194
1195    /// Canonical IGS long-name filename without archive compression suffix.
1196    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1197        let convention = validate_product(
1198            self.center,
1199            self.product_type,
1200            &self.sample,
1201            self.issue.as_deref(),
1202        )?;
1203        let descriptor = product_type_convention(self.product_type);
1204        Ok(match descriptor.kind {
1205            ProductFilenameKind::Sampled => format!(
1206                "{}_{}_{}_{}_{}.{}",
1207                convention.token,
1208                date_block(self.date, self.issue.as_deref()),
1209                convention.span,
1210                self.sample,
1211                descriptor.content_code,
1212                descriptor.extension
1213            ),
1214            ProductFilenameKind::Nav => format!(
1215                "{}_R_{}_{}_{}.{}",
1216                convention.token,
1217                date_block(self.date, None),
1218                convention.span,
1219                descriptor.content_code,
1220                descriptor.extension
1221            ),
1222        })
1223    }
1224
1225    /// Full archive URL, including `.gz` when the cataloged archive is gzipped.
1226    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1227        let convention = validate_product(
1228            self.center,
1229            self.product_type,
1230            &self.sample,
1231            self.issue.as_deref(),
1232        )?;
1233        let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1234        let filename = self.canonical_filename()?;
1235        Ok(format!(
1236            "{}/{}/{}{}",
1237            entry.root_url,
1238            dir_path(convention.layout, self.date)?,
1239            filename,
1240            convention.compression.suffix()
1241        ))
1242    }
1243}
1244
1245/// A pure station observation specification.
1246#[derive(Debug, Clone, PartialEq, Eq)]
1247pub struct StationObservationSpec {
1248    /// 9-character RINEX 3 site identifier.
1249    pub station: String,
1250    /// Observation date.
1251    pub date: ProductDate,
1252    /// Sampling token.
1253    pub sample: String,
1254}
1255
1256impl StationObservationSpec {
1257    /// Build and validate a daily station observation product.
1258    pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1259        validate_station(station)?;
1260        validate_sample(sample)?;
1261        Ok(Self {
1262            station: station.to_string(),
1263            date,
1264            sample: sample.to_string(),
1265        })
1266    }
1267
1268    /// Canonical RINEX 3 CRINEX filename without archive compression suffix.
1269    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1270        station_obs_filename(&self.station, self.date, &self.sample)
1271    }
1272
1273    /// Full archive URL, including `.gz`.
1274    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1275        station_obs_url(&self.station, self.date, &self.sample)
1276    }
1277}
1278
1279/// Static catalog entries, in the same order as the binding data catalog.
1280#[must_use]
1281pub const fn catalog() -> &'static [CenterCatalogEntry] {
1282    &CATALOG
1283}
1284
1285/// Supported center codes, in catalog order.
1286#[must_use]
1287pub const fn centers() -> &'static [AnalysisCenter] {
1288    &CENTER_ORDER
1289}
1290
1291/// Supported product types.
1292#[must_use]
1293pub const fn product_types() -> &'static [ProductTypeConvention] {
1294    &PRODUCT_TYPE_CONVENTIONS
1295}
1296
1297/// Archive hosts present in the catalog.
1298#[must_use]
1299pub const fn allowed_hosts() -> &'static [&'static str] {
1300    &ALLOWED_HOSTS
1301}
1302
1303/// Catalog entry for the Skadi SRTM terrain source.
1304#[must_use]
1305pub const fn skadi_source_entry() -> TerrainSourceEntry {
1306    SKADI_SOURCE
1307}
1308
1309/// Catalog entry for the CelesTrak CSSI space-weather source.
1310#[must_use]
1311pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1312    CELESTRAK_SPACE_WEATHER_SOURCE
1313}
1314
1315/// Filename for a CelesTrak space-weather product.
1316#[must_use]
1317pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
1318    match product {
1319        SpaceWeatherProduct::All => "SW-All.csv",
1320        SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
1321    }
1322}
1323
1324/// Build the CelesTrak archive URL for a space-weather product.
1325#[must_use]
1326pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
1327    format!(
1328        "{}/{}",
1329        CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
1330        space_weather_filename(product)
1331    )
1332}
1333
1334/// Build the cache relative path for a space-weather product.
1335#[must_use]
1336pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1337    format!("space-weather/{}", space_weather_filename(product))
1338}
1339
1340/// Build the Skadi SRTM tile id, for example `N36W107`.
1341pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1342    validate_terrain_tile_index(lat_index, lon_index)?;
1343    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1344    let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1345    Ok(format!(
1346        "{lat_hemi}{:02}{lon_hemi}{:03}",
1347        lat_index.abs(),
1348        lon_index.abs()
1349    ))
1350}
1351
1352/// Build the Skadi latitude band directory, for example `N36`.
1353pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1354    validate_terrain_lat_index(lat_index)?;
1355    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1356    Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1357}
1358
1359/// Build the Skadi SRTM archive URL for a tile.
1360pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1361    let band = skadi_band(lat_index)?;
1362    let tile_id = skadi_tile_id(lat_index, lon_index)?;
1363    Ok(format!(
1364        "{}/skadi/{}/{}.hgt{}",
1365        SKADI_SOURCE.root_url,
1366        band,
1367        tile_id,
1368        SKADI_SOURCE.compression.suffix()
1369    ))
1370}
1371
1372/// Build the DTED tile filename read by the terrain module.
1373pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1374    validate_terrain_tile_index(lat_index, lon_index)?;
1375    Ok(format!(
1376        "{}_{}{}",
1377        terrain::format_lat(lat_index),
1378        terrain::format_lon(lon_index),
1379        terrain::DTED_SUFFIX
1380    ))
1381}
1382
1383/// Build the DTED ten-degree cache block directory read by the terrain module.
1384pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1385    validate_terrain_tile_index(lat_index, lon_index)?;
1386    Ok(terrain::terrain_block_dir(lat_index, lon_index))
1387}
1388
1389/// Build the DTED cache relative path read by the terrain module.
1390pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1391    Ok(format!(
1392        "{}/{}",
1393        dted_block_dir(lat_index, lon_index)?,
1394        dted_tile_filename(lat_index, lon_index)?
1395    ))
1396}
1397
1398/// Parse a Skadi SRTM tile id into `(lat_index, lon_index)`.
1399pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1400    let bytes = id.as_bytes();
1401    if bytes.len() != 7
1402        || !matches!(bytes[0], b'N' | b'S')
1403        || !matches!(bytes[3], b'E' | b'W')
1404        || !bytes[1..3].iter().all(u8::is_ascii_digit)
1405        || !bytes[4..7].iter().all(u8::is_ascii_digit)
1406    {
1407        return Err(DataCatalogError::InvalidTileId(id.to_string()));
1408    }
1409
1410    let lat_abs = id[1..3]
1411        .parse::<i32>()
1412        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1413    let lon_abs = id[4..7]
1414        .parse::<i32>()
1415        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1416    if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1417        return Err(DataCatalogError::InvalidTileId(id.to_string()));
1418    }
1419
1420    let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1421    let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1422    validate_terrain_tile_index(lat_index, lon_index)?;
1423    Ok((lat_index, lon_index))
1424}
1425
1426/// Derive the terrain tile index covering a latitude/longitude coordinate.
1427pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1428    if !lat_deg.is_finite()
1429        || !lon_deg.is_finite()
1430        || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1431        || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1432    {
1433        return Err(DataCatalogError::InvalidCoordinate {
1434            lat_deg_bits: lat_deg.to_bits(),
1435            lon_deg_bits: lon_deg.to_bits(),
1436        });
1437    }
1438
1439    let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1440    if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1441        lat_index = MAX_TERRAIN_LAT_INDEX;
1442    }
1443    if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1444        lon_index = MAX_TERRAIN_LON_INDEX;
1445    }
1446    validate_terrain_tile_index(lat_index, lon_index)?;
1447    Ok((lat_index, lon_index))
1448}
1449
1450/// Convert decompressed SRTM1 HGT bytes into deterministic DTED `.dt2` bytes.
1451///
1452/// The HGT payload must be 3601 by 3601 big-endian `i16` samples in row-major
1453/// order. HGT rows run north to south; DTED data records are longitude columns
1454/// with postings south to north, so output posting `(i, j)` reads source sample
1455/// `hgt[r = 3600 - i][c = j]`. SRTM void samples (`-32768`) are written as sea
1456/// level (`0`) so the existing terrain reader returns `0` for those postings.
1457pub fn hgt_to_dted(
1458    lat_index: i32,
1459    lon_index: i32,
1460    hgt: &[u8],
1461) -> Result<Vec<u8>, HgtConversionError> {
1462    validate_hgt_tile_index(lat_index, lon_index)?;
1463    if hgt.len() != SRTM1_HGT_LEN {
1464        return Err(HgtConversionError::BadLength {
1465            expected: SRTM1_HGT_LEN,
1466            got: hgt.len(),
1467        });
1468    }
1469
1470    let mut out = vec![b' '; DTED_SRTM1_LEN];
1471    out[0..4].copy_from_slice(b"UHL1");
1472    out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1473    out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1474    out[47..51].copy_from_slice(b"3601");
1475    out[51..55].copy_from_slice(b"3601");
1476
1477    for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1478        let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1479        let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1480        out[block_start] = terrain::DATA_SENTINEL;
1481
1482        let count = (lon_posting as u32).to_be_bytes();
1483        out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1484        out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1485        out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1486
1487        for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1488            let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1489            let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1490            let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1491            let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1492            let dted_sample_start = block_start + 8 + 2 * lat_posting;
1493            out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1494        }
1495
1496        let checksum = out[block_start..checksum_start]
1497            .iter()
1498            .fold(0i32, |acc, byte| acc + i32::from(*byte));
1499        out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1500    }
1501
1502    debug_assert_eq!(out.len(), 25_981_042);
1503    Ok(out)
1504}
1505
1506/// Product pairs intentionally withheld because no open mirror is known.
1507#[must_use]
1508pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
1509    &NO_OPEN_MIRRORS
1510}
1511
1512/// Confirm that a center/product pair has an open catalog mirror.
1513pub fn open_mirror(
1514    center: AnalysisCenter,
1515    product_type: ProductType,
1516) -> Result<(), DataCatalogError> {
1517    open_mirror_code(center.code(), product_type.code())
1518}
1519
1520/// Confirm that a center/product code pair is not in the no-open-mirror list.
1521pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
1522    if NO_OPEN_MIRRORS
1523        .iter()
1524        .any(|entry| entry.center == center && entry.product_type == product_type)
1525    {
1526        Err(DataCatalogError::NoOpenMirror {
1527            center: center.to_string(),
1528            product_type: product_type.to_string(),
1529        })
1530    } else {
1531        Ok(())
1532    }
1533}
1534
1535/// Look up a center's static catalog entry.
1536#[must_use]
1537pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
1538    CATALOG.iter().find(|entry| entry.center == center)
1539}
1540
1541/// Look up the convention for one center and product type.
1542pub fn product_convention(
1543    center: AnalysisCenter,
1544    product_type: ProductType,
1545) -> Result<&'static CenterProductConvention, DataCatalogError> {
1546    open_mirror(center, product_type)?;
1547    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1548    entry
1549        .products
1550        .iter()
1551        .find(|product| product.product_type == product_type)
1552        .ok_or(DataCatalogError::UnsupportedProduct {
1553            center,
1554            product_type,
1555        })
1556}
1557
1558/// Default sampling token for a center/product pair.
1559pub fn default_sample(
1560    center: AnalysisCenter,
1561    product_type: ProductType,
1562) -> Result<&'static str, DataCatalogError> {
1563    Ok(product_convention(center, product_type)?.default_sample)
1564}
1565
1566/// GPS week number for a product date.
1567pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
1568    date.gps_week()
1569}
1570
1571/// Day-of-year in `1..=366` for a product date.
1572#[must_use]
1573pub fn day_of_year(date: ProductDate) -> u16 {
1574    date.day_of_year()
1575}
1576
1577/// Build a product specification for any center/product/date combination.
1578pub fn product(
1579    center: AnalysisCenter,
1580    product_type: ProductType,
1581    date: ProductDate,
1582    sample: Option<&str>,
1583    issue: Option<&str>,
1584) -> Result<ProductSpec, DataCatalogError> {
1585    let sample = match sample {
1586        Some(sample) => sample,
1587        None => default_sample(center, product_type)?,
1588    };
1589    ProductSpec::new(center, product_type, date, sample, issue)
1590}
1591
1592/// Build the canonical IGS long-name filename for a product.
1593pub fn canonical_filename(
1594    center: AnalysisCenter,
1595    product_type: ProductType,
1596    date: ProductDate,
1597    sample: Option<&str>,
1598    issue: Option<&str>,
1599) -> Result<String, DataCatalogError> {
1600    product(center, product_type, date, sample, issue)?.canonical_filename()
1601}
1602
1603/// Build the full archive URL for a product.
1604pub fn archive_url(
1605    center: AnalysisCenter,
1606    product_type: ProductType,
1607    date: ProductDate,
1608    sample: Option<&str>,
1609    issue: Option<&str>,
1610) -> Result<String, DataCatalogError> {
1611    product(center, product_type, date, sample, issue)?.archive_url()
1612}
1613
1614/// Build a clock product for a center and date.
1615pub fn mgex_clk(
1616    center: AnalysisCenter,
1617    date: ProductDate,
1618    sample: Option<&str>,
1619) -> Result<ProductSpec, DataCatalogError> {
1620    product(center, ProductType::Clk, date, sample, None)
1621}
1622
1623/// Build a merged broadcast-navigation product for a center and date.
1624pub fn mgex_nav(
1625    center: AnalysisCenter,
1626    date: ProductDate,
1627    sample: Option<&str>,
1628) -> Result<ProductSpec, DataCatalogError> {
1629    product(center, ProductType::Nav, date, sample, None)
1630}
1631
1632/// Build an IONEX product for a center and date.
1633pub fn mgex_ionex(
1634    center: AnalysisCenter,
1635    date: ProductDate,
1636    sample: Option<&str>,
1637) -> Result<ProductSpec, DataCatalogError> {
1638    product(center, ProductType::Ionex, date, sample, None)
1639}
1640
1641/// Build the CODE rapid IONEX product for a date.
1642pub fn rapid_ionex(
1643    date: ProductDate,
1644    sample: Option<&str>,
1645) -> Result<ProductSpec, DataCatalogError> {
1646    product(
1647        AnalysisCenter::CodRap,
1648        ProductType::Ionex,
1649        date,
1650        sample,
1651        None,
1652    )
1653}
1654
1655/// Day offset for predicted IONEX aliases.
1656#[must_use]
1657pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
1658    match center {
1659        AnalysisCenter::CodPrd2 => 1,
1660        _ => 0,
1661    }
1662}
1663
1664/// Build a CODE predicted IONEX product for a target date.
1665pub fn predicted_ionex(
1666    center: AnalysisCenter,
1667    date: ProductDate,
1668    sample: Option<&str>,
1669) -> Result<ProductSpec, DataCatalogError> {
1670    match center {
1671        AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
1672            let target = date.add_days(predicted_day_offset(center))?;
1673            product(center, ProductType::Ionex, target, sample, None)
1674        }
1675        other => Err(DataCatalogError::UnsupportedProduct {
1676            center: other,
1677            product_type: ProductType::Ionex,
1678        }),
1679    }
1680}
1681
1682/// Build an SP3 product for a center and date.
1683pub fn mgex_sp3(
1684    center: AnalysisCenter,
1685    date: ProductDate,
1686    sample: Option<&str>,
1687) -> Result<ProductSpec, DataCatalogError> {
1688    product(center, ProductType::Sp3, date, sample, None)
1689}
1690
1691/// Build an ultra-rapid OPS SP3 product for a date and issue time.
1692pub fn ops_ultra_sp3(
1693    center: AnalysisCenter,
1694    date: ProductDate,
1695    sample: Option<&str>,
1696    issue: Option<&str>,
1697) -> Result<ProductSpec, DataCatalogError> {
1698    let issue = issue.unwrap_or("0000");
1699    product(center, ProductType::Sp3, date, sample, Some(issue))
1700}
1701
1702/// Generate the current primary ultra-rapid SP3 location followed by known
1703/// duration/sampling alternates and documented latest-product aliases.
1704///
1705/// Candidate order is deterministic and center-specific. Callers should try
1706/// the next location only when the prior archive URL is absent; transport and
1707/// retry policy remain outside the pure core catalog.
1708pub fn ultra_sp3_locations(
1709    center: AnalysisCenter,
1710    date: ProductDate,
1711    issue: &str,
1712) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
1713    validate_issue_for_center(center, Some(issue))?;
1714    let patterns: &[UltraSp3Pattern] = match center {
1715        AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
1716        AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
1717        AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
1718        AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
1719        other => {
1720            return Err(DataCatalogError::UnsupportedProduct {
1721                center: other,
1722                product_type: ProductType::Sp3,
1723            })
1724        }
1725    };
1726    let convention = product_convention(center, ProductType::Sp3)?;
1727    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1728    let directory = dir_path(convention.layout, date)?;
1729    let date = date_block(date, Some(issue));
1730
1731    Ok(patterns
1732        .iter()
1733        .map(|pattern| {
1734            let filename = pattern.alias_filename.map_or_else(
1735                || {
1736                    format!(
1737                        "{}_{}_{}_{}_ORB.SP3",
1738                        convention.token, date, pattern.span, pattern.sample
1739                    )
1740                },
1741                ToOwned::to_owned,
1742            );
1743            UltraSp3Location {
1744                pattern: pattern.label.to_string(),
1745                span: pattern.span.to_string(),
1746                sample: pattern.sample.to_string(),
1747                url: format!(
1748                    "{}/{}/{}{}",
1749                    entry.root_url,
1750                    directory,
1751                    filename,
1752                    convention.compression.suffix()
1753                ),
1754                filename,
1755                compression: convention.compression,
1756            }
1757        })
1758        .collect())
1759}
1760
1761/// Build an ultra-rapid OPS clock product for a date and issue time.
1762pub fn ops_ultra_clk(
1763    center: AnalysisCenter,
1764    date: ProductDate,
1765    sample: Option<&str>,
1766    issue: Option<&str>,
1767) -> Result<ProductSpec, DataCatalogError> {
1768    let issue = issue.unwrap_or("0000");
1769    product(center, ProductType::Clk, date, sample, Some(issue))
1770}
1771
1772/// Select the latest ultra-rapid OPS SP3 issue at or before a target time.
1773pub fn latest_ops_ultra_sp3(
1774    center: AnalysisCenter,
1775    target: ProductDateTime,
1776    sample: Option<&str>,
1777    available_issues: Option<&[UltraIssue]>,
1778) -> Result<ProductSpec, DataCatalogError> {
1779    let selected = latest_ultra_issue(center, target, available_issues)?;
1780    ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
1781}
1782
1783/// Candidate ultra-rapid issues at or before a target time, newest first.
1784pub fn ultra_issue_candidates(
1785    center: AnalysisCenter,
1786    target: ProductDateTime,
1787) -> Result<Vec<UltraIssue>, DataCatalogError> {
1788    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1789    let _ = product_convention(center, ProductType::Sp3)?;
1790    if entry.issues.is_empty() {
1791        return Err(DataCatalogError::UnsupportedProduct {
1792            center,
1793            product_type: ProductType::Sp3,
1794        });
1795    }
1796
1797    let mut candidates = Vec::new();
1798    for date in [target.date, target.date.add_days(-1)?] {
1799        for issue in entry.issues.iter().rev() {
1800            if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
1801                candidates.push(UltraIssue::new(date, issue)?);
1802            }
1803        }
1804    }
1805    Ok(candidates)
1806}
1807
1808/// Latest ultra-rapid issue at or before a target time.
1809pub fn latest_ultra_issue(
1810    center: AnalysisCenter,
1811    target: ProductDateTime,
1812    available_issues: Option<&[UltraIssue]>,
1813) -> Result<UltraIssue, DataCatalogError> {
1814    let candidates = ultra_issue_candidates(center, target)?;
1815    if candidates.is_empty() {
1816        return Err(DataCatalogError::NoUltraIssue);
1817    }
1818    if let Some(available) = available_issues {
1819        candidates
1820            .into_iter()
1821            .find(|candidate| {
1822                available
1823                    .iter()
1824                    .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
1825            })
1826            .ok_or(DataCatalogError::NoAvailableUltraIssue)
1827    } else {
1828        Ok(candidates[0].clone())
1829    }
1830}
1831
1832/// Candidate IONEX dates at or before a target date, newest first.
1833pub fn gim_date_candidates(
1834    center: AnalysisCenter,
1835    target: ProductDate,
1836    lookback: u32,
1837) -> Result<Vec<ProductDate>, DataCatalogError> {
1838    let _ = product_convention(center, ProductType::Ionex)?;
1839    let base = target.add_days(predicted_day_offset(center))?;
1840    let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
1841    for back in 0..=lookback {
1842        out.push(base.add_days(-i64::from(back))?);
1843    }
1844    Ok(out)
1845}
1846
1847/// Build a daily station observation product.
1848pub fn station_obs(
1849    station: &str,
1850    date: ProductDate,
1851    sample: Option<&str>,
1852) -> Result<StationObservationSpec, DataCatalogError> {
1853    StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
1854}
1855
1856/// Build the canonical RINEX 3 CRINEX filename for a daily station observation.
1857pub fn station_obs_filename(
1858    station: &str,
1859    date: ProductDate,
1860    sample: &str,
1861) -> Result<String, DataCatalogError> {
1862    validate_station(station)?;
1863    validate_sample(sample)?;
1864    Ok(format!(
1865        "{}_R_{}_01D_{}_MO.crx",
1866        station,
1867        date_block(date, None),
1868        sample
1869    ))
1870}
1871
1872/// Build the full BKG IGS archive URL for a daily station observation.
1873pub fn station_obs_url(
1874    station: &str,
1875    date: ProductDate,
1876    sample: &str,
1877) -> Result<String, DataCatalogError> {
1878    let filename = station_obs_filename(station, date, sample)?;
1879    Ok(format!(
1880        "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
1881        dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
1882        filename
1883    ))
1884}
1885
1886/// The transfer protocol for the daily station observation archive.
1887#[must_use]
1888pub const fn station_obs_protocol() -> ArchiveProtocol {
1889    ArchiveProtocol::Https
1890}
1891
1892fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
1893    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
1894        Ok(())
1895    } else {
1896        Err(DataCatalogError::InvalidTileIndex {
1897            lat_index,
1898            lon_index: 0,
1899        })
1900    }
1901}
1902
1903fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
1904    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1905        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1906    {
1907        Ok(())
1908    } else {
1909        Err(DataCatalogError::InvalidTileIndex {
1910            lat_index,
1911            lon_index,
1912        })
1913    }
1914}
1915
1916fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
1917    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1918        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1919    {
1920        Ok(())
1921    } else {
1922        Err(HgtConversionError::InvalidTileIndex {
1923            lat_index,
1924            lon_index,
1925        })
1926    }
1927}
1928
1929fn dted_coord_field(index: i32, is_longitude: bool) -> String {
1930    let hemi = match (is_longitude, index >= 0) {
1931        (true, true) => 'E',
1932        (true, false) => 'W',
1933        (false, true) => 'N',
1934        (false, false) => 'S',
1935    };
1936    format!("{:03}0000{hemi}", index.abs())
1937}
1938
1939fn encode_dted_signed_magnitude(sample: i16) -> u16 {
1940    if sample == i16::MIN {
1941        0
1942    } else if sample >= 0 {
1943        sample as u16
1944    } else {
1945        0x8000 | (-i32::from(sample) as u16)
1946    }
1947}
1948
1949fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
1950    PRODUCT_TYPE_CONVENTIONS
1951        .iter()
1952        .find(|descriptor| descriptor.product_type == product_type)
1953        .expect("product descriptor exists for enum variant")
1954}
1955
1956fn validate_product(
1957    center: AnalysisCenter,
1958    product_type: ProductType,
1959    sample: &str,
1960    issue: Option<&str>,
1961) -> Result<&'static CenterProductConvention, DataCatalogError> {
1962    let convention = product_convention(center, product_type)?;
1963    validate_sample(sample)?;
1964    validate_issue_for_center(center, issue)?;
1965    Ok(convention)
1966}
1967
1968fn validate_issue_for_center(
1969    center: AnalysisCenter,
1970    issue: Option<&str>,
1971) -> Result<(), DataCatalogError> {
1972    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1973    match (entry.issues.is_empty(), issue) {
1974        (true, None) => Ok(()),
1975        (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
1976        (false, None) => Err(DataCatalogError::MissingIssue { center }),
1977        (false, Some(issue)) => {
1978            validate_issue(issue)?;
1979            if entry.issues.contains(&issue) {
1980                Ok(())
1981            } else {
1982                Err(DataCatalogError::UnsupportedIssue {
1983                    center,
1984                    issue: issue.to_string(),
1985                })
1986            }
1987        }
1988    }
1989}
1990
1991fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
1992    let bytes = sample.as_bytes();
1993    let valid = bytes.len() == 3
1994        && bytes[0].is_ascii_digit()
1995        && bytes[1].is_ascii_digit()
1996        && bytes[2].is_ascii_uppercase();
1997    if valid {
1998        Ok(())
1999    } else {
2000        Err(DataCatalogError::InvalidSample(sample.to_string()))
2001    }
2002}
2003
2004fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2005    let bytes = issue.as_bytes();
2006    let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2007    if !valid_digits {
2008        return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2009    }
2010    let hour = issue[0..2]
2011        .parse::<u8>()
2012        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2013    let minute = issue[2..4]
2014        .parse::<u8>()
2015        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2016    if hour <= 23 && minute <= 59 {
2017        Ok(())
2018    } else {
2019        Err(DataCatalogError::InvalidIssue(issue.to_string()))
2020    }
2021}
2022
2023fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2024    let bytes = station.as_bytes();
2025    let valid = bytes.len() == 9
2026        && bytes
2027            .iter()
2028            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2029    if valid {
2030        Ok(())
2031    } else {
2032        Err(DataCatalogError::InvalidStation(station.to_string()))
2033    }
2034}
2035
2036fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2037    validate_issue(issue)?;
2038    let hour = issue[0..2]
2039        .parse::<u16>()
2040        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2041    let minute = issue[2..4]
2042        .parse::<u16>()
2043        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2044    Ok(hour * 60 + minute)
2045}
2046
2047fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2048    Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2049}
2050
2051fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2052    format!(
2053        "{}{:03}{}",
2054        date.year,
2055        date.day_of_year(),
2056        issue.unwrap_or("0000")
2057    )
2058}
2059
2060fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2061    Ok(match layout {
2062        ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2063        ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2064        ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2065        ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2066        ArchiveLayout::BkgBrdcYearDoy => {
2067            format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2068        }
2069        ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2070        ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2071        ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2072        ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2073    })
2074}
2075
2076fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2077    let (year, month, day) = civil_from_julian_day_number(jdn);
2078    let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2079    let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2080    let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2081    ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2082}