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