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    /// Public publisher represented by this catalog product line.
83    #[must_use]
84    pub const fn publisher(self) -> ProductPublisher {
85        match self {
86            Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
87            Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
88                ProductPublisher::Code
89            }
90            Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
91            Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
92        }
93    }
94
95    /// Public solution class represented by this catalog product line.
96    #[must_use]
97    pub const fn solution_class(self) -> SolutionClass {
98        match self {
99            Self::Igs => SolutionClass::Broadcast,
100            Self::CodRap | Self::Gfz => SolutionClass::Rapid,
101            Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
102            Self::Esa | Self::Cod => SolutionClass::Final,
103            Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
104        }
105    }
106
107    /// Prediction horizon associated with a predicted product alias.
108    #[must_use]
109    pub const fn prediction_horizon_days(self) -> Option<u8> {
110        match self {
111            Self::CodPrd1 => Some(1),
112            Self::CodPrd2 => Some(2),
113            _ => None,
114        }
115    }
116}
117
118impl fmt::Display for AnalysisCenter {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        f.write_str(self.code())
121    }
122}
123
124impl FromStr for AnalysisCenter {
125    type Err = DataCatalogError;
126
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
129    }
130}
131
132/// Product type supported by the data-product catalog.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
134pub enum ProductType {
135    /// Precise orbit SP3.
136    Sp3,
137    /// RINEX clock.
138    Clk,
139    /// Merged broadcast navigation.
140    Nav,
141    /// IONEX global ionosphere map.
142    Ionex,
143}
144
145impl ProductType {
146    /// The lower-case product code.
147    #[must_use]
148    pub const fn code(self) -> &'static str {
149        match self {
150            Self::Sp3 => "sp3",
151            Self::Clk => "clk",
152            Self::Nav => "nav",
153            Self::Ionex => "ionex",
154        }
155    }
156
157    /// Parse a lower-case product code.
158    #[must_use]
159    pub fn from_code(code: &str) -> Option<Self> {
160        match code {
161            "sp3" => Some(Self::Sp3),
162            "clk" => Some(Self::Clk),
163            "nav" => Some(Self::Nav),
164            "ionex" => Some(Self::Ionex),
165            _ => None,
166        }
167    }
168}
169
170impl fmt::Display for ProductType {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(self.code())
173    }
174}
175
176impl FromStr for ProductType {
177    type Err = DataCatalogError;
178
179    fn from_str(s: &str) -> Result<Self, Self::Err> {
180        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
181    }
182}
183
184/// Public organization that produced or combined a GNSS product.
185///
186/// This is intentionally separate from [`AnalysisCenter`]. Catalog center
187/// codes such as `cod`, `cod_rap`, and `cod_ult` select different product
188/// lines, but all three have the same publisher.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
190pub enum ProductPublisher {
191    /// International GNSS Service combination product.
192    Igs,
193    /// Center for Orbit Determination in Europe (CODE).
194    Code,
195    /// European Space Agency.
196    Esa,
197    /// GFZ German Research Centre for Geosciences.
198    Gfz,
199}
200
201impl ProductPublisher {
202    /// IGS long-filename publisher token.
203    #[must_use]
204    pub const fn code(self) -> &'static str {
205        match self {
206            Self::Igs => "IGS",
207            Self::Code => "COD",
208            Self::Esa => "ESA",
209            Self::Gfz => "GFZ",
210        }
211    }
212}
213
214impl fmt::Display for ProductPublisher {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        f.write_str(self.code())
217    }
218}
219
220/// Public solution class encoded in a GNSS product name.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
222pub enum SolutionClass {
223    /// Final product.
224    Final,
225    /// Rapid product.
226    Rapid,
227    /// Ultra-rapid product, which may contain observed and predicted segments.
228    UltraRapid,
229    /// Predicted product.
230    Predicted,
231    /// Broadcast navigation product.
232    Broadcast,
233}
234
235impl SolutionClass {
236    /// Stable public code used in Sidereon provenance and cache paths.
237    #[must_use]
238    pub const fn code(self) -> &'static str {
239        match self {
240            Self::Final => "final",
241            Self::Rapid => "rapid",
242            Self::UltraRapid => "ultra_rapid",
243            Self::Predicted => "predicted",
244            Self::Broadcast => "broadcast",
245        }
246    }
247
248    /// IGS long-filename solution token where one exists.
249    #[must_use]
250    pub const fn filename_token(self) -> Option<&'static str> {
251        match self {
252            Self::Final => Some("FIN"),
253            Self::Rapid => Some("RAP"),
254            Self::UltraRapid => Some("ULT"),
255            Self::Predicted => Some("PRD"),
256            Self::Broadcast => None,
257        }
258    }
259}
260
261impl fmt::Display for SolutionClass {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        f.write_str(self.code())
264    }
265}
266
267/// Public campaign or project encoded in a GNSS product name.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
269pub enum ProductCampaign {
270    /// Operational IGS product line.
271    Operational,
272    /// Multi-GNSS product line (`MGN`).
273    MultiGnss,
274    /// Multi-GNSS Experiment product line (`MGX`).
275    MultiGnssExperiment,
276    /// Broadcast navigation archive product.
277    Broadcast,
278}
279
280impl ProductCampaign {
281    /// Stable public code.
282    #[must_use]
283    pub const fn code(self) -> &'static str {
284        match self {
285            Self::Operational => "OPS",
286            Self::MultiGnss => "MGN",
287            Self::MultiGnssExperiment => "MGX",
288            Self::Broadcast => "BRD",
289        }
290    }
291}
292
293/// Public serialization format carried by a catalog product.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
295pub enum ProductFormat {
296    /// Standard Product 3 orbit format.
297    Sp3,
298    /// IONosphere map EXchange format.
299    Ionex,
300    /// RINEX clock format.
301    RinexClock,
302    /// RINEX navigation format.
303    RinexNavigation,
304}
305
306impl ProductFormat {
307    /// Stable public format code.
308    #[must_use]
309    pub const fn code(self) -> &'static str {
310        match self {
311            Self::Sp3 => "SP3",
312            Self::Ionex => "IONEX",
313            Self::RinexClock => "RINEX_CLK",
314            Self::RinexNavigation => "RINEX_NAV",
315        }
316    }
317}
318
319/// Explicit distributor used to obtain an exact public product.
320///
321/// Distributor selection never changes product publisher, solution class,
322/// issue, cadence, date, or family.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
324pub enum DistributionSource {
325    /// The cataloged analysis-center or IGS direct archive.
326    Direct,
327    /// NASA CDDIS over HTTPS, optionally authenticated with Earthdata Login.
328    NasaCddis,
329    /// Bytes read from a caller-provided local file.
330    LocalFile,
331    /// Bytes supplied directly by the caller.
332    InMemory,
333}
334
335impl DistributionSource {
336    /// Stable public code used in provenance and cache paths.
337    #[must_use]
338    pub const fn code(self) -> &'static str {
339        match self {
340            Self::Direct => "direct",
341            Self::NasaCddis => "nasa_cddis",
342            Self::LocalFile => "local_file",
343            Self::InMemory => "in_memory",
344        }
345    }
346}
347
348/// CelesTrak space-weather product served by the data catalog.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
350pub enum SpaceWeatherProduct {
351    /// `SW-All.csv`: full history plus daily and monthly predictions.
352    All,
353    /// `SW-Last5Years.csv`: observed rolling window.
354    Last5Years,
355}
356
357impl SpaceWeatherProduct {
358    /// The lower-case catalog code.
359    #[must_use]
360    pub const fn code(self) -> &'static str {
361        match self {
362            Self::All => "sw_all",
363            Self::Last5Years => "sw_last5",
364        }
365    }
366
367    /// Parse a lower-case catalog code.
368    #[must_use]
369    pub fn from_code(code: &str) -> Option<Self> {
370        match code {
371            "sw_all" => Some(Self::All),
372            "sw_last5" => Some(Self::Last5Years),
373            _ => None,
374        }
375    }
376}
377
378impl fmt::Display for SpaceWeatherProduct {
379    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
380        f.write_str(self.code())
381    }
382}
383
384impl FromStr for SpaceWeatherProduct {
385    type Err = DataCatalogError;
386
387    fn from_str(s: &str) -> Result<Self, Self::Err> {
388        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
389    }
390}
391
392/// Archive transport protocol recorded by the catalog.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
394pub enum ArchiveProtocol {
395    /// HTTP.
396    Http,
397    /// HTTPS.
398    Https,
399}
400
401impl ArchiveProtocol {
402    /// URI scheme text.
403    #[must_use]
404    pub const fn as_str(self) -> &'static str {
405        match self {
406            Self::Http => "http",
407            Self::Https => "https",
408        }
409    }
410}
411
412/// Archive compression for a cataloged product.
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
414pub enum ArchiveCompression {
415    /// Archive URL has a `.gz` suffix.
416    Gzip,
417    /// Archive URL is the plain product filename.
418    None,
419}
420
421impl ArchiveCompression {
422    /// Catalog text for the compression format.
423    #[must_use]
424    pub const fn as_str(self) -> &'static str {
425        match self {
426            Self::Gzip => "gzip",
427            Self::None => "none",
428        }
429    }
430
431    const fn suffix(self) -> &'static str {
432        match self {
433            Self::Gzip => ".gz",
434            Self::None => "",
435        }
436    }
437}
438
439/// Directory layout used below an archive root.
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
441pub enum ArchiveLayout {
442    /// `rapid/w<gps-week>`.
443    GfzRapidWeek,
444    /// `ultra/w<gps-week>`.
445    GfzUltraWeek,
446    /// `<gps-week>`.
447    GpsWeek,
448    /// `products/<gps-week>`.
449    BkgProductsWeek,
450    /// `BRDC/<year>/<day-of-year>`.
451    BkgBrdcYearDoy,
452    /// `obs/<year>/<day-of-year>`.
453    BkgObsYearDoy,
454    /// `CODE_MGEX/CODE/<year>`.
455    AiubCodeMgexYear,
456    /// `CODE/<year>`.
457    AiubCodeYear,
458    /// `CODE`.
459    AiubCodeRoot,
460}
461
462/// Product filename convention.
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum ProductFilenameKind {
465    /// `TOKEN_DATE_LEN_SAMPLE_CODE.EXT`.
466    Sampled,
467    /// `TOKEN_R_DATE_LEN_CODE.ext`.
468    Nav,
469}
470
471/// Product-type filename convention.
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub struct ProductTypeConvention {
474    /// Product type.
475    pub product_type: ProductType,
476    /// Filename content code, for example `ORB`.
477    pub content_code: &'static str,
478    /// Filename extension, preserving archive case.
479    pub extension: &'static str,
480    /// Filename convention.
481    pub kind: ProductFilenameKind,
482}
483
484/// Per-center convention for one product type.
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub struct CenterProductConvention {
487    /// Product type.
488    pub product_type: ProductType,
489    /// IGS long-name token prefix.
490    pub token: &'static str,
491    /// Directory layout under the archive root.
492    pub layout: ArchiveLayout,
493    /// Product span token.
494    pub span: &'static str,
495    /// Default sampling token.
496    pub default_sample: &'static str,
497    /// Archive compression.
498    pub compression: ArchiveCompression,
499}
500
501/// Static catalog entry for one analysis-center code.
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub struct CenterCatalogEntry {
504    /// Analysis-center code.
505    pub center: AnalysisCenter,
506    /// Lower-case catalog code.
507    pub code: &'static str,
508    /// Archive URI scheme.
509    pub protocol: ArchiveProtocol,
510    /// Archive host.
511    pub host: &'static str,
512    /// Archive root URL without trailing slash.
513    pub root_url: &'static str,
514    /// Product conventions served by this center.
515    pub products: &'static [CenterProductConvention],
516    /// Valid issue times for sub-daily products.
517    pub issues: &'static [&'static str],
518}
519
520/// Static catalog entry for one terrain source.
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522pub struct TerrainSourceEntry {
523    /// Archive URI scheme.
524    pub protocol: ArchiveProtocol,
525    /// Archive host.
526    pub host: &'static str,
527    /// Archive compression.
528    pub compression: ArchiveCompression,
529    /// Archive root URL without trailing slash.
530    pub root_url: &'static str,
531}
532
533/// Static catalog entry for the CelesTrak space-weather source.
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub struct SpaceWeatherSourceEntry {
536    /// Archive URI scheme.
537    pub protocol: ArchiveProtocol,
538    /// Archive host.
539    pub host: &'static str,
540    /// Archive compression.
541    pub compression: ArchiveCompression,
542    /// Archive root URL without trailing slash.
543    pub root_url: &'static str,
544}
545
546/// Product pair that is intentionally not offered because no open mirror exists.
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
548pub struct NoOpenMirrorProduct {
549    /// Analysis-center code.
550    pub center: &'static str,
551    /// Product type code.
552    pub product_type: &'static str,
553}
554
555const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
556    ProductTypeConvention {
557        product_type: ProductType::Sp3,
558        content_code: "ORB",
559        extension: "SP3",
560        kind: ProductFilenameKind::Sampled,
561    },
562    ProductTypeConvention {
563        product_type: ProductType::Clk,
564        content_code: "CLK",
565        extension: "CLK",
566        kind: ProductFilenameKind::Sampled,
567    },
568    ProductTypeConvention {
569        product_type: ProductType::Nav,
570        content_code: "MN",
571        extension: "rnx",
572        kind: ProductFilenameKind::Nav,
573    },
574    ProductTypeConvention {
575        product_type: ProductType::Ionex,
576        content_code: "GIM",
577        extension: "INX",
578        kind: ProductFilenameKind::Sampled,
579    },
580];
581
582const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
583    product_type: ProductType::Ionex,
584    token: "COD0OPSRAP",
585    layout: ArchiveLayout::AiubCodeRoot,
586    span: "01D",
587    default_sample: "01H",
588    compression: ArchiveCompression::Gzip,
589}];
590
591const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
592    product_type: ProductType::Ionex,
593    token: "COD0OPSPRD",
594    layout: ArchiveLayout::AiubCodeRoot,
595    span: "01D",
596    default_sample: "01H",
597    compression: ArchiveCompression::Gzip,
598}];
599
600const ESA_PRODUCTS: [CenterProductConvention; 3] = [
601    CenterProductConvention {
602        product_type: ProductType::Sp3,
603        token: "ESA0MGNFIN",
604        layout: ArchiveLayout::GpsWeek,
605        span: "01D",
606        default_sample: "05M",
607        compression: ArchiveCompression::Gzip,
608    },
609    CenterProductConvention {
610        product_type: ProductType::Clk,
611        token: "ESA0MGNFIN",
612        layout: ArchiveLayout::GpsWeek,
613        span: "01D",
614        default_sample: "30S",
615        compression: ArchiveCompression::Gzip,
616    },
617    CenterProductConvention {
618        product_type: ProductType::Ionex,
619        token: "ESA0OPSFIN",
620        layout: ArchiveLayout::GpsWeek,
621        span: "01D",
622        default_sample: "02H",
623        compression: ArchiveCompression::Gzip,
624    },
625];
626
627const COD_PRODUCTS: [CenterProductConvention; 3] = [
628    CenterProductConvention {
629        product_type: ProductType::Sp3,
630        token: "COD0MGXFIN",
631        layout: ArchiveLayout::AiubCodeMgexYear,
632        span: "01D",
633        default_sample: "05M",
634        compression: ArchiveCompression::Gzip,
635    },
636    CenterProductConvention {
637        product_type: ProductType::Clk,
638        token: "COD0MGXFIN",
639        layout: ArchiveLayout::AiubCodeMgexYear,
640        span: "01D",
641        default_sample: "30S",
642        compression: ArchiveCompression::Gzip,
643    },
644    CenterProductConvention {
645        product_type: ProductType::Ionex,
646        token: "COD0OPSFIN",
647        layout: ArchiveLayout::AiubCodeYear,
648        span: "01D",
649        default_sample: "01H",
650        compression: ArchiveCompression::Gzip,
651    },
652];
653
654const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
655    CenterProductConvention {
656        product_type: ProductType::Sp3,
657        token: "GFZ0OPSRAP",
658        layout: ArchiveLayout::GfzRapidWeek,
659        span: "01D",
660        default_sample: "15M",
661        compression: ArchiveCompression::Gzip,
662    },
663    CenterProductConvention {
664        product_type: ProductType::Clk,
665        token: "GFZ0OPSRAP",
666        layout: ArchiveLayout::GfzRapidWeek,
667        span: "01D",
668        default_sample: "30S",
669        compression: ArchiveCompression::Gzip,
670    },
671];
672
673const IGS_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
674    product_type: ProductType::Nav,
675    token: "BRDC00WRD",
676    layout: ArchiveLayout::BkgBrdcYearDoy,
677    span: "01D",
678    default_sample: "01D",
679    compression: ArchiveCompression::Gzip,
680}];
681
682const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
683    product_type: ProductType::Sp3,
684    token: "IGS0OPSULT",
685    layout: ArchiveLayout::BkgProductsWeek,
686    span: "02D",
687    default_sample: "15M",
688    compression: ArchiveCompression::Gzip,
689}];
690
691const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
692    product_type: ProductType::Sp3,
693    token: "COD0OPSULT",
694    layout: ArchiveLayout::AiubCodeRoot,
695    span: "01D",
696    default_sample: "05M",
697    compression: ArchiveCompression::None,
698}];
699
700const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
701    product_type: ProductType::Sp3,
702    token: "ESA0OPSULT",
703    layout: ArchiveLayout::GpsWeek,
704    span: "02D",
705    default_sample: "05M",
706    compression: ArchiveCompression::Gzip,
707}];
708
709const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
710    product_type: ProductType::Sp3,
711    token: "GFZ0OPSULT",
712    layout: ArchiveLayout::GfzUltraWeek,
713    span: "02D",
714    default_sample: "05M",
715    compression: ArchiveCompression::Gzip,
716}];
717
718const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
719const COD_ULT_ISSUES: [&str; 1] = ["0000"];
720const GFZ_ULT_ISSUES: [&str; 8] = [
721    "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
722];
723
724const CENTER_ORDER: [AnalysisCenter; 11] = [
725    AnalysisCenter::CodRap,
726    AnalysisCenter::CodPrd1,
727    AnalysisCenter::CodPrd2,
728    AnalysisCenter::Igs,
729    AnalysisCenter::Esa,
730    AnalysisCenter::Cod,
731    AnalysisCenter::Gfz,
732    AnalysisCenter::IgsUlt,
733    AnalysisCenter::CodUlt,
734    AnalysisCenter::EsaUlt,
735    AnalysisCenter::GfzUlt,
736];
737
738const CATALOG: [CenterCatalogEntry; 11] = [
739    CenterCatalogEntry {
740        center: AnalysisCenter::CodRap,
741        code: "cod_rap",
742        protocol: ArchiveProtocol::Http,
743        host: "ftp.aiub.unibe.ch",
744        root_url: "http://ftp.aiub.unibe.ch",
745        products: &COD_RAP_PRODUCTS,
746        issues: &[],
747    },
748    CenterCatalogEntry {
749        center: AnalysisCenter::CodPrd1,
750        code: "cod_prd1",
751        protocol: ArchiveProtocol::Https,
752        host: "www.aiub.unibe.ch",
753        root_url: "https://www.aiub.unibe.ch/download",
754        products: &COD_PRD_PRODUCTS,
755        issues: &[],
756    },
757    CenterCatalogEntry {
758        center: AnalysisCenter::CodPrd2,
759        code: "cod_prd2",
760        protocol: ArchiveProtocol::Https,
761        host: "www.aiub.unibe.ch",
762        root_url: "https://www.aiub.unibe.ch/download",
763        products: &COD_PRD_PRODUCTS,
764        issues: &[],
765    },
766    CenterCatalogEntry {
767        center: AnalysisCenter::Igs,
768        code: "igs",
769        protocol: ArchiveProtocol::Https,
770        host: "igs.bkg.bund.de",
771        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
772        products: &IGS_PRODUCTS,
773        issues: &[],
774    },
775    CenterCatalogEntry {
776        center: AnalysisCenter::Esa,
777        code: "esa",
778        protocol: ArchiveProtocol::Https,
779        host: "navigation-office.esa.int",
780        root_url: "https://navigation-office.esa.int/products/gnss-products",
781        products: &ESA_PRODUCTS,
782        issues: &[],
783    },
784    CenterCatalogEntry {
785        center: AnalysisCenter::Cod,
786        code: "cod",
787        protocol: ArchiveProtocol::Http,
788        host: "ftp.aiub.unibe.ch",
789        root_url: "http://ftp.aiub.unibe.ch",
790        products: &COD_PRODUCTS,
791        issues: &[],
792    },
793    CenterCatalogEntry {
794        center: AnalysisCenter::Gfz,
795        code: "gfz",
796        protocol: ArchiveProtocol::Https,
797        host: "isdc-data.gfz.de",
798        root_url: "https://isdc-data.gfz.de/gnss/products",
799        products: &GFZ_PRODUCTS,
800        issues: &[],
801    },
802    CenterCatalogEntry {
803        center: AnalysisCenter::IgsUlt,
804        code: "igs_ult",
805        protocol: ArchiveProtocol::Https,
806        host: "igs.bkg.bund.de",
807        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
808        products: &IGS_ULT_PRODUCTS,
809        issues: &OPSULT_ISSUES,
810    },
811    CenterCatalogEntry {
812        center: AnalysisCenter::CodUlt,
813        code: "cod_ult",
814        protocol: ArchiveProtocol::Https,
815        host: "www.aiub.unibe.ch",
816        // AIUB retired the old ftp.aiub.unibe.ch HTTP tree. Its public file
817        // browser links products through this stable HTTPS download surface,
818        // which redirects to the current object store.
819        root_url: "https://www.aiub.unibe.ch/download",
820        products: &COD_ULT_PRODUCTS,
821        issues: &COD_ULT_ISSUES,
822    },
823    CenterCatalogEntry {
824        center: AnalysisCenter::EsaUlt,
825        code: "esa_ult",
826        protocol: ArchiveProtocol::Https,
827        host: "navigation-office.esa.int",
828        root_url: "https://navigation-office.esa.int/products/gnss-products",
829        products: &ESA_ULT_PRODUCTS,
830        issues: &OPSULT_ISSUES,
831    },
832    CenterCatalogEntry {
833        center: AnalysisCenter::GfzUlt,
834        code: "gfz_ult",
835        protocol: ArchiveProtocol::Https,
836        host: "isdc-data.gfz.de",
837        root_url: "https://isdc-data.gfz.de/gnss/products",
838        products: &GFZ_ULT_PRODUCTS,
839        issues: &GFZ_ULT_ISSUES,
840    },
841];
842
843const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
844    protocol: ArchiveProtocol::Https,
845    host: "s3.amazonaws.com",
846    compression: ArchiveCompression::Gzip,
847    root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
848};
849
850const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
851    protocol: ArchiveProtocol::Https,
852    host: "celestrak.org",
853    compression: ArchiveCompression::None,
854    root_url: "https://celestrak.org/SpaceData",
855};
856
857const ALLOWED_HOSTS: [&str; 11] = [
858    "ftp.aiub.unibe.ch",
859    "www.aiub.unibe.ch",
860    "download.aiub.unibe.ch",
861    "zhw-b.s3.cloud.switch.ch",
862    "navigation-office.esa.int",
863    "isdc-data.gfz.de",
864    "igs.bkg.bund.de",
865    "s3.amazonaws.com",
866    "celestrak.org",
867    "cddis.nasa.gov",
868    "urs.earthdata.nasa.gov",
869];
870
871const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
872    NoOpenMirrorProduct {
873        center: "grg",
874        product_type: "sp3",
875    },
876    NoOpenMirrorProduct {
877        center: "grg",
878        product_type: "clk",
879    },
880    NoOpenMirrorProduct {
881        center: "wum",
882        product_type: "sp3",
883    },
884    NoOpenMirrorProduct {
885        center: "wum",
886        product_type: "clk",
887    },
888    NoOpenMirrorProduct {
889        center: "grg_ult",
890        product_type: "sp3",
891    },
892    NoOpenMirrorProduct {
893        center: "grg_ult",
894        product_type: "clk",
895    },
896    NoOpenMirrorProduct {
897        center: "igs",
898        product_type: "ionex",
899    },
900];
901
902/// Error returned by the pure data-product catalog.
903#[derive(Debug, Clone, PartialEq, Eq)]
904pub enum DataCatalogError {
905    /// Unknown analysis-center code.
906    UnknownCenter(String),
907    /// Unknown product type code.
908    UnknownProductType(String),
909    /// The center does not serve the requested product type.
910    UnsupportedProduct {
911        /// Analysis center.
912        center: AnalysisCenter,
913        /// Product type.
914        product_type: ProductType,
915    },
916    /// A distributor does not carry the requested product family.
917    UnsupportedDistribution {
918        /// Explicit distributor.
919        source: DistributionSource,
920        /// Requested product family.
921        product_type: ProductType,
922    },
923    /// An exact request did not include any acceptable distributor.
924    NoDistributionSources,
925    /// A caller-constructed identity contained an unsafe official filename.
926    InvalidOfficialFilename(String),
927    /// A caller-constructed identity disagrees with its official filename.
928    InconsistentProductIdentity {
929        /// Identity field that did not agree with the filename or catalog convention.
930        field: &'static str,
931    },
932    /// The product has no verified anonymous HTTP(S) mirror.
933    NoOpenMirror {
934        /// Analysis-center code.
935        center: String,
936        /// Product type code.
937        product_type: String,
938    },
939    /// Bad civil date.
940    InvalidDate {
941        /// Year.
942        year: i32,
943        /// Month.
944        month: u8,
945        /// Day.
946        day: u8,
947    },
948    /// Date cannot be represented by this API.
949    DateOutOfRange,
950    /// Date precedes the GPS week epoch.
951    DateBeforeGpsEpoch(ProductDate),
952    /// GPS day-of-week must be `0..=6`.
953    InvalidGpsDayOfWeek(u8),
954    /// Sampling token is not `NNX` with an upper-case unit.
955    InvalidSample(String),
956    /// Issue time is malformed.
957    InvalidIssue(String),
958    /// The center requires an issue time.
959    MissingIssue {
960        /// Analysis center.
961        center: AnalysisCenter,
962    },
963    /// The center does not use issue times.
964    UnexpectedIssue {
965        /// Analysis center.
966        center: AnalysisCenter,
967    },
968    /// Issue time is valid text but not published by this center.
969    UnsupportedIssue {
970        /// Analysis center.
971        center: AnalysisCenter,
972        /// Issue time.
973        issue: String,
974    },
975    /// The target datetime was invalid.
976    InvalidDateTime {
977        /// Hour.
978        hour: u8,
979        /// Minute.
980        minute: u8,
981        /// Second.
982        second: u8,
983    },
984    /// No ultra-rapid issue exists at or before the requested target.
985    NoUltraIssue,
986    /// No available ultra-rapid issue exists at or before the requested target.
987    NoAvailableUltraIssue,
988    /// Station identifier is not a 9-character upper-case alphanumeric token.
989    InvalidStation(String),
990    /// Terrain lookup coordinate is non-finite or outside the reader range.
991    InvalidCoordinate {
992        /// Latitude as `f64::to_bits()`.
993        lat_deg_bits: u64,
994        /// Longitude as `f64::to_bits()`.
995        lon_deg_bits: u64,
996    },
997    /// Terrain tile index is outside the valid one-degree cell range.
998    InvalidTileIndex {
999        /// Latitude index.
1000        lat_index: i32,
1001        /// Longitude index.
1002        lon_index: i32,
1003    },
1004    /// Skadi tile identifier is malformed.
1005    InvalidTileId(String),
1006}
1007
1008impl fmt::Display for DataCatalogError {
1009    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1010        match self {
1011            Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1012            Self::UnknownProductType(product_type) => {
1013                write!(f, "unknown product type {product_type:?}")
1014            }
1015            Self::UnsupportedProduct {
1016                center,
1017                product_type,
1018            } => write!(f, "{center} does not serve {product_type}"),
1019            Self::UnsupportedDistribution {
1020                source,
1021                product_type,
1022            } => write!(
1023                f,
1024                "distributor {} does not serve {product_type}",
1025                source.code()
1026            ),
1027            Self::NoDistributionSources => {
1028                write!(f, "exact product request has no distributors")
1029            }
1030            Self::InvalidOfficialFilename(filename) => {
1031                write!(f, "invalid official product filename {filename:?}")
1032            }
1033            Self::InconsistentProductIdentity { field } => {
1034                write!(
1035                    f,
1036                    "product identity field {field:?} disagrees with its official filename"
1037                )
1038            }
1039            Self::NoOpenMirror {
1040                center,
1041                product_type,
1042            } => write!(f, "{center}/{product_type} has no open mirror"),
1043            Self::InvalidDate { year, month, day } => {
1044                write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1045            }
1046            Self::DateOutOfRange => write!(f, "product date is out of range"),
1047            Self::DateBeforeGpsEpoch(date) => {
1048                write!(f, "product date {date} is before the GPS week epoch")
1049            }
1050            Self::InvalidGpsDayOfWeek(day) => {
1051                write!(f, "invalid GPS day-of-week {day}")
1052            }
1053            Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1054            Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1055            Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1056            Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1057            Self::UnsupportedIssue { center, issue } => {
1058                write!(f, "{center} does not publish issue {issue:?}")
1059            }
1060            Self::InvalidDateTime {
1061                hour,
1062                minute,
1063                second,
1064            } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1065            Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1066            Self::NoAvailableUltraIssue => {
1067                write!(f, "no available ultra-rapid issue at or before target")
1068            }
1069            Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1070            Self::InvalidCoordinate {
1071                lat_deg_bits,
1072                lon_deg_bits,
1073            } => write!(
1074                f,
1075                "invalid terrain coordinate lat={} lon={}",
1076                f64::from_bits(*lat_deg_bits),
1077                f64::from_bits(*lon_deg_bits)
1078            ),
1079            Self::InvalidTileIndex {
1080                lat_index,
1081                lon_index,
1082            } => write!(
1083                f,
1084                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1085            ),
1086            Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1087        }
1088    }
1089}
1090
1091impl std::error::Error for DataCatalogError {}
1092
1093/// Error returned by SRTM HGT to DTED conversion.
1094#[derive(Debug, Clone, PartialEq, Eq)]
1095pub enum HgtConversionError {
1096    /// The decompressed HGT payload is not the SRTM1 byte length.
1097    BadLength {
1098        /// Expected byte length.
1099        expected: usize,
1100        /// Actual byte length.
1101        got: usize,
1102    },
1103    /// Terrain tile index is outside the valid one-degree cell range.
1104    InvalidTileIndex {
1105        /// Latitude index.
1106        lat_index: i32,
1107        /// Longitude index.
1108        lon_index: i32,
1109    },
1110}
1111
1112impl fmt::Display for HgtConversionError {
1113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1114        match self {
1115            Self::BadLength { expected, got } => {
1116                write!(
1117                    f,
1118                    "invalid SRTM1 HGT length: expected {expected}, got {got}"
1119                )
1120            }
1121            Self::InvalidTileIndex {
1122                lat_index,
1123                lon_index,
1124            } => write!(
1125                f,
1126                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1127            ),
1128        }
1129    }
1130}
1131
1132impl std::error::Error for HgtConversionError {}
1133
1134const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1135const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1136const MIN_TERRAIN_LON_INDEX: i32 = -180;
1137const MAX_TERRAIN_LON_INDEX: i32 = 179;
1138const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1139const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1140const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1141const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1142const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1143const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1144const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1145const DTED_SRTM1_LEN: usize =
1146    terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1147
1148/// Civil UTC date used by product archive names.
1149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1150pub struct ProductDate {
1151    /// Year.
1152    pub year: i32,
1153    /// Month in `1..=12`.
1154    pub month: u8,
1155    /// Day of month.
1156    pub day: u8,
1157}
1158
1159impl ProductDate {
1160    /// Build and validate a civil date.
1161    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1162        let days = days_in_month(i64::from(year), i64::from(month));
1163        if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1164            return Err(DataCatalogError::InvalidDate { year, month, day });
1165        }
1166        Ok(Self { year, month, day })
1167    }
1168
1169    /// Build a date from GPS week and day-of-week (`0` = Sunday).
1170    pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1171        if day_of_week > 6 {
1172            return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1173        }
1174        let epoch_jdn =
1175            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1176        let offset_days = i64::from(week)
1177            .checked_mul(7)
1178            .and_then(|days| days.checked_add(i64::from(day_of_week)))
1179            .ok_or(DataCatalogError::DateOutOfRange)?;
1180        product_date_from_jdn(
1181            epoch_jdn
1182                .checked_add(offset_days)
1183                .ok_or(DataCatalogError::DateOutOfRange)?,
1184        )
1185    }
1186
1187    /// GPS week for this date.
1188    pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1189        week_from_calendar(
1190            TimeScale::Gpst,
1191            i64::from(self.year),
1192            i64::from(self.month),
1193            i64::from(self.day),
1194        )
1195        .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1196    }
1197
1198    /// Day-of-year in `1..=366`.
1199    #[must_use]
1200    pub fn day_of_year(self) -> u16 {
1201        day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1202    }
1203
1204    fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1205        product_date_from_jdn(
1206            self.julian_day_number()
1207                .checked_add(days)
1208                .ok_or(DataCatalogError::DateOutOfRange)?,
1209        )
1210    }
1211
1212    fn julian_day_number(self) -> i64 {
1213        julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1214    }
1215}
1216
1217impl fmt::Display for ProductDate {
1218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1219        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1220    }
1221}
1222
1223/// Civil UTC date and time used for ultra-rapid issue selection.
1224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1225pub struct ProductDateTime {
1226    /// Date.
1227    pub date: ProductDate,
1228    /// Hour in `0..=23`.
1229    pub hour: u8,
1230    /// Minute in `0..=59`.
1231    pub minute: u8,
1232    /// Second in `0..=59`.
1233    pub second: u8,
1234}
1235
1236impl ProductDateTime {
1237    /// Build and validate a civil date and time.
1238    pub fn new(
1239        date: ProductDate,
1240        hour: u8,
1241        minute: u8,
1242        second: u8,
1243    ) -> Result<Self, DataCatalogError> {
1244        if hour > 23 || minute > 59 || second > 59 {
1245            return Err(DataCatalogError::InvalidDateTime {
1246                hour,
1247                minute,
1248                second,
1249            });
1250        }
1251        Ok(Self {
1252            date,
1253            hour,
1254            minute,
1255            second,
1256        })
1257    }
1258
1259    fn ordering_minutes(self) -> i64 {
1260        self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1261    }
1262}
1263
1264/// Ultra-rapid issue date and `HHMM` issue time.
1265#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1266pub struct UltraIssue {
1267    /// Product date.
1268    pub date: ProductDate,
1269    /// Issue time.
1270    pub issue: String,
1271}
1272
1273impl UltraIssue {
1274    /// Build and validate an ultra-rapid issue.
1275    pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1276        validate_issue(issue)?;
1277        Ok(Self {
1278            date,
1279            issue: issue.to_string(),
1280        })
1281    }
1282}
1283
1284/// One generated ultra-rapid SP3 archive candidate.
1285#[derive(Debug, Clone, PartialEq, Eq)]
1286pub struct UltraSp3Location {
1287    /// Stable catalog label identifying the primary, alternate, or alias rule.
1288    pub pattern: String,
1289    /// Product span token used by the candidate.
1290    pub span: String,
1291    /// Sampling token used by the candidate.
1292    pub sample: String,
1293    /// Archive filename without a transport compression suffix.
1294    pub filename: String,
1295    /// Full archive URL, including its compression suffix when applicable.
1296    pub url: String,
1297    /// Archive compression for this candidate.
1298    pub compression: ArchiveCompression,
1299}
1300
1301#[derive(Debug, Clone, Copy)]
1302struct UltraSp3Pattern {
1303    label: &'static str,
1304    span: &'static str,
1305    sample: &'static str,
1306    alias_filename: Option<&'static str>,
1307}
1308
1309const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1310    UltraSp3Pattern {
1311        label: "primary_02D_15M",
1312        span: "02D",
1313        sample: "15M",
1314        alias_filename: None,
1315    },
1316    UltraSp3Pattern {
1317        label: "alternate_02D_05M",
1318        span: "02D",
1319        sample: "05M",
1320        alias_filename: None,
1321    },
1322    UltraSp3Pattern {
1323        label: "alternate_01D_15M",
1324        span: "01D",
1325        sample: "15M",
1326        alias_filename: None,
1327    },
1328];
1329
1330const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1331    UltraSp3Pattern {
1332        label: "primary_01D_05M",
1333        span: "01D",
1334        sample: "05M",
1335        alias_filename: None,
1336    },
1337    UltraSp3Pattern {
1338        label: "alternate_02D_05M",
1339        span: "02D",
1340        sample: "05M",
1341        alias_filename: None,
1342    },
1343    UltraSp3Pattern {
1344        label: "alias_latest",
1345        span: "01D",
1346        sample: "05M",
1347        alias_filename: Some("COD0OPSULT.SP3"),
1348    },
1349];
1350
1351const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1352    UltraSp3Pattern {
1353        label: "primary_02D_05M",
1354        span: "02D",
1355        sample: "05M",
1356        alias_filename: None,
1357    },
1358    UltraSp3Pattern {
1359        label: "alternate_02D_15M",
1360        span: "02D",
1361        sample: "15M",
1362        alias_filename: None,
1363    },
1364    UltraSp3Pattern {
1365        label: "alternate_01D_05M",
1366        span: "01D",
1367        sample: "05M",
1368        alias_filename: None,
1369    },
1370];
1371
1372const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1373    UltraSp3Pattern {
1374        label: "primary_02D_05M",
1375        span: "02D",
1376        sample: "05M",
1377        alias_filename: None,
1378    },
1379    UltraSp3Pattern {
1380        label: "alternate_02D_15M",
1381        span: "02D",
1382        sample: "15M",
1383        alias_filename: None,
1384    },
1385    UltraSp3Pattern {
1386        label: "alternate_01D_05M",
1387        span: "01D",
1388        sample: "05M",
1389        alias_filename: None,
1390    },
1391];
1392
1393/// Exact identity of one public GNSS product, independent of distributor.
1394///
1395/// The official filename is part of the identity. Transport compression and
1396/// URL belong to [`DistributionLocation`] because two distributors may package
1397/// the same decompressed product differently.
1398#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1399pub struct ProductIdentity {
1400    /// Product family.
1401    pub family: ProductType,
1402    /// Producing or combining organization.
1403    pub publisher: ProductPublisher,
1404    /// Solution class or tier.
1405    pub solution: SolutionClass,
1406    /// Campaign or project.
1407    pub campaign: ProductCampaign,
1408    /// Product-line version encoded by the long filename.
1409    pub version: u8,
1410    /// Nominal product start date.
1411    pub date: ProductDate,
1412    /// Optional `HHMM` issue/start time.
1413    pub issue: Option<String>,
1414    /// Intended coverage period token, for example `01D`.
1415    pub span: String,
1416    /// Sampling interval token, for example `05M`.
1417    pub sample: String,
1418    /// Official filename without transport compression suffix.
1419    pub official_filename: String,
1420    /// Public serialization format.
1421    pub format: ProductFormat,
1422    /// Prediction horizon when the product line encodes one.
1423    pub prediction_horizon_days: Option<u8>,
1424}
1425
1426impl ProductIdentity {
1427    /// Validate that every identity field agrees with the official filename.
1428    ///
1429    /// This is required for caller-constructed values before using them in a
1430    /// request, URL, or cache path. Catalog-produced identities are validated
1431    /// before they are returned.
1432    pub fn validate(&self) -> Result<(), DataCatalogError> {
1433        validate_official_filename(&self.official_filename)?;
1434        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1435        validate_sample(&self.sample)?;
1436        if let Some(issue) = self.issue.as_deref() {
1437            validate_issue(issue)?;
1438        }
1439
1440        if self.format != product_format(self.family) {
1441            return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1442        }
1443
1444        let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1445            (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1446            (_, SolutionClass::Predicted, _) => false,
1447            (_, _, None) => true,
1448            (_, _, Some(_)) => false,
1449        };
1450        if !horizon_valid {
1451            return Err(DataCatalogError::InconsistentProductIdentity {
1452                field: "prediction_horizon_days",
1453            });
1454        }
1455
1456        let descriptor = product_type_convention(self.family);
1457        let expected = match descriptor.kind {
1458            ProductFilenameKind::Sampled => {
1459                let solution_token = self
1460                    .solution
1461                    .filename_token()
1462                    .ok_or(DataCatalogError::InconsistentProductIdentity { field: "solution" })?;
1463                format!(
1464                    "{}{}{}{}_{}_{}_{}_{}.{}",
1465                    self.publisher.code(),
1466                    self.version,
1467                    self.campaign.code(),
1468                    solution_token,
1469                    date_block(self.date, self.issue.as_deref()),
1470                    self.span,
1471                    self.sample,
1472                    descriptor.content_code,
1473                    descriptor.extension
1474                )
1475            }
1476            ProductFilenameKind::Nav => {
1477                let nav_fields_valid = self.publisher == ProductPublisher::Igs
1478                    && self.solution == SolutionClass::Broadcast
1479                    && self.campaign == ProductCampaign::Broadcast
1480                    && self.version == 0
1481                    && self.issue.is_none()
1482                    && self.span == "01D"
1483                    && self.sample == "01D";
1484                if !nav_fields_valid {
1485                    return Err(DataCatalogError::InconsistentProductIdentity {
1486                        field: "broadcast_navigation",
1487                    });
1488                }
1489                format!(
1490                    "BRDC00WRD_R_{}_{}_{}.{}",
1491                    date_block(self.date, None),
1492                    self.span,
1493                    descriptor.content_code,
1494                    descriptor.extension
1495                )
1496            }
1497        };
1498        if expected != self.official_filename {
1499            return Err(DataCatalogError::InconsistentProductIdentity {
1500                field: "official_filename",
1501            });
1502        }
1503        Ok(())
1504    }
1505
1506    /// Deterministic identity key suitable for a portable cache layout.
1507    pub fn key(&self) -> Result<String, DataCatalogError> {
1508        self.validate()?;
1509        let prediction = self.prediction_horizon_days.map_or_else(
1510            || "observed".to_string(),
1511            |days| format!("prediction_{days}d"),
1512        );
1513        Ok(format!(
1514            "{}/{}/{}/{}/{}/{}",
1515            self.family.code(),
1516            self.publisher.code().to_ascii_lowercase(),
1517            self.solution.code(),
1518            self.campaign.code().to_ascii_lowercase(),
1519            prediction,
1520            self.official_filename
1521        ))
1522    }
1523
1524    /// Deterministic cache path for this identity and distributor.
1525    pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1526        Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1527    }
1528}
1529
1530/// Distribution metadata for an exact product identity.
1531#[derive(Debug, Clone, PartialEq, Eq)]
1532pub struct DistributionLocation {
1533    /// Selected distributor.
1534    pub source: DistributionSource,
1535    /// Original public URL. Local and in-memory sources have no URL.
1536    pub original_url: Option<String>,
1537    /// Archive filename as served, including transport compression suffix.
1538    pub archive_filename: String,
1539    /// Compression applied by this distributor.
1540    pub compression: ArchiveCompression,
1541}
1542
1543/// Exact product request with an ordered, caller-controlled distributor list.
1544#[derive(Debug, Clone, PartialEq, Eq)]
1545pub struct ProductRequest {
1546    /// Exact requested identity.
1547    pub identity: ProductIdentity,
1548    /// Ordered acceptable distributors for that identity only.
1549    pub distributors: Vec<DistributionSource>,
1550}
1551
1552impl ProductRequest {
1553    /// Build an exact request. At least one distributor is required.
1554    pub fn new(
1555        identity: ProductIdentity,
1556        distributors: Vec<DistributionSource>,
1557    ) -> Result<Self, DataCatalogError> {
1558        if distributors.is_empty() {
1559            return Err(DataCatalogError::NoDistributionSources);
1560        }
1561        identity.validate()?;
1562        Ok(Self {
1563            identity,
1564            distributors,
1565        })
1566    }
1567}
1568
1569/// A pure product specification that resolves to one archive filename and URL.
1570#[derive(Debug, Clone, PartialEq, Eq)]
1571pub struct ProductSpec {
1572    /// Analysis center.
1573    pub center: AnalysisCenter,
1574    /// Product type.
1575    pub product_type: ProductType,
1576    /// Product date.
1577    pub date: ProductDate,
1578    /// Sampling token.
1579    pub sample: String,
1580    /// Optional issue time for ultra-rapid products.
1581    pub issue: Option<String>,
1582}
1583
1584impl ProductSpec {
1585    /// Build a product specification and validate it against the catalog.
1586    pub fn new(
1587        center: AnalysisCenter,
1588        product_type: ProductType,
1589        date: ProductDate,
1590        sample: &str,
1591        issue: Option<&str>,
1592    ) -> Result<Self, DataCatalogError> {
1593        validate_product(center, product_type, sample, issue)?;
1594        Ok(Self {
1595            center,
1596            product_type,
1597            date,
1598            sample: sample.to_string(),
1599            issue: issue.map(ToOwned::to_owned),
1600        })
1601    }
1602
1603    /// GPS week for the product date.
1604    pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1605        self.date.gps_week()
1606    }
1607
1608    /// Day-of-year for the product date.
1609    #[must_use]
1610    pub fn day_of_year(&self) -> u16 {
1611        self.date.day_of_year()
1612    }
1613
1614    /// Canonical IGS long-name filename without archive compression suffix.
1615    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1616        let convention = validate_product(
1617            self.center,
1618            self.product_type,
1619            &self.sample,
1620            self.issue.as_deref(),
1621        )?;
1622        let descriptor = product_type_convention(self.product_type);
1623        Ok(match descriptor.kind {
1624            ProductFilenameKind::Sampled => format!(
1625                "{}_{}_{}_{}_{}.{}",
1626                convention.token,
1627                date_block(self.date, self.issue.as_deref()),
1628                convention.span,
1629                self.sample,
1630                descriptor.content_code,
1631                descriptor.extension
1632            ),
1633            ProductFilenameKind::Nav => format!(
1634                "{}_R_{}_{}_{}.{}",
1635                convention.token,
1636                date_block(self.date, None),
1637                convention.span,
1638                descriptor.content_code,
1639                descriptor.extension
1640            ),
1641        })
1642    }
1643
1644    /// Full archive URL, including `.gz` when the cataloged archive is gzipped.
1645    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1646        let convention = validate_product(
1647            self.center,
1648            self.product_type,
1649            &self.sample,
1650            self.issue.as_deref(),
1651        )?;
1652        let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1653        let filename = self.canonical_filename()?;
1654        Ok(format!(
1655            "{}/{}/{}{}",
1656            entry.root_url,
1657            product_dir_path(self.center, convention.layout, self.date)?,
1658            filename,
1659            convention.compression.suffix()
1660        ))
1661    }
1662
1663    /// Exact product identity, independent of distributor.
1664    pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
1665        let convention = validate_product(
1666            self.center,
1667            self.product_type,
1668            &self.sample,
1669            self.issue.as_deref(),
1670        )?;
1671        let descriptor = product_type_convention(self.product_type);
1672        let campaign = match descriptor.kind {
1673            ProductFilenameKind::Nav => ProductCampaign::Broadcast,
1674            ProductFilenameKind::Sampled => match convention.token.get(4..7) {
1675                Some("OPS") => ProductCampaign::Operational,
1676                Some("MGN") => ProductCampaign::MultiGnss,
1677                Some("MGX") => ProductCampaign::MultiGnssExperiment,
1678                _ => {
1679                    return Err(DataCatalogError::InconsistentProductIdentity {
1680                        field: "campaign",
1681                    });
1682                }
1683            },
1684        };
1685        let identity = ProductIdentity {
1686            family: self.product_type,
1687            publisher: self.center.publisher(),
1688            solution: self.center.solution_class(),
1689            campaign,
1690            version: 0,
1691            date: self.date,
1692            issue: self.issue.clone(),
1693            span: convention.span.to_string(),
1694            sample: self.sample.clone(),
1695            official_filename: self.canonical_filename()?,
1696            format: product_format(self.product_type),
1697            prediction_horizon_days: self.center.prediction_horizon_days(),
1698        };
1699        identity.validate()?;
1700        Ok(identity)
1701    }
1702
1703    /// Resolve one explicit distributor without changing product identity.
1704    pub fn distribution_location(
1705        &self,
1706        source: DistributionSource,
1707    ) -> Result<DistributionLocation, DataCatalogError> {
1708        let identity = self.identity()?;
1709        match source {
1710            DistributionSource::Direct => {
1711                let compression = product_convention(self.center, self.product_type)?.compression;
1712                Ok(DistributionLocation {
1713                    source,
1714                    original_url: Some(self.archive_url()?),
1715                    archive_filename: format!(
1716                        "{}{}",
1717                        identity.official_filename,
1718                        compression.suffix()
1719                    ),
1720                    compression,
1721                })
1722            }
1723            DistributionSource::NasaCddis => {
1724                let url = cddis_archive_url(&identity)?;
1725                Ok(DistributionLocation {
1726                    source,
1727                    original_url: Some(url),
1728                    archive_filename: format!("{}.gz", identity.official_filename),
1729                    compression: ArchiveCompression::Gzip,
1730                })
1731            }
1732            DistributionSource::LocalFile | DistributionSource::InMemory => {
1733                Ok(DistributionLocation {
1734                    source,
1735                    original_url: None,
1736                    archive_filename: identity.official_filename,
1737                    compression: ArchiveCompression::None,
1738                })
1739            }
1740        }
1741    }
1742}
1743
1744/// A pure station observation specification.
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct StationObservationSpec {
1747    /// 9-character RINEX 3 site identifier.
1748    pub station: String,
1749    /// Observation date.
1750    pub date: ProductDate,
1751    /// Sampling token.
1752    pub sample: String,
1753}
1754
1755impl StationObservationSpec {
1756    /// Build and validate a daily station observation product.
1757    pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1758        validate_station(station)?;
1759        validate_sample(sample)?;
1760        Ok(Self {
1761            station: station.to_string(),
1762            date,
1763            sample: sample.to_string(),
1764        })
1765    }
1766
1767    /// Canonical RINEX 3 CRINEX filename without archive compression suffix.
1768    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1769        station_obs_filename(&self.station, self.date, &self.sample)
1770    }
1771
1772    /// Full archive URL, including `.gz`.
1773    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1774        station_obs_url(&self.station, self.date, &self.sample)
1775    }
1776}
1777
1778/// Static catalog entries, in the same order as the binding data catalog.
1779#[must_use]
1780pub const fn catalog() -> &'static [CenterCatalogEntry] {
1781    &CATALOG
1782}
1783
1784/// Supported center codes, in catalog order.
1785#[must_use]
1786pub const fn centers() -> &'static [AnalysisCenter] {
1787    &CENTER_ORDER
1788}
1789
1790/// Supported product types.
1791#[must_use]
1792pub const fn product_types() -> &'static [ProductTypeConvention] {
1793    &PRODUCT_TYPE_CONVENTIONS
1794}
1795
1796/// Archive hosts present in the catalog.
1797#[must_use]
1798pub const fn allowed_hosts() -> &'static [&'static str] {
1799    &ALLOWED_HOSTS
1800}
1801
1802/// Catalog entry for the Skadi SRTM terrain source.
1803#[must_use]
1804pub const fn skadi_source_entry() -> TerrainSourceEntry {
1805    SKADI_SOURCE
1806}
1807
1808/// Catalog entry for the CelesTrak CSSI space-weather source.
1809#[must_use]
1810pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1811    CELESTRAK_SPACE_WEATHER_SOURCE
1812}
1813
1814/// Filename for a CelesTrak space-weather product.
1815#[must_use]
1816pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
1817    match product {
1818        SpaceWeatherProduct::All => "SW-All.csv",
1819        SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
1820    }
1821}
1822
1823/// Build the CelesTrak archive URL for a space-weather product.
1824#[must_use]
1825pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
1826    format!(
1827        "{}/{}",
1828        CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
1829        space_weather_filename(product)
1830    )
1831}
1832
1833/// Build the cache relative path for a space-weather product.
1834#[must_use]
1835pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1836    format!("space-weather/{}", space_weather_filename(product))
1837}
1838
1839/// Build the Skadi SRTM tile id, for example `N36W107`.
1840pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1841    validate_terrain_tile_index(lat_index, lon_index)?;
1842    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1843    let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1844    Ok(format!(
1845        "{lat_hemi}{:02}{lon_hemi}{:03}",
1846        lat_index.abs(),
1847        lon_index.abs()
1848    ))
1849}
1850
1851/// Build the Skadi latitude band directory, for example `N36`.
1852pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1853    validate_terrain_lat_index(lat_index)?;
1854    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1855    Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1856}
1857
1858/// Build the Skadi SRTM archive URL for a tile.
1859pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1860    let band = skadi_band(lat_index)?;
1861    let tile_id = skadi_tile_id(lat_index, lon_index)?;
1862    Ok(format!(
1863        "{}/skadi/{}/{}.hgt{}",
1864        SKADI_SOURCE.root_url,
1865        band,
1866        tile_id,
1867        SKADI_SOURCE.compression.suffix()
1868    ))
1869}
1870
1871/// Build the DTED tile filename read by the terrain module.
1872pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1873    validate_terrain_tile_index(lat_index, lon_index)?;
1874    Ok(format!(
1875        "{}_{}{}",
1876        terrain::format_lat(lat_index),
1877        terrain::format_lon(lon_index),
1878        terrain::DTED_SUFFIX
1879    ))
1880}
1881
1882/// Build the DTED ten-degree cache block directory read by the terrain module.
1883pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1884    validate_terrain_tile_index(lat_index, lon_index)?;
1885    Ok(terrain::terrain_block_dir(lat_index, lon_index))
1886}
1887
1888/// Build the DTED cache relative path read by the terrain module.
1889pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1890    Ok(format!(
1891        "{}/{}",
1892        dted_block_dir(lat_index, lon_index)?,
1893        dted_tile_filename(lat_index, lon_index)?
1894    ))
1895}
1896
1897/// Parse a Skadi SRTM tile id into `(lat_index, lon_index)`.
1898pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1899    let bytes = id.as_bytes();
1900    if bytes.len() != 7
1901        || !matches!(bytes[0], b'N' | b'S')
1902        || !matches!(bytes[3], b'E' | b'W')
1903        || !bytes[1..3].iter().all(u8::is_ascii_digit)
1904        || !bytes[4..7].iter().all(u8::is_ascii_digit)
1905    {
1906        return Err(DataCatalogError::InvalidTileId(id.to_string()));
1907    }
1908
1909    let lat_abs = id[1..3]
1910        .parse::<i32>()
1911        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1912    let lon_abs = id[4..7]
1913        .parse::<i32>()
1914        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1915    if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1916        return Err(DataCatalogError::InvalidTileId(id.to_string()));
1917    }
1918
1919    let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1920    let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1921    validate_terrain_tile_index(lat_index, lon_index)?;
1922    Ok((lat_index, lon_index))
1923}
1924
1925/// Derive the terrain tile index covering a latitude/longitude coordinate.
1926pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1927    if !lat_deg.is_finite()
1928        || !lon_deg.is_finite()
1929        || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1930        || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1931    {
1932        return Err(DataCatalogError::InvalidCoordinate {
1933            lat_deg_bits: lat_deg.to_bits(),
1934            lon_deg_bits: lon_deg.to_bits(),
1935        });
1936    }
1937
1938    let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1939    if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1940        lat_index = MAX_TERRAIN_LAT_INDEX;
1941    }
1942    if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1943        lon_index = MAX_TERRAIN_LON_INDEX;
1944    }
1945    validate_terrain_tile_index(lat_index, lon_index)?;
1946    Ok((lat_index, lon_index))
1947}
1948
1949/// Convert decompressed SRTM1 HGT bytes into deterministic DTED `.dt2` bytes.
1950///
1951/// The HGT payload must be 3601 by 3601 big-endian `i16` samples in row-major
1952/// order. HGT rows run north to south; DTED data records are longitude columns
1953/// with postings south to north, so output posting `(i, j)` reads source sample
1954/// `hgt[r = 3600 - i][c = j]`. SRTM void samples (`-32768`) are written as sea
1955/// level (`0`) so the existing terrain reader returns `0` for those postings.
1956pub fn hgt_to_dted(
1957    lat_index: i32,
1958    lon_index: i32,
1959    hgt: &[u8],
1960) -> Result<Vec<u8>, HgtConversionError> {
1961    validate_hgt_tile_index(lat_index, lon_index)?;
1962    if hgt.len() != SRTM1_HGT_LEN {
1963        return Err(HgtConversionError::BadLength {
1964            expected: SRTM1_HGT_LEN,
1965            got: hgt.len(),
1966        });
1967    }
1968
1969    let mut out = vec![b' '; DTED_SRTM1_LEN];
1970    out[0..4].copy_from_slice(b"UHL1");
1971    out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1972    out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1973    out[47..51].copy_from_slice(b"3601");
1974    out[51..55].copy_from_slice(b"3601");
1975
1976    for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1977        let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1978        let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1979        out[block_start] = terrain::DATA_SENTINEL;
1980
1981        let count = (lon_posting as u32).to_be_bytes();
1982        out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1983        out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1984        out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1985
1986        for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1987            let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1988            let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1989            let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1990            let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1991            let dted_sample_start = block_start + 8 + 2 * lat_posting;
1992            out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1993        }
1994
1995        let checksum = out[block_start..checksum_start]
1996            .iter()
1997            .fold(0i32, |acc, byte| acc + i32::from(*byte));
1998        out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1999    }
2000
2001    debug_assert_eq!(out.len(), 25_981_042);
2002    Ok(out)
2003}
2004
2005/// Product pairs intentionally withheld because no open mirror is known.
2006#[must_use]
2007pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2008    &NO_OPEN_MIRRORS
2009}
2010
2011/// Confirm that a center/product pair has an open catalog mirror.
2012pub fn open_mirror(
2013    center: AnalysisCenter,
2014    product_type: ProductType,
2015) -> Result<(), DataCatalogError> {
2016    open_mirror_code(center.code(), product_type.code())
2017}
2018
2019/// Confirm that a center/product code pair is not in the no-open-mirror list.
2020pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2021    if NO_OPEN_MIRRORS
2022        .iter()
2023        .any(|entry| entry.center == center && entry.product_type == product_type)
2024    {
2025        Err(DataCatalogError::NoOpenMirror {
2026            center: center.to_string(),
2027            product_type: product_type.to_string(),
2028        })
2029    } else {
2030        Ok(())
2031    }
2032}
2033
2034/// Look up a center's static catalog entry.
2035#[must_use]
2036pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2037    CATALOG.iter().find(|entry| entry.center == center)
2038}
2039
2040/// Look up the convention for one center and product type.
2041pub fn product_convention(
2042    center: AnalysisCenter,
2043    product_type: ProductType,
2044) -> Result<&'static CenterProductConvention, DataCatalogError> {
2045    open_mirror(center, product_type)?;
2046    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2047    entry
2048        .products
2049        .iter()
2050        .find(|product| product.product_type == product_type)
2051        .ok_or(DataCatalogError::UnsupportedProduct {
2052            center,
2053            product_type,
2054        })
2055}
2056
2057/// Default sampling token for a center/product pair.
2058pub fn default_sample(
2059    center: AnalysisCenter,
2060    product_type: ProductType,
2061) -> Result<&'static str, DataCatalogError> {
2062    Ok(product_convention(center, product_type)?.default_sample)
2063}
2064
2065/// GPS week number for a product date.
2066pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2067    date.gps_week()
2068}
2069
2070/// Day-of-year in `1..=366` for a product date.
2071#[must_use]
2072pub fn day_of_year(date: ProductDate) -> u16 {
2073    date.day_of_year()
2074}
2075
2076/// Build a product specification for any center/product/date combination.
2077pub fn product(
2078    center: AnalysisCenter,
2079    product_type: ProductType,
2080    date: ProductDate,
2081    sample: Option<&str>,
2082    issue: Option<&str>,
2083) -> Result<ProductSpec, DataCatalogError> {
2084    let sample = match sample {
2085        Some(sample) => sample,
2086        None => default_sample(center, product_type)?,
2087    };
2088    ProductSpec::new(center, product_type, date, sample, issue)
2089}
2090
2091/// Build the canonical IGS long-name filename for a product.
2092pub fn canonical_filename(
2093    center: AnalysisCenter,
2094    product_type: ProductType,
2095    date: ProductDate,
2096    sample: Option<&str>,
2097    issue: Option<&str>,
2098) -> Result<String, DataCatalogError> {
2099    product(center, product_type, date, sample, issue)?.canonical_filename()
2100}
2101
2102/// Build the full archive URL for a product.
2103pub fn archive_url(
2104    center: AnalysisCenter,
2105    product_type: ProductType,
2106    date: ProductDate,
2107    sample: Option<&str>,
2108    issue: Option<&str>,
2109) -> Result<String, DataCatalogError> {
2110    product(center, product_type, date, sample, issue)?.archive_url()
2111}
2112
2113/// Build the exact identity for a catalog product.
2114pub fn product_identity(
2115    center: AnalysisCenter,
2116    product_type: ProductType,
2117    date: ProductDate,
2118    sample: Option<&str>,
2119    issue: Option<&str>,
2120) -> Result<ProductIdentity, DataCatalogError> {
2121    product(center, product_type, date, sample, issue)?.identity()
2122}
2123
2124/// Resolve an explicit distributor for a catalog product.
2125pub fn distribution_location(
2126    center: AnalysisCenter,
2127    product_type: ProductType,
2128    date: ProductDate,
2129    sample: Option<&str>,
2130    issue: Option<&str>,
2131    source: DistributionSource,
2132) -> Result<DistributionLocation, DataCatalogError> {
2133    product(center, product_type, date, sample, issue)?.distribution_location(source)
2134}
2135
2136/// Build the official NASA CDDIS HTTPS URL for an exact SP3 or IONEX identity.
2137///
2138/// CDDIS stores current SP3 products by GPS week and current IONEX products by
2139/// year/day-of-year. The decompressed official filename is unchanged.
2140pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2141    identity.validate()?;
2142    match identity.family {
2143        ProductType::Sp3 => Ok(format!(
2144            "https://cddis.nasa.gov/archive/gnss/products/{}/{}.gz",
2145            identity.date.gps_week()?,
2146            identity.official_filename
2147        )),
2148        ProductType::Ionex => Ok(format!(
2149            "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2150            identity.date.year,
2151            identity.date.day_of_year(),
2152            identity.official_filename
2153        )),
2154        product_type => Err(DataCatalogError::UnsupportedDistribution {
2155            source: DistributionSource::NasaCddis,
2156            product_type,
2157        }),
2158    }
2159}
2160
2161/// Build a clock product for a center and date.
2162pub fn mgex_clk(
2163    center: AnalysisCenter,
2164    date: ProductDate,
2165    sample: Option<&str>,
2166) -> Result<ProductSpec, DataCatalogError> {
2167    product(center, ProductType::Clk, date, sample, None)
2168}
2169
2170/// Build a merged broadcast-navigation product for a center and date.
2171pub fn mgex_nav(
2172    center: AnalysisCenter,
2173    date: ProductDate,
2174    sample: Option<&str>,
2175) -> Result<ProductSpec, DataCatalogError> {
2176    product(center, ProductType::Nav, date, sample, None)
2177}
2178
2179/// Build an IONEX product for a center and date.
2180pub fn mgex_ionex(
2181    center: AnalysisCenter,
2182    date: ProductDate,
2183    sample: Option<&str>,
2184) -> Result<ProductSpec, DataCatalogError> {
2185    product(center, ProductType::Ionex, date, sample, None)
2186}
2187
2188/// Build the CODE rapid IONEX product for a date.
2189pub fn rapid_ionex(
2190    date: ProductDate,
2191    sample: Option<&str>,
2192) -> Result<ProductSpec, DataCatalogError> {
2193    product(
2194        AnalysisCenter::CodRap,
2195        ProductType::Ionex,
2196        date,
2197        sample,
2198        None,
2199    )
2200}
2201
2202/// Day offset for predicted IONEX aliases.
2203#[must_use]
2204pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2205    match center {
2206        AnalysisCenter::CodPrd2 => 1,
2207        _ => 0,
2208    }
2209}
2210
2211/// Build a CODE predicted IONEX product for a target date.
2212pub fn predicted_ionex(
2213    center: AnalysisCenter,
2214    date: ProductDate,
2215    sample: Option<&str>,
2216) -> Result<ProductSpec, DataCatalogError> {
2217    match center {
2218        AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2219            let target = date.add_days(predicted_day_offset(center))?;
2220            product(center, ProductType::Ionex, target, sample, None)
2221        }
2222        other => Err(DataCatalogError::UnsupportedProduct {
2223            center: other,
2224            product_type: ProductType::Ionex,
2225        }),
2226    }
2227}
2228
2229/// Build an SP3 product for a center and date.
2230pub fn mgex_sp3(
2231    center: AnalysisCenter,
2232    date: ProductDate,
2233    sample: Option<&str>,
2234) -> Result<ProductSpec, DataCatalogError> {
2235    product(center, ProductType::Sp3, date, sample, None)
2236}
2237
2238/// Build an ultra-rapid OPS SP3 product for a date and issue time.
2239pub fn ops_ultra_sp3(
2240    center: AnalysisCenter,
2241    date: ProductDate,
2242    sample: Option<&str>,
2243    issue: Option<&str>,
2244) -> Result<ProductSpec, DataCatalogError> {
2245    let issue = issue.unwrap_or("0000");
2246    product(center, ProductType::Sp3, date, sample, Some(issue))
2247}
2248
2249/// Generate the current primary ultra-rapid SP3 location followed by known
2250/// duration/sampling alternates and documented latest-product aliases.
2251///
2252/// Candidate order is deterministic and center-specific. Callers should try
2253/// the next location only when the prior archive URL is absent; transport and
2254/// retry policy remain outside the pure core catalog.
2255pub fn ultra_sp3_locations(
2256    center: AnalysisCenter,
2257    date: ProductDate,
2258    issue: &str,
2259) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2260    validate_issue_for_center(center, Some(issue))?;
2261    let patterns: &[UltraSp3Pattern] = match center {
2262        AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
2263        AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
2264        AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
2265        AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
2266        other => {
2267            return Err(DataCatalogError::UnsupportedProduct {
2268                center: other,
2269                product_type: ProductType::Sp3,
2270            })
2271        }
2272    };
2273    let convention = product_convention(center, ProductType::Sp3)?;
2274    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2275    let directory = dir_path(convention.layout, date)?;
2276    let date = date_block(date, Some(issue));
2277
2278    Ok(patterns
2279        .iter()
2280        .map(|pattern| {
2281            let filename = pattern.alias_filename.map_or_else(
2282                || {
2283                    format!(
2284                        "{}_{}_{}_{}_ORB.SP3",
2285                        convention.token, date, pattern.span, pattern.sample
2286                    )
2287                },
2288                ToOwned::to_owned,
2289            );
2290            UltraSp3Location {
2291                pattern: pattern.label.to_string(),
2292                span: pattern.span.to_string(),
2293                sample: pattern.sample.to_string(),
2294                url: format!(
2295                    "{}/{}/{}{}",
2296                    entry.root_url,
2297                    directory,
2298                    filename,
2299                    convention.compression.suffix()
2300                ),
2301                filename,
2302                compression: convention.compression,
2303            }
2304        })
2305        .collect())
2306}
2307
2308/// Build an ultra-rapid OPS clock product for a date and issue time.
2309pub fn ops_ultra_clk(
2310    center: AnalysisCenter,
2311    date: ProductDate,
2312    sample: Option<&str>,
2313    issue: Option<&str>,
2314) -> Result<ProductSpec, DataCatalogError> {
2315    let issue = issue.unwrap_or("0000");
2316    product(center, ProductType::Clk, date, sample, Some(issue))
2317}
2318
2319/// Select the latest ultra-rapid OPS SP3 issue at or before a target time.
2320pub fn latest_ops_ultra_sp3(
2321    center: AnalysisCenter,
2322    target: ProductDateTime,
2323    sample: Option<&str>,
2324    available_issues: Option<&[UltraIssue]>,
2325) -> Result<ProductSpec, DataCatalogError> {
2326    let selected = latest_ultra_issue(center, target, available_issues)?;
2327    ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
2328}
2329
2330/// Candidate ultra-rapid issues at or before a target time, newest first.
2331pub fn ultra_issue_candidates(
2332    center: AnalysisCenter,
2333    target: ProductDateTime,
2334) -> Result<Vec<UltraIssue>, DataCatalogError> {
2335    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2336    let _ = product_convention(center, ProductType::Sp3)?;
2337    if entry.issues.is_empty() {
2338        return Err(DataCatalogError::UnsupportedProduct {
2339            center,
2340            product_type: ProductType::Sp3,
2341        });
2342    }
2343
2344    let mut candidates = Vec::new();
2345    for date in [target.date, target.date.add_days(-1)?] {
2346        for issue in entry.issues.iter().rev() {
2347            if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
2348                candidates.push(UltraIssue::new(date, issue)?);
2349            }
2350        }
2351    }
2352    Ok(candidates)
2353}
2354
2355/// Latest ultra-rapid issue at or before a target time.
2356pub fn latest_ultra_issue(
2357    center: AnalysisCenter,
2358    target: ProductDateTime,
2359    available_issues: Option<&[UltraIssue]>,
2360) -> Result<UltraIssue, DataCatalogError> {
2361    let candidates = ultra_issue_candidates(center, target)?;
2362    if candidates.is_empty() {
2363        return Err(DataCatalogError::NoUltraIssue);
2364    }
2365    if let Some(available) = available_issues {
2366        candidates
2367            .into_iter()
2368            .find(|candidate| {
2369                available
2370                    .iter()
2371                    .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
2372            })
2373            .ok_or(DataCatalogError::NoAvailableUltraIssue)
2374    } else {
2375        Ok(candidates[0].clone())
2376    }
2377}
2378
2379/// Candidate IONEX dates at or before a target date, newest first.
2380pub fn gim_date_candidates(
2381    center: AnalysisCenter,
2382    target: ProductDate,
2383    lookback: u32,
2384) -> Result<Vec<ProductDate>, DataCatalogError> {
2385    let _ = product_convention(center, ProductType::Ionex)?;
2386    let base = target.add_days(predicted_day_offset(center))?;
2387    let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
2388    for back in 0..=lookback {
2389        out.push(base.add_days(-i64::from(back))?);
2390    }
2391    Ok(out)
2392}
2393
2394/// Build a daily station observation product.
2395pub fn station_obs(
2396    station: &str,
2397    date: ProductDate,
2398    sample: Option<&str>,
2399) -> Result<StationObservationSpec, DataCatalogError> {
2400    StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
2401}
2402
2403/// Build the canonical RINEX 3 CRINEX filename for a daily station observation.
2404pub fn station_obs_filename(
2405    station: &str,
2406    date: ProductDate,
2407    sample: &str,
2408) -> Result<String, DataCatalogError> {
2409    validate_station(station)?;
2410    validate_sample(sample)?;
2411    Ok(format!(
2412        "{}_R_{}_01D_{}_MO.crx",
2413        station,
2414        date_block(date, None),
2415        sample
2416    ))
2417}
2418
2419/// Build the full BKG IGS archive URL for a daily station observation.
2420pub fn station_obs_url(
2421    station: &str,
2422    date: ProductDate,
2423    sample: &str,
2424) -> Result<String, DataCatalogError> {
2425    let filename = station_obs_filename(station, date, sample)?;
2426    Ok(format!(
2427        "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
2428        dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
2429        filename
2430    ))
2431}
2432
2433/// The transfer protocol for the daily station observation archive.
2434#[must_use]
2435pub const fn station_obs_protocol() -> ArchiveProtocol {
2436    ArchiveProtocol::Https
2437}
2438
2439fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
2440    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
2441        Ok(())
2442    } else {
2443        Err(DataCatalogError::InvalidTileIndex {
2444            lat_index,
2445            lon_index: 0,
2446        })
2447    }
2448}
2449
2450fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
2451    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2452        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2453    {
2454        Ok(())
2455    } else {
2456        Err(DataCatalogError::InvalidTileIndex {
2457            lat_index,
2458            lon_index,
2459        })
2460    }
2461}
2462
2463fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
2464    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2465        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2466    {
2467        Ok(())
2468    } else {
2469        Err(HgtConversionError::InvalidTileIndex {
2470            lat_index,
2471            lon_index,
2472        })
2473    }
2474}
2475
2476fn dted_coord_field(index: i32, is_longitude: bool) -> String {
2477    let hemi = match (is_longitude, index >= 0) {
2478        (true, true) => 'E',
2479        (true, false) => 'W',
2480        (false, true) => 'N',
2481        (false, false) => 'S',
2482    };
2483    format!("{:03}0000{hemi}", index.abs())
2484}
2485
2486fn encode_dted_signed_magnitude(sample: i16) -> u16 {
2487    if sample == i16::MIN {
2488        0
2489    } else if sample >= 0 {
2490        sample as u16
2491    } else {
2492        0x8000 | (-i32::from(sample) as u16)
2493    }
2494}
2495
2496fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
2497    PRODUCT_TYPE_CONVENTIONS
2498        .iter()
2499        .find(|descriptor| descriptor.product_type == product_type)
2500        .expect("product descriptor exists for enum variant")
2501}
2502
2503const fn product_format(product_type: ProductType) -> ProductFormat {
2504    match product_type {
2505        ProductType::Sp3 => ProductFormat::Sp3,
2506        ProductType::Ionex => ProductFormat::Ionex,
2507        ProductType::Clk => ProductFormat::RinexClock,
2508        ProductType::Nav => ProductFormat::RinexNavigation,
2509    }
2510}
2511
2512fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
2513    if filename.is_empty()
2514        || filename == "."
2515        || filename == ".."
2516        || filename.contains('/')
2517        || filename.contains('\\')
2518        || filename.contains('\0')
2519        || filename.contains("..")
2520    {
2521        Err(DataCatalogError::InvalidOfficialFilename(
2522            filename.to_string(),
2523        ))
2524    } else {
2525        Ok(())
2526    }
2527}
2528
2529fn validate_product(
2530    center: AnalysisCenter,
2531    product_type: ProductType,
2532    sample: &str,
2533    issue: Option<&str>,
2534) -> Result<&'static CenterProductConvention, DataCatalogError> {
2535    let convention = product_convention(center, product_type)?;
2536    validate_sample(sample)?;
2537    validate_issue_for_center(center, issue)?;
2538    Ok(convention)
2539}
2540
2541fn validate_issue_for_center(
2542    center: AnalysisCenter,
2543    issue: Option<&str>,
2544) -> Result<(), DataCatalogError> {
2545    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2546    match (entry.issues.is_empty(), issue) {
2547        (true, None) => Ok(()),
2548        (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
2549        (false, None) => Err(DataCatalogError::MissingIssue { center }),
2550        (false, Some(issue)) => {
2551            validate_issue(issue)?;
2552            if entry.issues.contains(&issue) {
2553                Ok(())
2554            } else {
2555                Err(DataCatalogError::UnsupportedIssue {
2556                    center,
2557                    issue: issue.to_string(),
2558                })
2559            }
2560        }
2561    }
2562}
2563
2564fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
2565    let bytes = sample.as_bytes();
2566    let valid = bytes.len() == 3
2567        && bytes[0].is_ascii_digit()
2568        && bytes[1].is_ascii_digit()
2569        && bytes[2].is_ascii_uppercase();
2570    if valid {
2571        Ok(())
2572    } else {
2573        Err(DataCatalogError::InvalidSample(sample.to_string()))
2574    }
2575}
2576
2577fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2578    let bytes = issue.as_bytes();
2579    let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2580    if !valid_digits {
2581        return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2582    }
2583    let hour = issue[0..2]
2584        .parse::<u8>()
2585        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2586    let minute = issue[2..4]
2587        .parse::<u8>()
2588        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2589    if hour <= 23 && minute <= 59 {
2590        Ok(())
2591    } else {
2592        Err(DataCatalogError::InvalidIssue(issue.to_string()))
2593    }
2594}
2595
2596fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2597    let bytes = station.as_bytes();
2598    let valid = bytes.len() == 9
2599        && bytes
2600            .iter()
2601            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2602    if valid {
2603        Ok(())
2604    } else {
2605        Err(DataCatalogError::InvalidStation(station.to_string()))
2606    }
2607}
2608
2609fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2610    validate_issue(issue)?;
2611    let hour = issue[0..2]
2612        .parse::<u16>()
2613        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2614    let minute = issue[2..4]
2615        .parse::<u16>()
2616        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2617    Ok(hour * 60 + minute)
2618}
2619
2620fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2621    Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2622}
2623
2624fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2625    format!(
2626        "{}{:03}{}",
2627        date.year,
2628        date.day_of_year(),
2629        issue.unwrap_or("0000")
2630    )
2631}
2632
2633fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2634    Ok(match layout {
2635        ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2636        ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2637        ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2638        ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2639        ArchiveLayout::BkgBrdcYearDoy => {
2640            format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2641        }
2642        ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2643        ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2644        ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2645        ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2646    })
2647}
2648
2649fn product_dir_path(
2650    center: AnalysisCenter,
2651    layout: ArchiveLayout,
2652    date: ProductDate,
2653) -> Result<String, DataCatalogError> {
2654    match center {
2655        AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
2656        AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
2657        _ => dir_path(layout, date),
2658    }
2659}
2660
2661fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2662    let (year, month, day) = civil_from_julian_day_number(jdn);
2663    let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2664    let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2665    let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2666    ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2667}