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