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;
10use std::collections::{HashMap, HashSet};
11
12use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
13use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
14use crate::astro::time::model::TimeScale;
15use crate::astro::time::scales::julian_day_number;
16use crate::terrain;
17
18/// Analysis-center code supported by the data-product catalog.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum AnalysisCenter {
21    /// `igs`.
22    Igs,
23    /// `cod_rap`.
24    CodRap,
25    /// `cod_prd1`.
26    CodPrd1,
27    /// `cod_prd2`.
28    CodPrd2,
29    /// `esa`.
30    Esa,
31    /// `cod`.
32    Cod,
33    /// `gfz`.
34    Gfz,
35    /// `igs_ult`.
36    IgsUlt,
37    /// `cod_ult`.
38    CodUlt,
39    /// `esa_ult`.
40    EsaUlt,
41    /// `gfz_ult`.
42    GfzUlt,
43    /// `wum_nrt`.
44    WumNrt,
45}
46
47impl AnalysisCenter {
48    /// The lower-case catalog code.
49    #[must_use]
50    pub const fn code(self) -> &'static str {
51        match self {
52            Self::Igs => "igs",
53            Self::CodRap => "cod_rap",
54            Self::CodPrd1 => "cod_prd1",
55            Self::CodPrd2 => "cod_prd2",
56            Self::Esa => "esa",
57            Self::Cod => "cod",
58            Self::Gfz => "gfz",
59            Self::IgsUlt => "igs_ult",
60            Self::CodUlt => "cod_ult",
61            Self::EsaUlt => "esa_ult",
62            Self::GfzUlt => "gfz_ult",
63            Self::WumNrt => "wum_nrt",
64        }
65    }
66
67    /// Parse a lower-case catalog code.
68    #[must_use]
69    pub fn from_code(code: &str) -> Option<Self> {
70        match code {
71            "igs" => Some(Self::Igs),
72            "cod_rap" => Some(Self::CodRap),
73            "cod_prd1" => Some(Self::CodPrd1),
74            "cod_prd2" => Some(Self::CodPrd2),
75            "esa" => Some(Self::Esa),
76            "cod" => Some(Self::Cod),
77            "gfz" => Some(Self::Gfz),
78            "igs_ult" => Some(Self::IgsUlt),
79            "cod_ult" => Some(Self::CodUlt),
80            "esa_ult" => Some(Self::EsaUlt),
81            "gfz_ult" => Some(Self::GfzUlt),
82            "wum_nrt" => Some(Self::WumNrt),
83            _ => None,
84        }
85    }
86
87    /// Public publisher represented by this catalog product line.
88    #[must_use]
89    pub const fn publisher(self) -> ProductPublisher {
90        match self {
91            Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
92            Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
93                ProductPublisher::Code
94            }
95            Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
96            Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
97            Self::WumNrt => ProductPublisher::Whu,
98        }
99    }
100
101    /// Legacy center-wide solution class represented by this catalog code.
102    ///
103    /// Some centers publish more than one class. In particular, [`Self::Igs`]
104    /// serves both broadcast navigation and final orbit products. New code
105    /// should use [`product_solution_class`] when the product family is known.
106    #[must_use]
107    pub const fn solution_class(self) -> SolutionClass {
108        match self {
109            Self::Igs => SolutionClass::Broadcast,
110            Self::CodRap | Self::Gfz => SolutionClass::Rapid,
111            Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
112            Self::Esa | Self::Cod => SolutionClass::Final,
113            Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
114            Self::WumNrt => SolutionClass::NearRealTime,
115        }
116    }
117
118    /// Prediction horizon associated with a predicted product alias.
119    #[must_use]
120    pub const fn prediction_horizon_days(self) -> Option<u8> {
121        match self {
122            Self::CodPrd1 => Some(1),
123            Self::CodPrd2 => Some(2),
124            _ => None,
125        }
126    }
127}
128
129impl fmt::Display for AnalysisCenter {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.write_str(self.code())
132    }
133}
134
135impl FromStr for AnalysisCenter {
136    type Err = DataCatalogError;
137
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
140    }
141}
142
143/// Product type supported by the data-product catalog.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
145pub enum ProductType {
146    /// Precise orbit SP3.
147    Sp3,
148    /// RINEX clock.
149    Clk,
150    /// Merged broadcast navigation.
151    Nav,
152    /// IONEX global ionosphere map.
153    Ionex,
154}
155
156impl ProductType {
157    /// The lower-case product code.
158    #[must_use]
159    pub const fn code(self) -> &'static str {
160        match self {
161            Self::Sp3 => "sp3",
162            Self::Clk => "clk",
163            Self::Nav => "nav",
164            Self::Ionex => "ionex",
165        }
166    }
167
168    /// Parse a lower-case product code.
169    #[must_use]
170    pub fn from_code(code: &str) -> Option<Self> {
171        match code {
172            "sp3" => Some(Self::Sp3),
173            "clk" => Some(Self::Clk),
174            "nav" => Some(Self::Nav),
175            "ionex" => Some(Self::Ionex),
176            _ => None,
177        }
178    }
179}
180
181impl fmt::Display for ProductType {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.write_str(self.code())
184    }
185}
186
187impl FromStr for ProductType {
188    type Err = DataCatalogError;
189
190    fn from_str(s: &str) -> Result<Self, Self::Err> {
191        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
192    }
193}
194
195/// Public organization that produced or combined a GNSS product.
196///
197/// This is intentionally separate from [`AnalysisCenter`]. Catalog center
198/// codes such as `cod`, `cod_rap`, and `cod_ult` select different product
199/// lines, but all three have the same publisher.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
201pub enum ProductPublisher {
202    /// International GNSS Service combination product.
203    Igs,
204    /// Center for Orbit Determination in Europe (CODE).
205    Code,
206    /// European Space Agency.
207    Esa,
208    /// GFZ German Research Centre for Geosciences.
209    Gfz,
210    /// Wuhan University IGS Analysis Center.
211    Whu,
212}
213
214impl ProductPublisher {
215    /// IGS long-filename publisher token.
216    #[must_use]
217    pub const fn code(self) -> &'static str {
218        match self {
219            Self::Igs => "IGS",
220            Self::Code => "COD",
221            Self::Esa => "ESA",
222            Self::Gfz => "GFZ",
223            Self::Whu => "WUM",
224        }
225    }
226}
227
228impl fmt::Display for ProductPublisher {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        f.write_str(self.code())
231    }
232}
233
234/// Public solution class encoded in a GNSS product name.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
236pub enum SolutionClass {
237    /// Final product.
238    Final,
239    /// Rapid product.
240    Rapid,
241    /// Ultra-rapid product, which may contain observed and predicted segments.
242    UltraRapid,
243    /// Predicted product.
244    Predicted,
245    /// Near-real-time product, published on a sub-ultra-rapid (hourly) rhythm.
246    NearRealTime,
247    /// Broadcast navigation product.
248    Broadcast,
249}
250
251impl SolutionClass {
252    /// Stable public code used in Sidereon provenance and cache paths.
253    #[must_use]
254    pub const fn code(self) -> &'static str {
255        match self {
256            Self::Final => "final",
257            Self::Rapid => "rapid",
258            Self::UltraRapid => "ultra_rapid",
259            Self::Predicted => "predicted",
260            Self::NearRealTime => "near_real_time",
261            Self::Broadcast => "broadcast",
262        }
263    }
264
265    /// IGS long-filename solution token where one exists.
266    #[must_use]
267    pub const fn filename_token(self) -> Option<&'static str> {
268        match self {
269            Self::Final => Some("FIN"),
270            Self::Rapid => Some("RAP"),
271            Self::UltraRapid => Some("ULT"),
272            Self::Predicted => Some("PRD"),
273            Self::NearRealTime => Some("NRT"),
274            Self::Broadcast => None,
275        }
276    }
277}
278
279impl fmt::Display for SolutionClass {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        f.write_str(self.code())
282    }
283}
284
285/// Public campaign or project encoded in a GNSS product name.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
287pub enum ProductCampaign {
288    /// Operational IGS product line.
289    Operational,
290    /// Multi-GNSS product line (`MGN`).
291    MultiGnss,
292    /// Multi-GNSS Experiment product line (`MGX`).
293    MultiGnssExperiment,
294    /// Broadcast navigation archive product.
295    Broadcast,
296}
297
298impl ProductCampaign {
299    /// Stable public code.
300    #[must_use]
301    pub const fn code(self) -> &'static str {
302        match self {
303            Self::Operational => "OPS",
304            Self::MultiGnss => "MGN",
305            Self::MultiGnssExperiment => "MGX",
306            Self::Broadcast => "BRD",
307        }
308    }
309}
310
311/// Public serialization format carried by a catalog product.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
313pub enum ProductFormat {
314    /// Standard Product 3 orbit format.
315    Sp3,
316    /// IONosphere map EXchange format.
317    Ionex,
318    /// RINEX clock format.
319    RinexClock,
320    /// RINEX navigation format.
321    RinexNavigation,
322}
323
324impl ProductFormat {
325    /// Stable public format code.
326    #[must_use]
327    pub const fn code(self) -> &'static str {
328        match self {
329            Self::Sp3 => "SP3",
330            Self::Ionex => "IONEX",
331            Self::RinexClock => "RINEX_CLK",
332            Self::RinexNavigation => "RINEX_NAV",
333        }
334    }
335}
336
337/// Explicit distributor used to obtain an exact public product.
338///
339/// Distributor selection never changes product publisher, solution class,
340/// issue, cadence, date, or family.
341#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
342pub enum DistributionSource {
343    /// The cataloged analysis-center or IGS direct archive.
344    Direct,
345    /// NASA CDDIS over HTTPS, optionally authenticated with Earthdata Login.
346    NasaCddis,
347    /// Bytes read from a caller-provided local file.
348    LocalFile,
349    /// Bytes supplied directly by the caller.
350    InMemory,
351}
352
353impl DistributionSource {
354    /// Stable public code used in provenance and cache paths.
355    #[must_use]
356    pub const fn code(self) -> &'static str {
357        match self {
358            Self::Direct => "direct",
359            Self::NasaCddis => "nasa_cddis",
360            Self::LocalFile => "local_file",
361            Self::InMemory => "in_memory",
362        }
363    }
364}
365
366/// CelesTrak space-weather product served by the data catalog.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
368pub enum SpaceWeatherProduct {
369    /// `SW-All.csv`: full history plus daily and monthly predictions.
370    All,
371    /// `SW-Last5Years.csv`: observed rolling window.
372    Last5Years,
373}
374
375impl SpaceWeatherProduct {
376    /// The lower-case catalog code.
377    #[must_use]
378    pub const fn code(self) -> &'static str {
379        match self {
380            Self::All => "sw_all",
381            Self::Last5Years => "sw_last5",
382        }
383    }
384
385    /// Parse a lower-case catalog code.
386    #[must_use]
387    pub fn from_code(code: &str) -> Option<Self> {
388        match code {
389            "sw_all" => Some(Self::All),
390            "sw_last5" => Some(Self::Last5Years),
391            _ => None,
392        }
393    }
394}
395
396impl fmt::Display for SpaceWeatherProduct {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        f.write_str(self.code())
399    }
400}
401
402impl FromStr for SpaceWeatherProduct {
403    type Err = DataCatalogError;
404
405    fn from_str(s: &str) -> Result<Self, Self::Err> {
406        Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
407    }
408}
409
410/// Archive transport protocol recorded by the catalog.
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
412pub enum ArchiveProtocol {
413    /// HTTP.
414    Http,
415    /// HTTPS.
416    Https,
417    /// Anonymous FTP. Wuhan University's IGS data center serves its open
418    /// archive over `ftp://` only; there is no HTTP surface for these paths.
419    Ftp,
420}
421
422impl ArchiveProtocol {
423    /// URI scheme text.
424    #[must_use]
425    pub const fn as_str(self) -> &'static str {
426        match self {
427            Self::Http => "http",
428            Self::Https => "https",
429            Self::Ftp => "ftp",
430        }
431    }
432}
433
434/// Archive compression for a cataloged product.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
436pub enum ArchiveCompression {
437    /// Archive URL has a `.gz` suffix.
438    Gzip,
439    /// Archive URL uses the historical Unix-compress `.Z` suffix.
440    UnixCompress,
441    /// Archive URL is the plain product filename.
442    None,
443}
444
445impl ArchiveCompression {
446    /// Catalog text for the compression format.
447    #[must_use]
448    pub const fn as_str(self) -> &'static str {
449        match self {
450            Self::Gzip => "gzip",
451            Self::UnixCompress => "unix_compress",
452            Self::None => "none",
453        }
454    }
455
456    const fn suffix(self) -> &'static str {
457        match self {
458            Self::Gzip => ".gz",
459            Self::UnixCompress => ".Z",
460            Self::None => "",
461        }
462    }
463}
464
465/// Directory layout used below an archive root.
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
467pub enum ArchiveLayout {
468    /// `rapid/w<gps-week>`.
469    GfzRapidWeek,
470    /// `ultra/w<gps-week>`.
471    GfzUltraWeek,
472    /// `<gps-week>`.
473    GpsWeek,
474    /// `products/<gps-week>`.
475    BkgProductsWeek,
476    /// `BRDC/<year>/<day-of-year>`.
477    BkgBrdcYearDoy,
478    /// `obs/<year>/<day-of-year>`.
479    BkgObsYearDoy,
480    /// `CODE_MGEX/CODE/<year>`.
481    AiubCodeMgexYear,
482    /// `CODE/<year>`.
483    AiubCodeYear,
484    /// `CODE`.
485    AiubCodeRoot,
486}
487
488/// Product filename convention.
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub enum ProductFilenameKind {
491    /// `TOKEN_DATE_LEN_SAMPLE_CODE.EXT`.
492    Sampled,
493    /// `TOKEN_R_DATE_LEN_CODE.ext`.
494    Nav,
495}
496
497/// Product-type filename convention.
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub struct ProductTypeConvention {
500    /// Product type.
501    pub product_type: ProductType,
502    /// Filename content code, for example `ORB`.
503    pub content_code: &'static str,
504    /// Filename extension, preserving archive case.
505    pub extension: &'static str,
506    /// Filename convention.
507    pub kind: ProductFilenameKind,
508}
509
510/// Per-center convention for one product type.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub struct CenterProductConvention {
513    /// Product type.
514    pub product_type: ProductType,
515    /// IGS long-name token prefix.
516    pub token: &'static str,
517    /// Directory layout under the archive root.
518    pub layout: ArchiveLayout,
519    /// Product span token.
520    pub span: &'static str,
521    /// Default sampling token.
522    pub default_sample: &'static str,
523    /// Archive compression.
524    pub compression: ArchiveCompression,
525}
526
527/// Static catalog entry for one analysis-center code.
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub struct CenterCatalogEntry {
530    /// Analysis-center code.
531    pub center: AnalysisCenter,
532    /// Lower-case catalog code.
533    pub code: &'static str,
534    /// Archive URI scheme.
535    pub protocol: ArchiveProtocol,
536    /// Archive host.
537    pub host: &'static str,
538    /// Archive root URL without trailing slash.
539    pub root_url: &'static str,
540    /// Product conventions served by this center.
541    pub products: &'static [CenterProductConvention],
542    /// Valid issue times for sub-daily products.
543    pub issues: &'static [&'static str],
544}
545
546/// Static catalog entry for one terrain source.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub struct TerrainSourceEntry {
549    /// Archive URI scheme.
550    pub protocol: ArchiveProtocol,
551    /// Archive host.
552    pub host: &'static str,
553    /// Archive compression.
554    pub compression: ArchiveCompression,
555    /// Archive root URL without trailing slash.
556    pub root_url: &'static str,
557}
558
559/// Static catalog entry for the CelesTrak space-weather source.
560#[derive(Debug, Clone, Copy, PartialEq, Eq)]
561pub struct SpaceWeatherSourceEntry {
562    /// Archive URI scheme.
563    pub protocol: ArchiveProtocol,
564    /// Archive host.
565    pub host: &'static str,
566    /// Archive compression.
567    pub compression: ArchiveCompression,
568    /// Archive root URL without trailing slash.
569    pub root_url: &'static str,
570}
571
572/// Product pair that is intentionally not offered because no open mirror exists.
573#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
574pub struct NoOpenMirrorProduct {
575    /// Analysis-center code.
576    pub center: &'static str,
577    /// Product type code.
578    pub product_type: &'static str,
579}
580
581const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
582    ProductTypeConvention {
583        product_type: ProductType::Sp3,
584        content_code: "ORB",
585        extension: "SP3",
586        kind: ProductFilenameKind::Sampled,
587    },
588    ProductTypeConvention {
589        product_type: ProductType::Clk,
590        content_code: "CLK",
591        extension: "CLK",
592        kind: ProductFilenameKind::Sampled,
593    },
594    ProductTypeConvention {
595        product_type: ProductType::Nav,
596        content_code: "MN",
597        extension: "rnx",
598        kind: ProductFilenameKind::Nav,
599    },
600    ProductTypeConvention {
601        product_type: ProductType::Ionex,
602        content_code: "GIM",
603        extension: "INX",
604        kind: ProductFilenameKind::Sampled,
605    },
606];
607
608const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
609    product_type: ProductType::Ionex,
610    token: "COD0OPSRAP",
611    layout: ArchiveLayout::AiubCodeRoot,
612    span: "01D",
613    default_sample: "01H",
614    compression: ArchiveCompression::Gzip,
615}];
616
617const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
618    product_type: ProductType::Ionex,
619    token: "COD0OPSPRD",
620    layout: ArchiveLayout::AiubCodeRoot,
621    span: "01D",
622    default_sample: "01H",
623    compression: ArchiveCompression::Gzip,
624}];
625
626/// Wuhan University MGEX near-real-time orbit line.
627///
628/// Verified against the live archive on 2026-08-04
629/// (`ftp://igs.gnsswhu.cn/pub/gps/products/mgex/<gps-week>/`): hourly
630/// `WUM0MGXNRT_<YYYYDDDHHMM>_02D_05M_ORB.SP3.gz` objects, SP3-d, agency
631/// `WHU`, 576 epochs (a half-open two-day span at five minutes) starting at
632/// the filename epoch. The `WUM0MGXULA` name this line was once known by is
633/// no longer published: the archived hourly series switches from ULA (last
634/// observed in GPS week 2230) through a publication gap to NRT from
635/// 2024-07-03 (day 185, GPS week 2321) onward.
636const WUM_NRT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
637    product_type: ProductType::Sp3,
638    token: "WUM0MGXNRT",
639    layout: ArchiveLayout::GpsWeek,
640    span: "02D",
641    default_sample: "05M",
642    compression: ArchiveCompression::Gzip,
643}];
644
645/// Hourly issue times published by the WUM near-real-time line.
646const WUM_NRT_ISSUES: [&str; 24] = [
647    "0000", "0100", "0200", "0300", "0400", "0500", "0600", "0700", "0800", "0900", "1000", "1100",
648    "1200", "1300", "1400", "1500", "1600", "1700", "1800", "1900", "2000", "2100", "2200", "2300",
649];
650
651const ESA_PRODUCTS: [CenterProductConvention; 3] = [
652    CenterProductConvention {
653        product_type: ProductType::Sp3,
654        token: "ESA0MGNFIN",
655        layout: ArchiveLayout::GpsWeek,
656        span: "01D",
657        default_sample: "05M",
658        compression: ArchiveCompression::Gzip,
659    },
660    CenterProductConvention {
661        product_type: ProductType::Clk,
662        token: "ESA0MGNFIN",
663        layout: ArchiveLayout::GpsWeek,
664        span: "01D",
665        default_sample: "30S",
666        compression: ArchiveCompression::Gzip,
667    },
668    CenterProductConvention {
669        product_type: ProductType::Ionex,
670        token: "ESA0OPSFIN",
671        layout: ArchiveLayout::GpsWeek,
672        span: "01D",
673        default_sample: "02H",
674        compression: ArchiveCompression::Gzip,
675    },
676];
677
678const COD_PRODUCTS: [CenterProductConvention; 3] = [
679    CenterProductConvention {
680        product_type: ProductType::Sp3,
681        token: "COD0MGXFIN",
682        layout: ArchiveLayout::AiubCodeMgexYear,
683        span: "01D",
684        default_sample: "05M",
685        compression: ArchiveCompression::Gzip,
686    },
687    CenterProductConvention {
688        product_type: ProductType::Clk,
689        token: "COD0MGXFIN",
690        layout: ArchiveLayout::AiubCodeMgexYear,
691        span: "01D",
692        default_sample: "30S",
693        compression: ArchiveCompression::Gzip,
694    },
695    CenterProductConvention {
696        product_type: ProductType::Ionex,
697        token: "COD0OPSFIN",
698        layout: ArchiveLayout::AiubCodeYear,
699        span: "01D",
700        default_sample: "01H",
701        compression: ArchiveCompression::Gzip,
702    },
703];
704
705const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
706    CenterProductConvention {
707        product_type: ProductType::Sp3,
708        token: "GFZ0OPSRAP",
709        layout: ArchiveLayout::GfzRapidWeek,
710        span: "01D",
711        default_sample: "05M",
712        compression: ArchiveCompression::Gzip,
713    },
714    CenterProductConvention {
715        product_type: ProductType::Clk,
716        token: "GFZ0OPSRAP",
717        layout: ArchiveLayout::GfzRapidWeek,
718        span: "01D",
719        default_sample: "30S",
720        compression: ArchiveCompression::Gzip,
721    },
722];
723
724const IGS_PRODUCTS: [CenterProductConvention; 2] = [
725    CenterProductConvention {
726        product_type: ProductType::Sp3,
727        token: "IGS0OPSFIN",
728        layout: ArchiveLayout::BkgProductsWeek,
729        span: "01D",
730        default_sample: "15M",
731        compression: ArchiveCompression::Gzip,
732    },
733    CenterProductConvention {
734        product_type: ProductType::Nav,
735        token: "BRDC00WRD",
736        layout: ArchiveLayout::BkgBrdcYearDoy,
737        span: "01D",
738        default_sample: "01D",
739        compression: ArchiveCompression::Gzip,
740    },
741];
742
743const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
744    product_type: ProductType::Sp3,
745    token: "IGS0OPSULT",
746    layout: ArchiveLayout::BkgProductsWeek,
747    span: "02D",
748    default_sample: "15M",
749    compression: ArchiveCompression::Gzip,
750}];
751
752const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
753    product_type: ProductType::Sp3,
754    token: "COD0OPSULT",
755    layout: ArchiveLayout::AiubCodeRoot,
756    span: "01D",
757    default_sample: "05M",
758    compression: ArchiveCompression::None,
759}];
760
761const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
762    product_type: ProductType::Sp3,
763    token: "ESA0OPSULT",
764    layout: ArchiveLayout::GpsWeek,
765    span: "02D",
766    default_sample: "05M",
767    compression: ArchiveCompression::Gzip,
768}];
769
770const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
771    product_type: ProductType::Sp3,
772    token: "GFZ0OPSULT",
773    layout: ArchiveLayout::GfzUltraWeek,
774    span: "02D",
775    default_sample: "05M",
776    compression: ArchiveCompression::Gzip,
777}];
778
779const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
780const COD_ULT_ISSUES: [&str; 1] = ["0000"];
781const GFZ_ULT_ISSUES: [&str; 8] = [
782    "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
783];
784
785/// First GPS week covered by the official IGS rapid/final orbit combination.
786///
787/// The IGS rapid/final combination began on 1994-01-02, GPS week 0730.
788const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
789
790/// First GPS week in which IGS operational products use only long filenames.
791///
792/// IGS transitioned at the start of GPS week 2238 (2022-11-27). Long-name
793/// trial products from earlier weeks are distinct products and are therefore
794/// not aliases for the legacy final combination.
795const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
796
797/// First GPS week in which AIUB's supported CODE product families use the
798/// cataloged long filenames.
799const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
800
801/// First GFZ rapid-orbit date published with the five-minute sampling token.
802///
803/// GFZ's official week-2158 listing ends the 15-minute series at 2021 day 137
804/// and begins the five-minute series at 2021 day 138.
805const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
806    year: 2021,
807    month: 5,
808    day: 18,
809};
810
811/// First date in the cataloged ESA final-orbit and clock series.
812const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
813    year: 2014,
814    month: 1,
815    day: 5,
816};
817
818/// First date in the cataloged GFZ rapid-orbit and clock series.
819const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
820    year: 2020,
821    month: 5,
822    day: 13,
823};
824
825/// First date in the cataloged ESA ultra-rapid SP3 series.
826const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
827    year: 2022,
828    month: 10,
829    day: 4,
830};
831
832/// First archived `WUM0MGXNRT` orbit date (2024 day 185, GPS week 2321),
833/// verified against the live Wuhan archive on 2026-08-04. The first issue on
834/// this date is `0300`; the catalog gates at the date level and leaves the
835/// two absent earlier issues to availability discovery. Earlier weeks carry
836/// either the discontinued `WUM0MGXULA` hourly line (last observed in GPS
837/// week 2230) or nothing, and are refused rather than assigned a filename
838/// that never existed.
839const WUM_NRT_SP3_START_DATE: ProductDate = ProductDate {
840    year: 2024,
841    month: 7,
842    day: 3,
843};
844
845/// Last ESA ultra-rapid issue that uses the 15-minute sampling token.
846const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
847    year: 2025,
848    month: 2,
849    day: 2,
850};
851const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
852
853/// First date in the cataloged GFZ ultra-rapid SP3 series.
854const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
855    year: 2020,
856    month: 10,
857    day: 6,
858};
859
860/// First date for which GFZ ultra-rapid SP3 defaults to five-minute sampling.
861const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
862    year: 2021,
863    month: 5,
864    day: 16,
865};
866
867/// Final primarily 15-minute GFZ ultra-rapid date.
868///
869/// Its `0000` issue is the documented transition overlap and publishes both
870/// 15-minute and 5-minute objects; later issues that day publish only 15-minute
871/// objects.
872const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
873    year: 2021,
874    month: 5,
875    day: 15,
876};
877
878/// First and last dates of GFZ's ultra-rapid content-start transition.
879///
880/// Before this window, the first SP3 epoch is one day before the epoch encoded
881/// by the filename. After the window, the two epochs are equal. GFZ's official
882/// objects show a non-monotonic, issue-by-issue transition inside the window,
883/// so those sixteen products are cataloged explicitly below.
884const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
885    year: 2022,
886    month: 9,
887    day: 7,
888};
889const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
890    year: 2022,
891    month: 9,
892    day: 8,
893};
894
895/// Relationship between the epoch in an SP3 filename and its first content
896/// epoch, established from the cataloged public product line.
897///
898/// This is archive metadata, not a value inferred from product bytes. Exact
899/// validation uses it to select one required start instant without relaxing
900/// equality against that instant.
901#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
902#[non_exhaustive]
903pub enum Sp3ContentStartConvention {
904    /// The first content epoch equals the epoch encoded by the filename.
905    FilenameEpoch,
906    /// The first content epoch is exactly 24 hours before the filename epoch.
907    FilenameEpochMinusOneDay,
908}
909
910impl Sp3ContentStartConvention {
911    /// Stable catalog code for serialization by language interfaces.
912    #[must_use]
913    pub const fn code(self) -> &'static str {
914        match self {
915            Self::FilenameEpoch => "filename_epoch",
916            Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
917        }
918    }
919
920    /// Whole seconds added to the filename epoch to obtain the first content
921    /// epoch.
922    #[must_use]
923    pub const fn content_start_offset_s(self) -> i64 {
924        match self {
925            Self::FilenameEpoch => 0,
926            Self::FilenameEpochMinusOneDay => -86_400,
927        }
928    }
929}
930
931/// Official GFZ objects for every issue in the two-day transition window.
932///
933/// The source URLs and access date are recorded in
934/// `docs/public-gnss-distribution-sources.md`. Keeping this as an exhaustive
935/// table prevents an appealing date/issue threshold from silently
936/// misclassifying the two reversions visible in the archive.
937const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
938    (
939        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
940        "0000",
941        Sp3ContentStartConvention::FilenameEpoch,
942    ),
943    (
944        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
945        "0300",
946        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
947    ),
948    (
949        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
950        "0600",
951        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
952    ),
953    (
954        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
955        "0900",
956        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
957    ),
958    (
959        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
960        "1200",
961        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
962    ),
963    (
964        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
965        "1500",
966        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
967    ),
968    (
969        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
970        "1800",
971        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
972    ),
973    (
974        GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
975        "2100",
976        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
977    ),
978    (
979        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
980        "0000",
981        Sp3ContentStartConvention::FilenameEpoch,
982    ),
983    (
984        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
985        "0300",
986        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
987    ),
988    (
989        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
990        "0600",
991        Sp3ContentStartConvention::FilenameEpochMinusOneDay,
992    ),
993    (
994        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
995        "0900",
996        Sp3ContentStartConvention::FilenameEpoch,
997    ),
998    (
999        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1000        "1200",
1001        Sp3ContentStartConvention::FilenameEpoch,
1002    ),
1003    (
1004        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1005        "1500",
1006        Sp3ContentStartConvention::FilenameEpoch,
1007    ),
1008    (
1009        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1010        "1800",
1011        Sp3ContentStartConvention::FilenameEpoch,
1012    ),
1013    (
1014        GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1015        "2100",
1016        Sp3ContentStartConvention::FilenameEpoch,
1017    ),
1018];
1019
1020const CENTER_ORDER: [AnalysisCenter; 12] = [
1021    AnalysisCenter::CodRap,
1022    AnalysisCenter::CodPrd1,
1023    AnalysisCenter::CodPrd2,
1024    AnalysisCenter::Igs,
1025    AnalysisCenter::Esa,
1026    AnalysisCenter::Cod,
1027    AnalysisCenter::Gfz,
1028    AnalysisCenter::IgsUlt,
1029    AnalysisCenter::CodUlt,
1030    AnalysisCenter::EsaUlt,
1031    AnalysisCenter::GfzUlt,
1032    AnalysisCenter::WumNrt,
1033];
1034
1035const CATALOG: [CenterCatalogEntry; 12] = [
1036    CenterCatalogEntry {
1037        center: AnalysisCenter::CodRap,
1038        code: "cod_rap",
1039        protocol: ArchiveProtocol::Https,
1040        host: "www.aiub.unibe.ch",
1041        root_url: "https://www.aiub.unibe.ch/download",
1042        products: &COD_RAP_PRODUCTS,
1043        issues: &[],
1044    },
1045    CenterCatalogEntry {
1046        center: AnalysisCenter::CodPrd1,
1047        code: "cod_prd1",
1048        protocol: ArchiveProtocol::Https,
1049        host: "www.aiub.unibe.ch",
1050        root_url: "https://www.aiub.unibe.ch/download",
1051        products: &COD_PRD_PRODUCTS,
1052        issues: &[],
1053    },
1054    CenterCatalogEntry {
1055        center: AnalysisCenter::CodPrd2,
1056        code: "cod_prd2",
1057        protocol: ArchiveProtocol::Https,
1058        host: "www.aiub.unibe.ch",
1059        root_url: "https://www.aiub.unibe.ch/download",
1060        products: &COD_PRD_PRODUCTS,
1061        issues: &[],
1062    },
1063    CenterCatalogEntry {
1064        center: AnalysisCenter::Igs,
1065        code: "igs",
1066        protocol: ArchiveProtocol::Https,
1067        host: "igs.bkg.bund.de",
1068        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1069        products: &IGS_PRODUCTS,
1070        issues: &[],
1071    },
1072    CenterCatalogEntry {
1073        center: AnalysisCenter::Esa,
1074        code: "esa",
1075        protocol: ArchiveProtocol::Https,
1076        host: "navigation-office.esa.int",
1077        root_url: "https://navigation-office.esa.int/products/gnss-products",
1078        products: &ESA_PRODUCTS,
1079        issues: &[],
1080    },
1081    CenterCatalogEntry {
1082        center: AnalysisCenter::Cod,
1083        code: "cod",
1084        protocol: ArchiveProtocol::Https,
1085        host: "www.aiub.unibe.ch",
1086        root_url: "https://www.aiub.unibe.ch/download",
1087        products: &COD_PRODUCTS,
1088        issues: &[],
1089    },
1090    CenterCatalogEntry {
1091        center: AnalysisCenter::Gfz,
1092        code: "gfz",
1093        protocol: ArchiveProtocol::Https,
1094        host: "isdc-data.gfz.de",
1095        root_url: "https://isdc-data.gfz.de/gnss/products",
1096        products: &GFZ_PRODUCTS,
1097        issues: &[],
1098    },
1099    CenterCatalogEntry {
1100        center: AnalysisCenter::IgsUlt,
1101        code: "igs_ult",
1102        protocol: ArchiveProtocol::Https,
1103        host: "igs.bkg.bund.de",
1104        root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1105        products: &IGS_ULT_PRODUCTS,
1106        issues: &OPSULT_ISSUES,
1107    },
1108    CenterCatalogEntry {
1109        center: AnalysisCenter::CodUlt,
1110        code: "cod_ult",
1111        protocol: ArchiveProtocol::Https,
1112        host: "www.aiub.unibe.ch",
1113        // AIUB retired the old ftp.aiub.unibe.ch HTTP tree. Its public file
1114        // browser links products through this stable HTTPS download surface,
1115        // which redirects to the current object store.
1116        root_url: "https://www.aiub.unibe.ch/download",
1117        products: &COD_ULT_PRODUCTS,
1118        issues: &COD_ULT_ISSUES,
1119    },
1120    CenterCatalogEntry {
1121        center: AnalysisCenter::EsaUlt,
1122        code: "esa_ult",
1123        protocol: ArchiveProtocol::Https,
1124        host: "navigation-office.esa.int",
1125        root_url: "https://navigation-office.esa.int/products/gnss-products",
1126        products: &ESA_ULT_PRODUCTS,
1127        issues: &OPSULT_ISSUES,
1128    },
1129    CenterCatalogEntry {
1130        center: AnalysisCenter::GfzUlt,
1131        code: "gfz_ult",
1132        protocol: ArchiveProtocol::Https,
1133        host: "isdc-data.gfz.de",
1134        root_url: "https://isdc-data.gfz.de/gnss/products",
1135        products: &GFZ_ULT_PRODUCTS,
1136        issues: &GFZ_ULT_ISSUES,
1137    },
1138    CenterCatalogEntry {
1139        center: AnalysisCenter::WumNrt,
1140        code: "wum_nrt",
1141        protocol: ArchiveProtocol::Ftp,
1142        host: "igs.gnsswhu.cn",
1143        root_url: "ftp://igs.gnsswhu.cn/pub/gps/products/mgex",
1144        products: &WUM_NRT_PRODUCTS,
1145        issues: &WUM_NRT_ISSUES,
1146    },
1147];
1148
1149const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
1150    protocol: ArchiveProtocol::Https,
1151    host: "s3.amazonaws.com",
1152    compression: ArchiveCompression::Gzip,
1153    root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
1154};
1155
1156const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
1157    protocol: ArchiveProtocol::Https,
1158    host: "celestrak.org",
1159    compression: ArchiveCompression::None,
1160    root_url: "https://celestrak.org/SpaceData",
1161};
1162
1163const ALLOWED_HOSTS: [&str; 11] = [
1164    "www.aiub.unibe.ch",
1165    "download.aiub.unibe.ch",
1166    "zhw-b.s3.cloud.switch.ch",
1167    "navigation-office.esa.int",
1168    "isdc-data.gfz.de",
1169    "igs.bkg.bund.de",
1170    "igs.gnsswhu.cn",
1171    "s3.amazonaws.com",
1172    "celestrak.org",
1173    "cddis.nasa.gov",
1174    "urs.earthdata.nasa.gov",
1175];
1176
1177const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
1178    NoOpenMirrorProduct {
1179        center: "grg",
1180        product_type: "sp3",
1181    },
1182    NoOpenMirrorProduct {
1183        center: "grg",
1184        product_type: "clk",
1185    },
1186    NoOpenMirrorProduct {
1187        center: "wum",
1188        product_type: "sp3",
1189    },
1190    NoOpenMirrorProduct {
1191        center: "wum",
1192        product_type: "clk",
1193    },
1194    NoOpenMirrorProduct {
1195        center: "grg_ult",
1196        product_type: "sp3",
1197    },
1198    NoOpenMirrorProduct {
1199        center: "grg_ult",
1200        product_type: "clk",
1201    },
1202    NoOpenMirrorProduct {
1203        center: "igs",
1204        product_type: "ionex",
1205    },
1206];
1207
1208/// Error returned by the pure data-product catalog.
1209#[derive(Debug, Clone, PartialEq, Eq)]
1210pub enum DataCatalogError {
1211    /// Unknown analysis-center code.
1212    UnknownCenter(String),
1213    /// Unknown product type code.
1214    UnknownProductType(String),
1215    /// The center does not serve the requested product type.
1216    UnsupportedProduct {
1217        /// Analysis center.
1218        center: AnalysisCenter,
1219        /// Product type.
1220        product_type: ProductType,
1221    },
1222    /// A distributor does not carry the requested product family.
1223    UnsupportedDistribution {
1224        /// Explicit distributor.
1225        source: DistributionSource,
1226        /// Requested product family.
1227        product_type: ProductType,
1228    },
1229    /// The catalog does not claim this product family's historical naming era.
1230    UnsupportedProductEra {
1231        /// Analysis center.
1232        center: AnalysisCenter,
1233        /// Product type.
1234        product_type: ProductType,
1235        /// Requested product date.
1236        date: ProductDate,
1237    },
1238    /// A distributor has no verified uniform layout for this product era.
1239    UnsupportedDistributionEra {
1240        /// Explicit distributor.
1241        source: DistributionSource,
1242        /// Analysis center.
1243        center: AnalysisCenter,
1244        /// Product type.
1245        product_type: ProductType,
1246        /// Requested product date.
1247        date: ProductDate,
1248    },
1249    /// An exact request did not include any acceptable distributor.
1250    NoDistributionSources,
1251    /// A caller-constructed identity contained an unsafe official filename.
1252    InvalidOfficialFilename(String),
1253    /// A caller-constructed identity disagrees with its official filename.
1254    InconsistentProductIdentity {
1255        /// Identity field that did not agree with the filename or catalog convention.
1256        field: &'static str,
1257    },
1258    /// The product has no verified anonymous HTTP(S) mirror.
1259    NoOpenMirror {
1260        /// Analysis-center code.
1261        center: String,
1262        /// Product type code.
1263        product_type: String,
1264    },
1265    /// Bad civil date.
1266    InvalidDate {
1267        /// Year.
1268        year: i32,
1269        /// Month.
1270        month: u8,
1271        /// Day.
1272        day: u8,
1273    },
1274    /// Date cannot be represented by this API.
1275    DateOutOfRange,
1276    /// Date precedes the GPS week epoch.
1277    DateBeforeGpsEpoch(ProductDate),
1278    /// GPS day-of-week must be `0..=6`.
1279    InvalidGpsDayOfWeek(u8),
1280    /// Sampling token is not a supported IGS period token.
1281    InvalidSample(String),
1282    /// A syntactically valid cadence is not published for this catalog line.
1283    UnsupportedSample {
1284        /// Analysis center.
1285        center: AnalysisCenter,
1286        /// Product type.
1287        product_type: ProductType,
1288        /// Requested sample token.
1289        sample: String,
1290    },
1291    /// Coverage-span token is not a supported IGS period token.
1292    InvalidSpan(String),
1293    /// Issue time is malformed.
1294    InvalidIssue(String),
1295    /// The center requires an issue time.
1296    MissingIssue {
1297        /// Analysis center.
1298        center: AnalysisCenter,
1299    },
1300    /// The center does not use issue times.
1301    UnexpectedIssue {
1302        /// Analysis center.
1303        center: AnalysisCenter,
1304    },
1305    /// Issue time is valid text but not published by this center.
1306    UnsupportedIssue {
1307        /// Analysis center.
1308        center: AnalysisCenter,
1309        /// Issue time.
1310        issue: String,
1311    },
1312    /// The target datetime was invalid.
1313    InvalidDateTime {
1314        /// Hour.
1315        hour: u8,
1316        /// Minute.
1317        minute: u8,
1318        /// Second.
1319        second: u8,
1320    },
1321    /// No ultra-rapid issue exists at or before the requested target.
1322    NoUltraIssue,
1323    /// No available ultra-rapid issue exists at or before the requested target.
1324    NoAvailableUltraIssue,
1325    /// An archive listing body did not classify as any recognized listing
1326    /// dialect. Deliberately not best-effort: a silent empty parse would be
1327    /// indistinguishable from "nothing published".
1328    UnrecognizedArchiveListing {
1329        /// Why classification failed.
1330        reason: String,
1331    },
1332    /// Station identifier is not a 9-character upper-case alphanumeric token.
1333    InvalidStation(String),
1334    /// Terrain lookup coordinate is non-finite or outside the reader range.
1335    InvalidCoordinate {
1336        /// Latitude as `f64::to_bits()`.
1337        lat_deg_bits: u64,
1338        /// Longitude as `f64::to_bits()`.
1339        lon_deg_bits: u64,
1340    },
1341    /// Terrain tile index is outside the valid one-degree cell range.
1342    InvalidTileIndex {
1343        /// Latitude index.
1344        lat_index: i32,
1345        /// Longitude index.
1346        lon_index: i32,
1347    },
1348    /// Skadi tile identifier is malformed.
1349    InvalidTileId(String),
1350}
1351
1352impl fmt::Display for DataCatalogError {
1353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1354        match self {
1355            Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1356            Self::UnknownProductType(product_type) => {
1357                write!(f, "unknown product type {product_type:?}")
1358            }
1359            Self::UnsupportedProduct {
1360                center,
1361                product_type,
1362            } => write!(f, "{center} does not serve {product_type}"),
1363            Self::UnsupportedDistribution {
1364                source,
1365                product_type,
1366            } => write!(
1367                f,
1368                "distributor {} does not serve {product_type}",
1369                source.code()
1370            ),
1371            Self::UnsupportedProductEra {
1372                center,
1373                product_type,
1374                date,
1375            } => write!(
1376                f,
1377                "{center}/{product_type} has no cataloged naming convention for {date}"
1378            ),
1379            Self::UnsupportedDistributionEra {
1380                source,
1381                center,
1382                product_type,
1383                date,
1384            } => write!(
1385                f,
1386                "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1387                source.code()
1388            ),
1389            Self::NoDistributionSources => {
1390                write!(f, "exact product request has no distributors")
1391            }
1392            Self::InvalidOfficialFilename(filename) => {
1393                write!(f, "invalid official product filename {filename:?}")
1394            }
1395            Self::InconsistentProductIdentity { field } => {
1396                write!(
1397                    f,
1398                    "product identity field {field:?} disagrees with its official filename"
1399                )
1400            }
1401            Self::NoOpenMirror {
1402                center,
1403                product_type,
1404            } => write!(f, "{center}/{product_type} has no open mirror"),
1405            Self::InvalidDate { year, month, day } => {
1406                write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1407            }
1408            Self::DateOutOfRange => write!(f, "product date is out of range"),
1409            Self::DateBeforeGpsEpoch(date) => {
1410                write!(f, "product date {date} is before the GPS week epoch")
1411            }
1412            Self::InvalidGpsDayOfWeek(day) => {
1413                write!(f, "invalid GPS day-of-week {day}")
1414            }
1415            Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1416            Self::UnsupportedSample {
1417                center,
1418                product_type,
1419                sample,
1420            } => write!(
1421                f,
1422                "{center}/{product_type} does not publish sample interval {sample:?}"
1423            ),
1424            Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1425            Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1426            Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1427            Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1428            Self::UnsupportedIssue { center, issue } => {
1429                write!(f, "{center} does not publish issue {issue:?}")
1430            }
1431            Self::InvalidDateTime {
1432                hour,
1433                minute,
1434                second,
1435            } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1436            Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1437            Self::NoAvailableUltraIssue => {
1438                write!(f, "no available ultra-rapid issue at or before target")
1439            }
1440            Self::UnrecognizedArchiveListing { reason } => {
1441                write!(f, "unrecognized archive listing: {reason}")
1442            }
1443            Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1444            Self::InvalidCoordinate {
1445                lat_deg_bits,
1446                lon_deg_bits,
1447            } => write!(
1448                f,
1449                "invalid terrain coordinate lat={} lon={}",
1450                f64::from_bits(*lat_deg_bits),
1451                f64::from_bits(*lon_deg_bits)
1452            ),
1453            Self::InvalidTileIndex {
1454                lat_index,
1455                lon_index,
1456            } => write!(
1457                f,
1458                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1459            ),
1460            Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1461        }
1462    }
1463}
1464
1465impl std::error::Error for DataCatalogError {}
1466
1467/// Error returned by SRTM HGT to DTED conversion.
1468#[derive(Debug, Clone, PartialEq, Eq)]
1469pub enum HgtConversionError {
1470    /// The decompressed HGT payload is not the SRTM1 byte length.
1471    BadLength {
1472        /// Expected byte length.
1473        expected: usize,
1474        /// Actual byte length.
1475        got: usize,
1476    },
1477    /// Terrain tile index is outside the valid one-degree cell range.
1478    InvalidTileIndex {
1479        /// Latitude index.
1480        lat_index: i32,
1481        /// Longitude index.
1482        lon_index: i32,
1483    },
1484}
1485
1486impl fmt::Display for HgtConversionError {
1487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1488        match self {
1489            Self::BadLength { expected, got } => {
1490                write!(
1491                    f,
1492                    "invalid SRTM1 HGT length: expected {expected}, got {got}"
1493                )
1494            }
1495            Self::InvalidTileIndex {
1496                lat_index,
1497                lon_index,
1498            } => write!(
1499                f,
1500                "invalid terrain tile index lat={lat_index} lon={lon_index}"
1501            ),
1502        }
1503    }
1504}
1505
1506impl std::error::Error for HgtConversionError {}
1507
1508const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1509const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1510const MIN_TERRAIN_LON_INDEX: i32 = -180;
1511const MAX_TERRAIN_LON_INDEX: i32 = 179;
1512const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1513const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1514const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1515const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1516const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1517const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1518const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1519const DTED_SRTM1_LEN: usize =
1520    terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1521
1522/// Civil UTC date used by product archive names.
1523#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1524pub struct ProductDate {
1525    /// Year.
1526    pub year: i32,
1527    /// Month in `1..=12`.
1528    pub month: u8,
1529    /// Day of month.
1530    pub day: u8,
1531}
1532
1533impl ProductDate {
1534    /// Build and validate a civil date.
1535    pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1536        let days = days_in_month(i64::from(year), i64::from(month));
1537        if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1538            return Err(DataCatalogError::InvalidDate { year, month, day });
1539        }
1540        Ok(Self { year, month, day })
1541    }
1542
1543    /// Build a date from GPS week and day-of-week (`0` = Sunday).
1544    pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1545        if day_of_week > 6 {
1546            return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1547        }
1548        let epoch_jdn =
1549            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1550        let offset_days = i64::from(week)
1551            .checked_mul(7)
1552            .and_then(|days| days.checked_add(i64::from(day_of_week)))
1553            .ok_or(DataCatalogError::DateOutOfRange)?;
1554        product_date_from_jdn(
1555            epoch_jdn
1556                .checked_add(offset_days)
1557                .ok_or(DataCatalogError::DateOutOfRange)?,
1558        )
1559    }
1560
1561    /// GPS week for this date.
1562    pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1563        week_from_calendar(
1564            TimeScale::Gpst,
1565            i64::from(self.year),
1566            i64::from(self.month),
1567            i64::from(self.day),
1568        )
1569        .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1570    }
1571
1572    /// GPS day of week (`0` = Sunday, `6` = Saturday) for this date.
1573    pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1574        let epoch_jdn =
1575            week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1576        let days = self
1577            .julian_day_number()
1578            .checked_sub(epoch_jdn)
1579            .ok_or(DataCatalogError::DateOutOfRange)?;
1580        if days < 0 {
1581            return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1582        }
1583        u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1584    }
1585
1586    /// Day-of-year in `1..=366`.
1587    #[must_use]
1588    pub fn day_of_year(self) -> u16 {
1589        day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1590    }
1591
1592    fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1593        product_date_from_jdn(
1594            self.julian_day_number()
1595                .checked_add(days)
1596                .ok_or(DataCatalogError::DateOutOfRange)?,
1597        )
1598    }
1599
1600    fn julian_day_number(self) -> i64 {
1601        julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1602    }
1603}
1604
1605impl fmt::Display for ProductDate {
1606    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1607        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1608    }
1609}
1610
1611/// Civil UTC date and time used for ultra-rapid issue selection.
1612#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1613pub struct ProductDateTime {
1614    /// Date.
1615    pub date: ProductDate,
1616    /// Hour in `0..=23`.
1617    pub hour: u8,
1618    /// Minute in `0..=59`.
1619    pub minute: u8,
1620    /// Second in `0..=59`.
1621    pub second: u8,
1622}
1623
1624impl ProductDateTime {
1625    /// Build and validate a civil date and time.
1626    pub fn new(
1627        date: ProductDate,
1628        hour: u8,
1629        minute: u8,
1630        second: u8,
1631    ) -> Result<Self, DataCatalogError> {
1632        if hour > 23 || minute > 59 || second > 59 {
1633            return Err(DataCatalogError::InvalidDateTime {
1634                hour,
1635                minute,
1636                second,
1637            });
1638        }
1639        Ok(Self {
1640            date,
1641            hour,
1642            minute,
1643            second,
1644        })
1645    }
1646
1647    fn ordering_minutes(self) -> i64 {
1648        self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1649    }
1650}
1651
1652/// Ultra-rapid issue date and `HHMM` issue time.
1653#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1654pub struct UltraIssue {
1655    /// Product date.
1656    pub date: ProductDate,
1657    /// Issue time.
1658    pub issue: String,
1659}
1660
1661impl UltraIssue {
1662    /// Build and validate an ultra-rapid issue.
1663    pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1664        validate_issue(issue)?;
1665        Ok(Self {
1666            date,
1667            issue: issue.to_string(),
1668        })
1669    }
1670}
1671
1672/// One generated ultra-rapid SP3 archive candidate.
1673#[derive(Debug, Clone, PartialEq, Eq)]
1674pub struct UltraSp3Location {
1675    /// Stable catalog label identifying the primary or overlapping dated rule.
1676    pub pattern: String,
1677    /// Product span token used by the candidate.
1678    pub span: String,
1679    /// Sampling token used by the candidate.
1680    pub sample: String,
1681    /// Archive filename without a transport compression suffix.
1682    pub filename: String,
1683    /// Full archive URL, including its compression suffix when applicable.
1684    pub url: String,
1685    /// Archive compression for this candidate.
1686    pub compression: ArchiveCompression,
1687}
1688
1689/// Exact identity of one public GNSS product, independent of distributor.
1690///
1691/// The official filename is part of the identity. Transport compression and
1692/// URL belong to [`DistributionLocation`] because two distributors may package
1693/// the same decompressed product differently.
1694#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1695pub struct ProductIdentity {
1696    /// Product family.
1697    pub family: ProductType,
1698    /// Catalog analysis-center product line.
1699    pub analysis_center: AnalysisCenter,
1700    /// Producing or combining organization.
1701    pub publisher: ProductPublisher,
1702    /// Solution class or tier.
1703    pub solution: SolutionClass,
1704    /// Campaign or project.
1705    pub campaign: ProductCampaign,
1706    /// Product-line version encoded by the long filename.
1707    pub version: u8,
1708    /// Product epoch encoded by the official filename.
1709    ///
1710    /// This is also the content start for most SP3 products. Cataloged
1711    /// historical products whose first epoch differs retain the filename epoch
1712    /// here; exact validation derives their required content start separately.
1713    pub date: ProductDate,
1714    /// Optional `HHMM` issue/epoch time encoded by the official filename.
1715    pub issue: Option<String>,
1716    /// Intended coverage period token, for example `01D`.
1717    pub span: String,
1718    /// Sampling interval token, for example `05M`.
1719    pub sample: String,
1720    /// Official filename without transport compression suffix.
1721    pub official_filename: String,
1722    /// Public serialization format.
1723    pub format: ProductFormat,
1724    /// Parsed serialization revision when the request constrains one.
1725    ///
1726    /// Catalog identities leave this unset because the revision is carried by
1727    /// product content rather than the official filename. A resolved identity
1728    /// may set it after parsing the product.
1729    pub format_version: Option<String>,
1730    /// Prediction horizon when the product line encodes one.
1731    pub prediction_horizon_days: Option<u8>,
1732}
1733
1734impl ProductIdentity {
1735    /// Validate that every identity field agrees with the official filename.
1736    ///
1737    /// This is required for caller-constructed values before using them in a
1738    /// request, URL, or cache path. Catalog-produced identities are validated
1739    /// before they are returned.
1740    pub fn validate(&self) -> Result<(), DataCatalogError> {
1741        validate_official_filename(&self.official_filename)?;
1742        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1743        validate_sample(&self.sample)?;
1744        validate_span(&self.span)?;
1745        if let Some(issue) = self.issue.as_deref() {
1746            validate_issue(issue)?;
1747        }
1748
1749        // Establish catalog support before deriving any URL. A syntactically
1750        // plausible caller-built identity is not evidence that the selected
1751        // center publishes that product family.
1752        let convention = product_convention(self.analysis_center, self.family)?;
1753        validate_product_date(self.analysis_center, self.family, self.date)?;
1754        if self.span != convention.span {
1755            return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1756        }
1757        validate_catalog_sample(
1758            self.analysis_center,
1759            self.family,
1760            self.date,
1761            &self.sample,
1762            self.issue.as_deref(),
1763        )?;
1764
1765        if self.format != product_format(self.family) {
1766            return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1767        }
1768
1769        if self
1770            .format_version
1771            .as_deref()
1772            .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1773        {
1774            return Err(DataCatalogError::InconsistentProductIdentity {
1775                field: "format_version",
1776            });
1777        }
1778
1779        let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1780            (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1781            (_, SolutionClass::Predicted, _) => false,
1782            (_, _, None) => true,
1783            (_, _, Some(_)) => false,
1784        };
1785        if !horizon_valid {
1786            return Err(DataCatalogError::InconsistentProductIdentity {
1787                field: "prediction_horizon_days",
1788            });
1789        }
1790        let descriptor = product_type_convention(self.family);
1791        let legacy_igs_final =
1792            uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1793        if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1794            let entry = center_catalog(self.analysis_center)
1795                .expect("validated analysis center has a catalog entry");
1796            let issue_valid = if entry.issues.is_empty() {
1797                self.issue.as_deref() == Some("0000")
1798            } else {
1799                self.issue
1800                    .as_deref()
1801                    .is_some_and(|issue| entry.issues.contains(&issue))
1802            };
1803            if !issue_valid {
1804                return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1805            }
1806        }
1807        let expected = if legacy_igs_final {
1808            let fields_valid = self.publisher == ProductPublisher::Igs
1809                && self.solution == SolutionClass::Final
1810                && self.campaign == ProductCampaign::Operational
1811                && self.version == 0
1812                && self.issue.as_deref() == Some("0000")
1813                && self.span == convention.span
1814                && self.sample == convention.default_sample;
1815            if !fields_valid {
1816                return Err(DataCatalogError::InconsistentProductIdentity {
1817                    field: "legacy_igs_final",
1818                });
1819            }
1820            format!(
1821                "igs{:04}{}.sp3",
1822                self.date.gps_week()?,
1823                self.date.gps_day_of_week()?
1824            )
1825        } else {
1826            match descriptor.kind {
1827                ProductFilenameKind::Sampled => {
1828                    let solution_token = self.solution.filename_token().ok_or(
1829                        DataCatalogError::InconsistentProductIdentity { field: "solution" },
1830                    )?;
1831                    format!(
1832                        "{}{}{}{}_{}_{}_{}_{}.{}",
1833                        self.publisher.code(),
1834                        self.version,
1835                        self.campaign.code(),
1836                        solution_token,
1837                        date_block(self.date, self.issue.as_deref()),
1838                        self.span,
1839                        self.sample,
1840                        descriptor.content_code,
1841                        descriptor.extension
1842                    )
1843                }
1844                ProductFilenameKind::Nav => {
1845                    let nav_fields_valid = self.publisher == ProductPublisher::Igs
1846                        && self.solution == SolutionClass::Broadcast
1847                        && self.campaign == ProductCampaign::Broadcast
1848                        && self.version == 0
1849                        && self.issue.is_none()
1850                        && self.span == "01D"
1851                        && self.sample == "01D";
1852                    if !nav_fields_valid {
1853                        return Err(DataCatalogError::InconsistentProductIdentity {
1854                            field: "broadcast_navigation",
1855                        });
1856                    }
1857                    format!(
1858                        "BRDC00WRD_R_{}_{}_{}.{}",
1859                        date_block(self.date, None),
1860                        self.span,
1861                        descriptor.content_code,
1862                        descriptor.extension
1863                    )
1864                }
1865            }
1866        };
1867        if expected != self.official_filename {
1868            return Err(DataCatalogError::InconsistentProductIdentity {
1869                field: "official_filename",
1870            });
1871        }
1872        if self.publisher != self.analysis_center.publisher()
1873            || self.solution != product_solution_class(self.analysis_center, self.family)?
1874            || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1875        {
1876            return Err(DataCatalogError::InconsistentProductIdentity {
1877                field: "analysis_center",
1878            });
1879        }
1880
1881        if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1882            let expected_catalog_filename = format!(
1883                "{}_{}_{}_{}_{}.{}",
1884                convention.token,
1885                date_block(self.date, self.issue.as_deref()),
1886                self.span,
1887                self.sample,
1888                descriptor.content_code,
1889                descriptor.extension
1890            );
1891            if expected_catalog_filename != self.official_filename {
1892                return Err(DataCatalogError::InconsistentProductIdentity {
1893                    field: "analysis_center",
1894                });
1895            }
1896        }
1897        Ok(())
1898    }
1899
1900    /// Deterministic identity key suitable for a portable cache layout.
1901    pub fn key(&self) -> Result<String, DataCatalogError> {
1902        use sha2::{Digest, Sha256};
1903
1904        let canonical = self.canonical_bytes()?;
1905        let digest = Sha256::digest(canonical);
1906        Ok(format!(
1907            "{}-{}-{}",
1908            self.publisher.code().to_ascii_lowercase(),
1909            self.solution.code(),
1910            digest[..10]
1911                .iter()
1912                .map(|byte| format!("{byte:02x}"))
1913                .collect::<String>()
1914        ))
1915    }
1916
1917    /// Canonical, unambiguous bytes containing every exact identity field.
1918    ///
1919    /// The encoding is ASCII/UTF-8 field text separated by NUL bytes. It is a
1920    /// stable cross-interface input to cache identity hashing, not a display
1921    /// or interchange document.
1922    pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1923        self.validate()?;
1924        let date = format!(
1925            "{:04}-{:02}-{:02}",
1926            self.date.year, self.date.month, self.date.day
1927        );
1928        let version = self.version.to_string();
1929        let prediction = self
1930            .prediction_horizon_days
1931            .map(|days| days.to_string())
1932            .unwrap_or_default();
1933        let fields = [
1934            self.family.code(),
1935            self.analysis_center.code(),
1936            self.publisher.code(),
1937            self.solution.code(),
1938            self.campaign.code(),
1939            version.as_str(),
1940            date.as_str(),
1941            self.issue.as_deref().unwrap_or_default(),
1942            self.span.as_str(),
1943            self.sample.as_str(),
1944            self.official_filename.as_str(),
1945            self.format.code(),
1946            self.format_version.as_deref().unwrap_or_default(),
1947            prediction.as_str(),
1948        ];
1949        if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1950            return Err(DataCatalogError::InconsistentProductIdentity {
1951                field: "canonical_encoding",
1952            });
1953        }
1954        Ok(fields.join("\0").into_bytes())
1955    }
1956
1957    /// Deterministic cache path for this identity and distributor.
1958    pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1959        Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1960    }
1961}
1962
1963/// Required first-content offset for a validated exact SP3 identity.
1964///
1965/// This is catalog data, not a property inferred from the bytes under test.
1966/// Keeping the lookup identity-based prevents a caller from weakening exact
1967/// start validation with an arbitrary override.
1968pub(crate) fn exact_sp3_content_start_offset_s(
1969    identity: &ProductIdentity,
1970) -> Result<i64, DataCatalogError> {
1971    identity.validate()?;
1972    if identity.family != ProductType::Sp3 {
1973        return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
1974    }
1975
1976    let entry =
1977        center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
1978    // Sampled non-issue identities encode midnight as `Some("0000")`, while
1979    // the public catalog query follows ProductSpec construction and accepts no
1980    // issue for those centers.
1981    let catalog_issue = if entry.issues.is_empty() {
1982        None
1983    } else {
1984        identity.issue.as_deref()
1985    };
1986    Ok(
1987        sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
1988            .content_start_offset_s(),
1989    )
1990}
1991
1992/// Return the official content-start convention for one cataloged SP3 product.
1993///
1994/// `issue` follows the same rules as [`product`]: it is required for
1995/// ultra-rapid centers, must be one of that center's published issues, and must
1996/// be absent for product lines without issue times. Sampling cadence is not an
1997/// input because the cataloged content-start convention is shared by every
1998/// supported cadence of the same product issue.
1999pub fn sp3_content_start_convention(
2000    center: AnalysisCenter,
2001    date: ProductDate,
2002    issue: Option<&str>,
2003) -> Result<Sp3ContentStartConvention, DataCatalogError> {
2004    ProductDate::new(date.year, date.month, date.day)?;
2005    product_convention(center, ProductType::Sp3)?;
2006    validate_product_date(center, ProductType::Sp3, date)?;
2007    validate_issue_for_center(center, issue)?;
2008
2009    sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
2010        DataCatalogError::UnsupportedIssue {
2011            center,
2012            issue: issue.unwrap_or_default().to_owned(),
2013        }
2014    })
2015}
2016
2017fn sp3_content_start_convention_inner(
2018    center: AnalysisCenter,
2019    date: ProductDate,
2020    issue: Option<&str>,
2021) -> Option<Sp3ContentStartConvention> {
2022    if center != AnalysisCenter::GfzUlt {
2023        return Some(Sp3ContentStartConvention::FilenameEpoch);
2024    }
2025    if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
2026        return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
2027    }
2028    if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
2029        return Some(Sp3ContentStartConvention::FilenameEpoch);
2030    }
2031
2032    let issue = issue?;
2033    GFZ_ULTRA_START_TRANSITION
2034        .iter()
2035        .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
2036        .map(|(_, _, convention)| *convention)
2037}
2038
2039/// Distribution metadata for an exact product identity.
2040#[derive(Debug, Clone, PartialEq, Eq)]
2041pub struct DistributionLocation {
2042    /// Selected distributor.
2043    pub source: DistributionSource,
2044    /// Original public URL. Local and in-memory sources have no URL.
2045    pub original_url: Option<String>,
2046    /// Archive filename as served, including transport compression suffix.
2047    pub archive_filename: String,
2048    /// Compression applied by this distributor.
2049    pub compression: ArchiveCompression,
2050}
2051
2052/// Exact product request with an ordered, caller-controlled distributor list.
2053#[derive(Debug, Clone, PartialEq, Eq)]
2054pub struct ProductRequest {
2055    /// Exact requested identity.
2056    pub identity: ProductIdentity,
2057    /// Ordered acceptable distributors for that identity only.
2058    pub distributors: Vec<DistributionSource>,
2059}
2060
2061/// Complete-set validation failure for exact product identities.
2062#[derive(Debug, Clone, PartialEq, Eq)]
2063pub enum ExactProductSetError {
2064    /// A complete set must declare at least one expected product.
2065    EmptyExpected,
2066    /// One expected identity was not internally consistent.
2067    InvalidExpected {
2068        /// Zero-based position in the expected identity list.
2069        index: usize,
2070        /// Identity validation failure.
2071        source: DataCatalogError,
2072    },
2073    /// One available identity was not internally consistent.
2074    InvalidAvailable {
2075        /// Zero-based position in the available identity list.
2076        index: usize,
2077        /// Identity validation failure.
2078        source: DataCatalogError,
2079    },
2080    /// The available identities were not exactly the expected set.
2081    Mismatch {
2082        /// Expected identities that were not available.
2083        missing: Vec<ProductIdentity>,
2084        /// Available identities that were not expected.
2085        unexpected: Vec<ProductIdentity>,
2086        /// Identities declared more than once in the expected list.
2087        duplicate_expected: Vec<ProductIdentity>,
2088        /// Identities declared more than once in the available list.
2089        duplicate_available: Vec<ProductIdentity>,
2090    },
2091}
2092
2093impl fmt::Display for ExactProductSetError {
2094    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2095        match self {
2096            Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2097            Self::InvalidExpected { index, source } => {
2098                write!(f, "expected product {index} is invalid: {source}")
2099            }
2100            Self::InvalidAvailable { index, source } => {
2101                write!(f, "available product {index} is invalid: {source}")
2102            }
2103            Self::Mismatch {
2104                missing,
2105                unexpected,
2106                duplicate_expected,
2107                duplicate_available,
2108            } => write!(
2109                f,
2110                "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2111                identity_list(missing),
2112                identity_list(unexpected),
2113                identity_list(duplicate_expected),
2114                identity_list(duplicate_available),
2115            ),
2116        }
2117    }
2118}
2119
2120impl std::error::Error for ExactProductSetError {}
2121
2122/// Require an available product inventory to match an expected exact set.
2123///
2124/// Every identity is validated before comparison. The expected list must be
2125/// non-empty, neither list may contain duplicates, every expected identity must
2126/// be available, and no undeclared identity may be present. Comparison uses the
2127/// complete [`ProductIdentity`], not only its filename, so metadata that
2128/// distinguishes otherwise identical archive names remains authoritative.
2129///
2130/// This function is a sans-IO completion gate: pass only identities from
2131/// successfully validated acquisitions, and do not start dependent processing
2132/// unless it returns `Ok(())`. For SP3 observed/predicted timing, use
2133/// [`crate::sp3::Sp3::prediction_summary`]; issue times and catalog fields are
2134/// not substitutes for the record flags in the product itself.
2135pub fn validate_exact_product_set(
2136    expected: &[ProductIdentity],
2137    available: &[ProductIdentity],
2138) -> Result<(), ExactProductSetError> {
2139    if expected.is_empty() {
2140        return Err(ExactProductSetError::EmptyExpected);
2141    }
2142    for (index, identity) in expected.iter().enumerate() {
2143        identity
2144            .validate()
2145            .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2146    }
2147    for (index, identity) in available.iter().enumerate() {
2148        identity
2149            .validate()
2150            .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2151    }
2152
2153    let expected_counts = identity_counts(expected);
2154    let available_counts = identity_counts(available);
2155    let missing = unique_matching(expected, |identity| {
2156        !available_counts.contains_key(identity)
2157    });
2158    let unexpected = unique_matching(available, |identity| {
2159        !expected_counts.contains_key(identity)
2160    });
2161    let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2162    let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2163
2164    if missing.is_empty()
2165        && unexpected.is_empty()
2166        && duplicate_expected.is_empty()
2167        && duplicate_available.is_empty()
2168    {
2169        Ok(())
2170    } else {
2171        Err(ExactProductSetError::Mismatch {
2172            missing,
2173            unexpected,
2174            duplicate_expected,
2175            duplicate_available,
2176        })
2177    }
2178}
2179
2180fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2181    let mut counts = HashMap::with_capacity(identities.len());
2182    for identity in identities {
2183        *counts.entry(identity).or_insert(0) += 1;
2184    }
2185    counts
2186}
2187
2188fn unique_matching(
2189    identities: &[ProductIdentity],
2190    mut predicate: impl FnMut(&ProductIdentity) -> bool,
2191) -> Vec<ProductIdentity> {
2192    let mut seen = HashSet::with_capacity(identities.len());
2193    identities
2194        .iter()
2195        .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2196        .cloned()
2197        .collect()
2198}
2199
2200fn identity_list(identities: &[ProductIdentity]) -> String {
2201    if identities.is_empty() {
2202        return "none".to_string();
2203    }
2204    identities
2205        .iter()
2206        .map(|identity| {
2207            identity
2208                .key()
2209                .unwrap_or_else(|_| identity.official_filename.clone())
2210        })
2211        .collect::<Vec<_>>()
2212        .join(", ")
2213}
2214
2215impl ProductRequest {
2216    /// Build an exact request. At least one distributor is required.
2217    pub fn new(
2218        identity: ProductIdentity,
2219        distributors: Vec<DistributionSource>,
2220    ) -> Result<Self, DataCatalogError> {
2221        if distributors.is_empty() {
2222            return Err(DataCatalogError::NoDistributionSources);
2223        }
2224        identity.validate()?;
2225        Ok(Self {
2226            identity,
2227            distributors,
2228        })
2229    }
2230}
2231
2232/// A pure product specification that resolves to one archive filename and URL.
2233#[derive(Debug, Clone, PartialEq, Eq)]
2234pub struct ProductSpec {
2235    /// Analysis center.
2236    pub center: AnalysisCenter,
2237    /// Product type.
2238    pub product_type: ProductType,
2239    /// Product date.
2240    pub date: ProductDate,
2241    /// Sampling token.
2242    pub sample: String,
2243    /// Optional issue time for ultra-rapid products.
2244    pub issue: Option<String>,
2245}
2246
2247impl ProductSpec {
2248    /// Build a product specification and validate it against the catalog.
2249    pub fn new(
2250        center: AnalysisCenter,
2251        product_type: ProductType,
2252        date: ProductDate,
2253        sample: &str,
2254        issue: Option<&str>,
2255    ) -> Result<Self, DataCatalogError> {
2256        ProductDate::new(date.year, date.month, date.day)?;
2257        validate_product(center, product_type, date, sample, issue)?;
2258        Ok(Self {
2259            center,
2260            product_type,
2261            date,
2262            sample: sample.to_string(),
2263            issue: issue.map(ToOwned::to_owned),
2264        })
2265    }
2266
2267    /// GPS week for the product date.
2268    pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2269        self.date.gps_week()
2270    }
2271
2272    /// Day-of-year for the product date.
2273    #[must_use]
2274    pub fn day_of_year(&self) -> u16 {
2275        self.date.day_of_year()
2276    }
2277
2278    /// Canonical official filename without archive compression suffix.
2279    ///
2280    /// IGS combined final SP3 products use the historical
2281    /// `igs<week><day>.sp3` convention before GPS week 2238 and the IGS long
2282    /// filename convention from week 2238 onward.
2283    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2284        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2285        let convention = validate_product(
2286            self.center,
2287            self.product_type,
2288            self.date,
2289            &self.sample,
2290            self.issue.as_deref(),
2291        )?;
2292        if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2293            return Ok(format!(
2294                "igs{:04}{}.sp3",
2295                self.date.gps_week()?,
2296                self.date.gps_day_of_week()?
2297            ));
2298        }
2299        let descriptor = product_type_convention(self.product_type);
2300        Ok(match descriptor.kind {
2301            ProductFilenameKind::Sampled => format!(
2302                "{}_{}_{}_{}_{}.{}",
2303                convention.token,
2304                date_block(self.date, self.issue.as_deref()),
2305                convention.span,
2306                self.sample,
2307                descriptor.content_code,
2308                descriptor.extension
2309            ),
2310            ProductFilenameKind::Nav => format!(
2311                "{}_R_{}_{}_{}.{}",
2312                convention.token,
2313                date_block(self.date, None),
2314                convention.span,
2315                descriptor.content_code,
2316                descriptor.extension
2317            ),
2318        })
2319    }
2320
2321    /// Full archive URL, including its cataloged transport-compression suffix.
2322    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2323        ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2324        let convention = validate_product(
2325            self.center,
2326            self.product_type,
2327            self.date,
2328            &self.sample,
2329            self.issue.as_deref(),
2330        )?;
2331        if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2332            return Err(DataCatalogError::UnsupportedDistributionEra {
2333                source: DistributionSource::Direct,
2334                center: self.center,
2335                product_type: self.product_type,
2336                date: self.date,
2337            });
2338        }
2339        let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2340        let filename = self.canonical_filename()?;
2341        let compression = product_archive_compression(
2342            self.center,
2343            self.product_type,
2344            self.date,
2345            convention.compression,
2346        )?;
2347        Ok(format!(
2348            "{}/{}/{}{}",
2349            entry.root_url,
2350            product_dir_path(self.center, convention.layout, self.date)?,
2351            filename,
2352            compression.suffix()
2353        ))
2354    }
2355
2356    /// Exact product identity, independent of distributor.
2357    pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2358        let convention = validate_product(
2359            self.center,
2360            self.product_type,
2361            self.date,
2362            &self.sample,
2363            self.issue.as_deref(),
2364        )?;
2365        let descriptor = product_type_convention(self.product_type);
2366        let campaign = match descriptor.kind {
2367            ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2368            ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2369                Some("OPS") => ProductCampaign::Operational,
2370                Some("MGN") => ProductCampaign::MultiGnss,
2371                Some("MGX") => ProductCampaign::MultiGnssExperiment,
2372                _ => {
2373                    return Err(DataCatalogError::InconsistentProductIdentity {
2374                        field: "campaign",
2375                    });
2376                }
2377            },
2378        };
2379        let identity = ProductIdentity {
2380            family: self.product_type,
2381            analysis_center: self.center,
2382            publisher: self.center.publisher(),
2383            solution: product_solution_class(self.center, self.product_type)?,
2384            campaign,
2385            version: 0,
2386            date: self.date,
2387            issue: match descriptor.kind {
2388                ProductFilenameKind::Sampled => {
2389                    Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2390                }
2391                ProductFilenameKind::Nav => None,
2392            },
2393            span: convention.span.to_string(),
2394            sample: self.sample.clone(),
2395            official_filename: self.canonical_filename()?,
2396            format: product_format(self.product_type),
2397            format_version: None,
2398            prediction_horizon_days: self.center.prediction_horizon_days(),
2399        };
2400        identity.validate()?;
2401        Ok(identity)
2402    }
2403
2404    /// Resolve one explicit distributor without changing product identity.
2405    pub fn distribution_location(
2406        &self,
2407        source: DistributionSource,
2408    ) -> Result<DistributionLocation, DataCatalogError> {
2409        let identity = self.identity()?;
2410        distribution_location_for_identity(&identity, source)
2411    }
2412}
2413
2414/// A pure station observation specification.
2415#[derive(Debug, Clone, PartialEq, Eq)]
2416pub struct StationObservationSpec {
2417    /// 9-character RINEX 3 site identifier.
2418    pub station: String,
2419    /// Observation date.
2420    pub date: ProductDate,
2421    /// Sampling token.
2422    pub sample: String,
2423}
2424
2425impl StationObservationSpec {
2426    /// Build and validate a daily station observation product.
2427    pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2428        validate_station(station)?;
2429        validate_sample(sample)?;
2430        Ok(Self {
2431            station: station.to_string(),
2432            date,
2433            sample: sample.to_string(),
2434        })
2435    }
2436
2437    /// Canonical RINEX 3 CRINEX filename without archive compression suffix.
2438    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2439        station_obs_filename(&self.station, self.date, &self.sample)
2440    }
2441
2442    /// Full archive URL, including `.gz`.
2443    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2444        station_obs_url(&self.station, self.date, &self.sample)
2445    }
2446}
2447
2448/// Static catalog entries, in the same order as the binding data catalog.
2449#[must_use]
2450pub const fn catalog() -> &'static [CenterCatalogEntry] {
2451    &CATALOG
2452}
2453
2454/// Supported center codes, in catalog order.
2455#[must_use]
2456pub const fn centers() -> &'static [AnalysisCenter] {
2457    &CENTER_ORDER
2458}
2459
2460/// Supported product types.
2461#[must_use]
2462pub const fn product_types() -> &'static [ProductTypeConvention] {
2463    &PRODUCT_TYPE_CONVENTIONS
2464}
2465
2466/// Archive hosts present in the catalog.
2467#[must_use]
2468pub const fn allowed_hosts() -> &'static [&'static str] {
2469    &ALLOWED_HOSTS
2470}
2471
2472/// Catalog entry for the Skadi SRTM terrain source.
2473#[must_use]
2474pub const fn skadi_source_entry() -> TerrainSourceEntry {
2475    SKADI_SOURCE
2476}
2477
2478/// Catalog entry for the CelesTrak CSSI space-weather source.
2479#[must_use]
2480pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2481    CELESTRAK_SPACE_WEATHER_SOURCE
2482}
2483
2484/// Filename for a CelesTrak space-weather product.
2485#[must_use]
2486pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2487    match product {
2488        SpaceWeatherProduct::All => "SW-All.csv",
2489        SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2490    }
2491}
2492
2493/// Build the CelesTrak archive URL for a space-weather product.
2494#[must_use]
2495pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2496    format!(
2497        "{}/{}",
2498        CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2499        space_weather_filename(product)
2500    )
2501}
2502
2503/// Build the cache relative path for a space-weather product.
2504#[must_use]
2505pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2506    format!("space-weather/{}", space_weather_filename(product))
2507}
2508
2509/// Build the Skadi SRTM tile id, for example `N36W107`.
2510pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2511    validate_terrain_tile_index(lat_index, lon_index)?;
2512    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2513    let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2514    Ok(format!(
2515        "{lat_hemi}{:02}{lon_hemi}{:03}",
2516        lat_index.abs(),
2517        lon_index.abs()
2518    ))
2519}
2520
2521/// Build the Skadi latitude band directory, for example `N36`.
2522pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2523    validate_terrain_lat_index(lat_index)?;
2524    let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2525    Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2526}
2527
2528/// Build the Skadi SRTM archive URL for a tile.
2529pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2530    let band = skadi_band(lat_index)?;
2531    let tile_id = skadi_tile_id(lat_index, lon_index)?;
2532    Ok(format!(
2533        "{}/skadi/{}/{}.hgt{}",
2534        SKADI_SOURCE.root_url,
2535        band,
2536        tile_id,
2537        SKADI_SOURCE.compression.suffix()
2538    ))
2539}
2540
2541/// Build the DTED tile filename read by the terrain module.
2542pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2543    validate_terrain_tile_index(lat_index, lon_index)?;
2544    Ok(format!(
2545        "{}_{}{}",
2546        terrain::format_lat(lat_index),
2547        terrain::format_lon(lon_index),
2548        terrain::DTED_SUFFIX
2549    ))
2550}
2551
2552/// Build the DTED ten-degree cache block directory read by the terrain module.
2553pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2554    validate_terrain_tile_index(lat_index, lon_index)?;
2555    Ok(terrain::terrain_block_dir(lat_index, lon_index))
2556}
2557
2558/// Build the DTED cache relative path read by the terrain module.
2559pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2560    Ok(format!(
2561        "{}/{}",
2562        dted_block_dir(lat_index, lon_index)?,
2563        dted_tile_filename(lat_index, lon_index)?
2564    ))
2565}
2566
2567/// Parse a Skadi SRTM tile id into `(lat_index, lon_index)`.
2568pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2569    let bytes = id.as_bytes();
2570    if bytes.len() != 7
2571        || !matches!(bytes[0], b'N' | b'S')
2572        || !matches!(bytes[3], b'E' | b'W')
2573        || !bytes[1..3].iter().all(u8::is_ascii_digit)
2574        || !bytes[4..7].iter().all(u8::is_ascii_digit)
2575    {
2576        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2577    }
2578
2579    let lat_abs = id[1..3]
2580        .parse::<i32>()
2581        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2582    let lon_abs = id[4..7]
2583        .parse::<i32>()
2584        .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2585    if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2586        return Err(DataCatalogError::InvalidTileId(id.to_string()));
2587    }
2588
2589    let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2590    let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2591    validate_terrain_tile_index(lat_index, lon_index)?;
2592    Ok((lat_index, lon_index))
2593}
2594
2595/// Derive the terrain tile index covering a latitude/longitude coordinate.
2596pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2597    if !lat_deg.is_finite()
2598        || !lon_deg.is_finite()
2599        || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2600        || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2601    {
2602        return Err(DataCatalogError::InvalidCoordinate {
2603            lat_deg_bits: lat_deg.to_bits(),
2604            lon_deg_bits: lon_deg.to_bits(),
2605        });
2606    }
2607
2608    let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2609    if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2610        lat_index = MAX_TERRAIN_LAT_INDEX;
2611    }
2612    if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2613        lon_index = MAX_TERRAIN_LON_INDEX;
2614    }
2615    validate_terrain_tile_index(lat_index, lon_index)?;
2616    Ok((lat_index, lon_index))
2617}
2618
2619/// Convert decompressed SRTM1 HGT bytes into deterministic DTED `.dt2` bytes.
2620///
2621/// The HGT payload must be 3601 by 3601 big-endian `i16` samples in row-major
2622/// order. HGT rows run north to south; DTED data records are longitude columns
2623/// with postings south to north, so output posting `(i, j)` reads source sample
2624/// `hgt[r = 3600 - i][c = j]`. SRTM void samples (`-32768`) are written as sea
2625/// level (`0`) so the existing terrain reader returns `0` for those postings.
2626pub fn hgt_to_dted(
2627    lat_index: i32,
2628    lon_index: i32,
2629    hgt: &[u8],
2630) -> Result<Vec<u8>, HgtConversionError> {
2631    validate_hgt_tile_index(lat_index, lon_index)?;
2632    if hgt.len() != SRTM1_HGT_LEN {
2633        return Err(HgtConversionError::BadLength {
2634            expected: SRTM1_HGT_LEN,
2635            got: hgt.len(),
2636        });
2637    }
2638
2639    let mut out = vec![b' '; DTED_SRTM1_LEN];
2640    out[0..4].copy_from_slice(b"UHL1");
2641    out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2642    out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2643    out[47..51].copy_from_slice(b"3601");
2644    out[51..55].copy_from_slice(b"3601");
2645
2646    for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2647        let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2648        let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2649        out[block_start] = terrain::DATA_SENTINEL;
2650
2651        let count = (lon_posting as u32).to_be_bytes();
2652        out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2653        out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2654        out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2655
2656        for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2657            let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2658            let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2659            let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2660            let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2661            let dted_sample_start = block_start + 8 + 2 * lat_posting;
2662            out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2663        }
2664
2665        let checksum = out[block_start..checksum_start]
2666            .iter()
2667            .fold(0i32, |acc, byte| acc + i32::from(*byte));
2668        out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2669    }
2670
2671    debug_assert_eq!(out.len(), 25_981_042);
2672    Ok(out)
2673}
2674
2675/// Product pairs intentionally withheld because no open mirror is known.
2676#[must_use]
2677pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2678    &NO_OPEN_MIRRORS
2679}
2680
2681/// Confirm that a center/product pair has an open catalog mirror.
2682pub fn open_mirror(
2683    center: AnalysisCenter,
2684    product_type: ProductType,
2685) -> Result<(), DataCatalogError> {
2686    open_mirror_code(center.code(), product_type.code())
2687}
2688
2689/// Confirm that a center/product code pair is not in the no-open-mirror list.
2690pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2691    if NO_OPEN_MIRRORS
2692        .iter()
2693        .any(|entry| entry.center == center && entry.product_type == product_type)
2694    {
2695        Err(DataCatalogError::NoOpenMirror {
2696            center: center.to_string(),
2697            product_type: product_type.to_string(),
2698        })
2699    } else {
2700        Ok(())
2701    }
2702}
2703
2704/// Look up a center's static catalog entry.
2705#[must_use]
2706pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2707    CATALOG.iter().find(|entry| entry.center == center)
2708}
2709
2710/// Look up the convention for one center and product type.
2711pub fn product_convention(
2712    center: AnalysisCenter,
2713    product_type: ProductType,
2714) -> Result<&'static CenterProductConvention, DataCatalogError> {
2715    open_mirror(center, product_type)?;
2716    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2717    entry
2718        .products
2719        .iter()
2720        .find(|product| product.product_type == product_type)
2721        .ok_or(DataCatalogError::UnsupportedProduct {
2722            center,
2723            product_type,
2724        })
2725}
2726
2727/// Return the solution class for a supported center/product family.
2728///
2729/// This product-aware API resolves the ambiguity in the legacy
2730/// [`AnalysisCenter::solution_class`] method. For example, IGS merged
2731/// broadcast navigation is [`SolutionClass::Broadcast`], while IGS combined
2732/// final SP3 is [`SolutionClass::Final`]. Unsupported combinations are
2733/// rejected before callers derive a filename or attempt acquisition.
2734pub fn product_solution_class(
2735    center: AnalysisCenter,
2736    product_type: ProductType,
2737) -> Result<SolutionClass, DataCatalogError> {
2738    product_convention(center, product_type)?;
2739    Ok(match (center, product_type) {
2740        (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2741        _ => center.solution_class(),
2742    })
2743}
2744
2745/// Current default sampling token for a center/product pair.
2746///
2747/// This preserves the original date-free query and reports the current catalog
2748/// convention. Use [`default_sample_for_date`] when deriving a historical
2749/// product whose published cadence may have changed.
2750pub fn default_sample(
2751    center: AnalysisCenter,
2752    product_type: ProductType,
2753) -> Result<&'static str, DataCatalogError> {
2754    Ok(product_convention(center, product_type)?.default_sample)
2755}
2756
2757/// Published default sampling token for a center/product pair on a date.
2758///
2759/// Most catalog families use one sampling token across their modeled history.
2760/// For issue-based products this date-only query represents the `0000` issue;
2761/// product construction uses its actual issue and can therefore select a
2762/// within-day transition. GFZ rapid and ultra-rapid SP3 and ESA ultra-rapid SP3
2763/// have cataloged cadence transitions.
2764pub fn default_sample_for_date(
2765    center: AnalysisCenter,
2766    product_type: ProductType,
2767    date: ProductDate,
2768) -> Result<&'static str, DataCatalogError> {
2769    default_sample_for_product_issue(center, product_type, date, None)
2770}
2771
2772/// GPS week number for a product date.
2773pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2774    date.gps_week()
2775}
2776
2777/// Day-of-year in `1..=366` for a product date.
2778#[must_use]
2779pub fn day_of_year(date: ProductDate) -> u16 {
2780    date.day_of_year()
2781}
2782
2783/// Build a product specification for any center/product/date combination.
2784pub fn product(
2785    center: AnalysisCenter,
2786    product_type: ProductType,
2787    date: ProductDate,
2788    sample: Option<&str>,
2789    issue: Option<&str>,
2790) -> Result<ProductSpec, DataCatalogError> {
2791    let sample = match sample {
2792        Some(sample) => sample,
2793        None => default_sample_for_product_issue(center, product_type, date, issue)?,
2794    };
2795    ProductSpec::new(center, product_type, date, sample, issue)
2796}
2797
2798/// Build the canonical IGS long-name filename for a product.
2799pub fn canonical_filename(
2800    center: AnalysisCenter,
2801    product_type: ProductType,
2802    date: ProductDate,
2803    sample: Option<&str>,
2804    issue: Option<&str>,
2805) -> Result<String, DataCatalogError> {
2806    product(center, product_type, date, sample, issue)?.canonical_filename()
2807}
2808
2809/// Build the full archive URL for a product.
2810pub fn archive_url(
2811    center: AnalysisCenter,
2812    product_type: ProductType,
2813    date: ProductDate,
2814    sample: Option<&str>,
2815    issue: Option<&str>,
2816) -> Result<String, DataCatalogError> {
2817    product(center, product_type, date, sample, issue)?.archive_url()
2818}
2819
2820/// Build the exact identity for a catalog product.
2821pub fn product_identity(
2822    center: AnalysisCenter,
2823    product_type: ProductType,
2824    date: ProductDate,
2825    sample: Option<&str>,
2826    issue: Option<&str>,
2827) -> Result<ProductIdentity, DataCatalogError> {
2828    product(center, product_type, date, sample, issue)?.identity()
2829}
2830
2831/// Resolve an explicit distributor for a catalog product.
2832pub fn distribution_location(
2833    center: AnalysisCenter,
2834    product_type: ProductType,
2835    date: ProductDate,
2836    sample: Option<&str>,
2837    issue: Option<&str>,
2838    source: DistributionSource,
2839) -> Result<DistributionLocation, DataCatalogError> {
2840    product(center, product_type, date, sample, issue)?.distribution_location(source)
2841}
2842
2843/// Resolve one explicit distributor from a complete exact product identity.
2844///
2845/// Unlike [`distribution_location`], this retains the already validated exact
2846/// center, date, issue, cadence, span, and filename carried by `identity`
2847/// instead of reconstructing a default product specification. The function
2848/// performs no network or file IO.
2849pub fn distribution_location_for_identity(
2850    identity: &ProductIdentity,
2851    source: DistributionSource,
2852) -> Result<DistributionLocation, DataCatalogError> {
2853    identity.validate()?;
2854    match source {
2855        DistributionSource::Direct => {
2856            let convention = product_convention(identity.analysis_center, identity.family)?;
2857            if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2858            {
2859                return Err(DataCatalogError::UnsupportedDistributionEra {
2860                    source,
2861                    center: identity.analysis_center,
2862                    product_type: identity.family,
2863                    date: identity.date,
2864                });
2865            }
2866            let entry = center_catalog(identity.analysis_center)
2867                .expect("validated analysis center has a catalog entry");
2868            let compression = product_archive_compression(
2869                identity.analysis_center,
2870                identity.family,
2871                identity.date,
2872                convention.compression,
2873            )?;
2874            let url = format!(
2875                "{}/{}/{}{}",
2876                entry.root_url,
2877                product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2878                identity.official_filename,
2879                compression.suffix()
2880            );
2881            Ok(DistributionLocation {
2882                source,
2883                original_url: Some(url),
2884                archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2885                compression,
2886            })
2887        }
2888        DistributionSource::NasaCddis => {
2889            validate_cddis_distribution_era(identity)?;
2890            let compression = product_archive_compression(
2891                identity.analysis_center,
2892                identity.family,
2893                identity.date,
2894                ArchiveCompression::Gzip,
2895            )?;
2896            Ok(DistributionLocation {
2897                source,
2898                original_url: Some(cddis_archive_url(identity)?),
2899                archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2900                compression,
2901            })
2902        }
2903        DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2904            source,
2905            original_url: None,
2906            archive_filename: identity.official_filename.clone(),
2907            compression: ArchiveCompression::None,
2908        }),
2909    }
2910}
2911
2912/// Build the official NASA CDDIS HTTPS URL for an exact SP3 or IONEX identity.
2913///
2914/// CDDIS stores supported current SP3 products by GPS week and current IONEX
2915/// products by year/day-of-year. The decompressed official filename is
2916/// unchanged. Before GPS week 2238, only the modeled IGS combined-final legacy
2917/// short-name SP3 series has a verified mapping; unmodeled long-name SP3 and
2918/// IONEX identities are rejected. ESA's `ESA0MGNFIN` final SP3 line is not
2919/// projected onto CDDIS because no exact CDDIS mapping is cataloged for it.
2920pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2921    identity.validate()?;
2922    validate_cddis_distribution_era(identity)?;
2923    match identity.family {
2924        ProductType::Sp3 => {
2925            let compression = product_archive_compression(
2926                identity.analysis_center,
2927                identity.family,
2928                identity.date,
2929                ArchiveCompression::Gzip,
2930            )?;
2931            Ok(format!(
2932                "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
2933                identity.date.gps_week()?,
2934                identity.official_filename,
2935                compression.suffix()
2936            ))
2937        }
2938        ProductType::Ionex => Ok(format!(
2939            "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2940            identity.date.year,
2941            identity.date.day_of_year(),
2942            identity.official_filename
2943        )),
2944        product_type => Err(DataCatalogError::UnsupportedDistribution {
2945            source: DistributionSource::NasaCddis,
2946            product_type,
2947        }),
2948    }
2949}
2950
2951/// Build a clock product for a center and date.
2952pub fn mgex_clk(
2953    center: AnalysisCenter,
2954    date: ProductDate,
2955    sample: Option<&str>,
2956) -> Result<ProductSpec, DataCatalogError> {
2957    product(center, ProductType::Clk, date, sample, None)
2958}
2959
2960/// Build a merged broadcast-navigation product for a center and date.
2961pub fn mgex_nav(
2962    center: AnalysisCenter,
2963    date: ProductDate,
2964    sample: Option<&str>,
2965) -> Result<ProductSpec, DataCatalogError> {
2966    product(center, ProductType::Nav, date, sample, None)
2967}
2968
2969/// Build an IONEX product for a center and date.
2970pub fn mgex_ionex(
2971    center: AnalysisCenter,
2972    date: ProductDate,
2973    sample: Option<&str>,
2974) -> Result<ProductSpec, DataCatalogError> {
2975    product(center, ProductType::Ionex, date, sample, None)
2976}
2977
2978/// Build the CODE rapid IONEX product for a date.
2979pub fn rapid_ionex(
2980    date: ProductDate,
2981    sample: Option<&str>,
2982) -> Result<ProductSpec, DataCatalogError> {
2983    product(
2984        AnalysisCenter::CodRap,
2985        ProductType::Ionex,
2986        date,
2987        sample,
2988        None,
2989    )
2990}
2991
2992/// Day offset for predicted IONEX aliases.
2993#[must_use]
2994pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2995    match center {
2996        AnalysisCenter::CodPrd2 => 1,
2997        _ => 0,
2998    }
2999}
3000
3001/// Build a CODE predicted IONEX product for a target date.
3002pub fn predicted_ionex(
3003    center: AnalysisCenter,
3004    date: ProductDate,
3005    sample: Option<&str>,
3006) -> Result<ProductSpec, DataCatalogError> {
3007    match center {
3008        AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
3009            let target = date.add_days(predicted_day_offset(center))?;
3010            product(center, ProductType::Ionex, target, sample, None)
3011        }
3012        other => Err(DataCatalogError::UnsupportedProduct {
3013            center: other,
3014            product_type: ProductType::Ionex,
3015        }),
3016    }
3017}
3018
3019/// Build an SP3 product for a center and date.
3020pub fn mgex_sp3(
3021    center: AnalysisCenter,
3022    date: ProductDate,
3023    sample: Option<&str>,
3024) -> Result<ProductSpec, DataCatalogError> {
3025    product(center, ProductType::Sp3, date, sample, None)
3026}
3027
3028/// Build an ultra-rapid OPS SP3 product for a date and issue time.
3029pub fn ops_ultra_sp3(
3030    center: AnalysisCenter,
3031    date: ProductDate,
3032    sample: Option<&str>,
3033    issue: Option<&str>,
3034) -> Result<ProductSpec, DataCatalogError> {
3035    let issue = issue.unwrap_or("0000");
3036    product(center, ProductType::Sp3, date, sample, Some(issue))
3037}
3038
3039/// Generate the officially cataloged ultra-rapid SP3 locations for one issue.
3040///
3041/// The dated locations use only spans and sampling intervals evidenced for the
3042/// exact center, date, and issue. A second dated location is returned only for
3043/// an archive-observed overlap such as GFZ's 2021-05-15 `0000` issue. Moving
3044/// latest-product snapshots are not exact dated identities and are therefore
3045/// outside this API. Callers should try the next location only when the prior
3046/// URL is absent; transport and retry policy remain outside the pure core
3047/// catalog.
3048pub fn ultra_sp3_locations(
3049    center: AnalysisCenter,
3050    date: ProductDate,
3051    issue: &str,
3052) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
3053    validate_issue_for_center(center, Some(issue))?;
3054    validate_product_date(center, ProductType::Sp3, date)?;
3055    match center {
3056        AnalysisCenter::IgsUlt
3057        | AnalysisCenter::CodUlt
3058        | AnalysisCenter::EsaUlt
3059        | AnalysisCenter::GfzUlt
3060        | AnalysisCenter::WumNrt => {}
3061        other => {
3062            return Err(DataCatalogError::UnsupportedProduct {
3063                center: other,
3064                product_type: ProductType::Sp3,
3065            })
3066        }
3067    };
3068    let default_sample =
3069        default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
3070    let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
3071    samples.sort_by_key(|sample| *sample != default_sample);
3072
3073    samples
3074        .into_iter()
3075        .map(|sample| {
3076            // Reuse the single-product catalog path so candidate enumeration
3077            // cannot drift from canonical filename, URL, identity, or
3078            // date-dependent compression derivation.
3079            let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3080            let identity = spec.identity()?;
3081            let filename = spec.canonical_filename()?;
3082            let url = spec.archive_url()?;
3083            let convention = product_convention(center, ProductType::Sp3)?;
3084            let compression = product_archive_compression(
3085                center,
3086                ProductType::Sp3,
3087                date,
3088                convention.compression,
3089            )?;
3090            Ok(UltraSp3Location {
3091                pattern: if sample == default_sample {
3092                    format!("primary_{}_{}", identity.span, sample)
3093                } else {
3094                    format!("alternate_{}_{}", identity.span, sample)
3095                },
3096                span: identity.span,
3097                sample: sample.to_string(),
3098                url,
3099                filename,
3100                compression,
3101            })
3102        })
3103        .collect()
3104}
3105
3106/// Build an ultra-rapid OPS clock product for a date and issue time.
3107pub fn ops_ultra_clk(
3108    center: AnalysisCenter,
3109    date: ProductDate,
3110    sample: Option<&str>,
3111    issue: Option<&str>,
3112) -> Result<ProductSpec, DataCatalogError> {
3113    let issue = issue.unwrap_or("0000");
3114    product(center, ProductType::Clk, date, sample, Some(issue))
3115}
3116
3117/// Select the latest ultra-rapid OPS SP3 issue at or before a target time.
3118pub fn latest_ops_ultra_sp3(
3119    center: AnalysisCenter,
3120    target: ProductDateTime,
3121    sample: Option<&str>,
3122    available_issues: Option<&[UltraIssue]>,
3123) -> Result<ProductSpec, DataCatalogError> {
3124    let selected = latest_ultra_issue(center, target, available_issues)?;
3125    ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3126}
3127
3128/// Candidate ultra-rapid issues at or before a target time, newest first.
3129pub fn ultra_issue_candidates(
3130    center: AnalysisCenter,
3131    target: ProductDateTime,
3132) -> Result<Vec<UltraIssue>, DataCatalogError> {
3133    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3134    let _ = product_convention(center, ProductType::Sp3)?;
3135    if entry.issues.is_empty() {
3136        return Err(DataCatalogError::UnsupportedProduct {
3137            center,
3138            product_type: ProductType::Sp3,
3139        });
3140    }
3141    validate_product_date(center, ProductType::Sp3, target.date)?;
3142
3143    let mut candidates = Vec::new();
3144    for date in [target.date, target.date.add_days(-1)?] {
3145        match validate_product_date(center, ProductType::Sp3, date) {
3146            Ok(()) => {}
3147            Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3148            Err(error) => return Err(error),
3149        }
3150        for issue in entry.issues.iter().rev() {
3151            if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3152                candidates.push(UltraIssue::new(date, issue)?);
3153            }
3154        }
3155    }
3156    Ok(candidates)
3157}
3158
3159/// Latest ultra-rapid issue at or before a target time.
3160pub fn latest_ultra_issue(
3161    center: AnalysisCenter,
3162    target: ProductDateTime,
3163    available_issues: Option<&[UltraIssue]>,
3164) -> Result<UltraIssue, DataCatalogError> {
3165    let candidates = ultra_issue_candidates(center, target)?;
3166    if candidates.is_empty() {
3167        return Err(DataCatalogError::NoUltraIssue);
3168    }
3169    if let Some(available) = available_issues {
3170        candidates
3171            .into_iter()
3172            .find(|candidate| {
3173                available
3174                    .iter()
3175                    .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3176            })
3177            .ok_or(DataCatalogError::NoAvailableUltraIssue)
3178    } else {
3179        Ok(candidates[0].clone())
3180    }
3181}
3182
3183/// Ordered cross-line candidates for one predicted IONEX map date.
3184///
3185/// CODE publishes two predicted global ionosphere lines for every map date:
3186/// the one-day prediction under `CODE/IONO/P1/` and the two-day prediction
3187/// under `CODE/IONO/P2/`. Both files carry the same official filename (the
3188/// filename date is the map date in both lines) but are distinct artifacts
3189/// with distinct exact identities and cache paths. Because the two-day line
3190/// for map date `M` is produced a day earlier than the one-day line, `P2/M`
3191/// is routinely published while `P1/M` is still absent whenever CODE runs
3192/// behind schedule.
3193///
3194/// This walk mirrors [`ultra_issue_candidates`]: it enumerates genuine
3195/// artifacts, ordered by preference (`P1` first, `P2` second), and the caller
3196/// acquires the first available one, cache-first. Hard rules:
3197///
3198/// - Every candidate is for the SAME map date. The walk never substitutes a
3199///   neighboring date's map; date fallback remains a separate, explicit
3200///   decision via [`gim_date_candidates`].
3201/// - Each candidate keeps its own exact identity ([`AnalysisCenter::CodPrd1`]
3202///   or [`AnalysisCenter::CodPrd2`] with its prediction horizon), so resolved
3203///   provenance names the line actually served and a cached `P2` artifact is
3204///   never re-labelled as `P1`.
3205/// - The walk is opt-in. A single-line request through [`predicted_ionex`]
3206///   keeps its fail-closed behavior.
3207///
3208/// Note the argument is the map date itself, unlike [`predicted_ionex`],
3209/// whose date argument is offset by [`predicted_day_offset`] for the two-day
3210/// line. A map date whose two-day production day falls outside the supported
3211/// calendar (its previous civil day is invalid) is rejected rather than
3212/// silently narrowed to one candidate.
3213pub fn predicted_ionex_line_candidates(
3214    map_date: ProductDate,
3215    sample: Option<&str>,
3216) -> Result<Vec<ProductSpec>, DataCatalogError> {
3217    let one_day = predicted_ionex(AnalysisCenter::CodPrd1, map_date, sample)?;
3218    let two_day_production_date = map_date.add_days(-1)?;
3219    let two_day = predicted_ionex(AnalysisCenter::CodPrd2, two_day_production_date, sample)?;
3220    // The same-map-date rule is the walk's contract, so it is enforced at
3221    // runtime in every build - not debug-asserted - even though only a
3222    // catalog bug could violate it.
3223    if one_day.date != map_date || two_day.date != map_date {
3224        return Err(DataCatalogError::InconsistentProductIdentity {
3225            field: "predicted_ionex_map_date",
3226        });
3227    }
3228    Ok(vec![one_day, two_day])
3229}
3230
3231/// Candidate IONEX dates at or before a target date, newest first.
3232pub fn gim_date_candidates(
3233    center: AnalysisCenter,
3234    target: ProductDate,
3235    lookback: u32,
3236) -> Result<Vec<ProductDate>, DataCatalogError> {
3237    let _ = product_convention(center, ProductType::Ionex)?;
3238    let base = target.add_days(predicted_day_offset(center))?;
3239    let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3240    for back in 0..=lookback {
3241        out.push(base.add_days(-i64::from(back))?);
3242    }
3243    Ok(out)
3244}
3245
3246// --- Publication status -----------------------------------------------------
3247//
3248// Doctrine for this section, deliberate and load-bearing:
3249//
3250// - The purity split is the design, not an accident. Everything here -
3251//   listing parsing, newest-issue selection, listing-URL derivation, age
3252//   arithmetic - is pure and network-free, exactly like the rest of the
3253//   catalog. The one networked call composing these pieces lives in the
3254//   scoreboard (`sidereon-scoreboard::publication_status`), behind its
3255//   existing fetcher trait so tests inject recorded bodies. Do not "fix"
3256//   this by adding transport here: the purity boundary is what lets every
3257//   interface reuse these semantics with its own acquisition stack, and
3258//   what keeps CI free of live-network dependence.
3259// - `observed_at` stays verbatim archive text forever. The archives disagree
3260//   on format and time zone (Apache indexes report server-local wall time
3261//   with no zone, AIUB's CSV reports ISO-8601 UTC, FTP LIST reports
3262//   `Mon DD HH:MM` with a year only for old files); parsing these into
3263//   instants would fabricate precision the archive never published. Lag
3264//   arithmetic therefore uses the filename's nominal issue epoch
3265//   ([`published_issue_age_minutes`]), which IS well-defined, and callers
3266//   who want the archive's own text get it untouched.
3267//
3268/// One object observed in an archive listing.
3269///
3270/// `path` is the object path exactly as the listing reported it: a bare
3271/// filename for an HTML autoindex or FTP directory listing, a slash-separated
3272/// archive path for a whole-tree listing such as AIUB's `full_listing.csv`.
3273/// `observed_at` is the archive-reported modification text, verbatim; archives
3274/// disagree on format and time zone, so Sidereon never reinterprets it (see
3275/// the section doctrine above).
3276#[derive(Debug, Clone, PartialEq, Eq)]
3277pub struct PublishedObject {
3278    /// Object path as listed.
3279    pub path: String,
3280    /// Archive-reported modification text, verbatim, when the listing has one.
3281    pub observed_at: Option<String>,
3282}
3283
3284/// Newest published issue of one center + product line, as evidenced by an
3285/// archive listing.
3286#[derive(Debug, Clone, PartialEq, Eq)]
3287pub struct PublishedProduct {
3288    /// Product date encoded by the newest published official filename.
3289    pub date: ProductDate,
3290    /// `HHMM` issue time encoded by that filename.
3291    pub issue: String,
3292    /// Official filename without transport compression suffix.
3293    pub filename: String,
3294    /// Archive-reported publication text for that object, verbatim.
3295    pub observed_at: Option<String>,
3296}
3297
3298/// Parse the object entries out of an archive listing body.
3299///
3300/// Dialect detection is closed: the body must classify as exactly one of the
3301/// listing surfaces the catalog's archives actually serve, each verified live
3302/// on 2026-08-04 and recorded as a fixture, and a body that fits none of them
3303/// is [`DataCatalogError::UnrecognizedArchiveListing`] - never a best-effort
3304/// empty result. An error page, a login interstitial, or a format change at
3305/// an archive must surface as "this is not a listing I understand", because a
3306/// silent empty parse is indistinguishable from "nothing published" and would
3307/// convert an archive change into a false publication-gap report.
3308///
3309/// - Apache `<pre>` autoindex and its older table flavor (GFZ
3310///   `isdc-data.gfz.de`, BKG `igs.bkg.bund.de`) and the ESA XHTML table
3311///   autoindex (`navigation-office.esa.int`): recognized by the autoindex
3312///   `Index of` marker; objects are relative anchors, with the row's
3313///   `YYYY-MM-DD HH:MM` text captured verbatim.
3314/// - AIUB whole-tree CSV (`www.aiub.unibe.ch/download/full_listing.csv`):
3315///   `path;bytes;ISO-8601;md5` rows; every non-empty row must fit that
3316///   grammar.
3317/// - Anonymous-FTP `LIST` output (WHU `igs.gnsswhu.cn`): Unix `ls -l` rows
3318///   (an optional leading `total` line allowed); every other non-empty row
3319///   must fit that grammar.
3320///
3321/// Within a recognized dialect, rows that by the dialect's own rules do not
3322/// name an object (parent links, sort links, directories, symlinks) are
3323/// skipped. The result preserves nothing but object paths and verbatim
3324/// modification text; interpretation belongs to
3325/// [`newest_published_product`].
3326pub fn parse_archive_listing(body: &str) -> Result<Vec<PublishedObject>, DataCatalogError> {
3327    let mut seen: Vec<PublishedObject> = Vec::new();
3328    let mut push = |path: String, observed_at: Option<String>| {
3329        if let Some(existing) = seen.iter_mut().find(|object| object.path == path) {
3330            if existing.observed_at.is_none() {
3331                existing.observed_at = observed_at;
3332            }
3333        } else {
3334            seen.push(PublishedObject { path, observed_at });
3335        }
3336    };
3337    let unrecognized = |reason: &str| DataCatalogError::UnrecognizedArchiveListing {
3338        reason: reason.to_string(),
3339    };
3340
3341    let non_empty: Vec<&str> = body
3342        .lines()
3343        .map(str::trim_end)
3344        .filter(|line| !line.trim().is_empty())
3345        .collect();
3346    if non_empty.is_empty() {
3347        return Err(unrecognized("empty body"));
3348    }
3349    let has_markup = body.contains('<');
3350
3351    // AIUB whole-tree CSV.
3352    if !has_markup && non_empty[0].matches(';').count() >= 3 {
3353        for line in &non_empty {
3354            if line.matches(';').count() < 3 {
3355                return Err(unrecognized("CSV row without its four fields"));
3356            }
3357            let mut fields = line.split(';');
3358            let (Some(path), Some(_bytes), Some(observed)) =
3359                (fields.next(), fields.next(), fields.next())
3360            else {
3361                return Err(unrecognized("CSV row without its four fields"));
3362            };
3363            if path.is_empty() || path.contains(' ') {
3364                return Err(unrecognized("CSV row without an archive path"));
3365            }
3366            // Directory rows carry `-1` sentinels and a trailing slash.
3367            if path.ends_with('/') {
3368                continue;
3369            }
3370            let observed_at =
3371                (!observed.is_empty() && observed != "-1").then(|| observed.to_string());
3372            push(path.to_string(), observed_at);
3373        }
3374        return Ok(seen);
3375    }
3376
3377    // Anonymous-FTP `LIST` (Unix `ls -l`) output.
3378    if !has_markup && non_empty[0].starts_with(['-', 'd', 'l']) {
3379        for (index, line) in non_empty.iter().enumerate() {
3380            if index == 0 && line.starts_with("total ") {
3381                continue;
3382            }
3383            let mode_shaped = line.len() > 10
3384                && line.starts_with(['-', 'd', 'l'])
3385                && line.as_bytes()[1..10]
3386                    .iter()
3387                    .all(|byte| matches!(byte, b'r' | b'w' | b'x' | b'-' | b's' | b't'));
3388            if !mode_shaped {
3389                return Err(unrecognized("FTP LIST row without a Unix mode field"));
3390            }
3391            // Directories and symlinks are not objects.
3392            if !line.starts_with('-') {
3393                continue;
3394            }
3395            let fields: Vec<&str> = line.split_whitespace().collect();
3396            if fields.len() < 9 {
3397                return Err(unrecognized("FTP LIST file row without nine fields"));
3398            }
3399            push(fields[8..].join(" "), Some(fields[5..8].join(" ")));
3400        }
3401        return Ok(seen);
3402    }
3403
3404    // HTML autoindex flavors, recognized by the shared `Index of` marker.
3405    if has_markup && body.contains("Index of") {
3406        for line in &non_empty {
3407            // Anchors, one or more per physical row. Sort links (`?C=`),
3408            // absolute parent links, and directories are not objects.
3409            let mut rest = *line;
3410            while let Some(start) = rest.find("<a href=\"") {
3411                rest = &rest[start + 9..];
3412                let Some(end) = rest.find('"') else { break };
3413                let target = &rest[..end];
3414                rest = &rest[end..];
3415                if target.is_empty()
3416                    || target.starts_with('?')
3417                    || target.starts_with('/')
3418                    || target.starts_with('#')
3419                    || target.contains("://")
3420                    || target.ends_with('/')
3421                {
3422                    continue;
3423                }
3424                let observed_at = find_listing_datetime(rest).map(str::to_string);
3425                push(target.to_string(), observed_at);
3426            }
3427        }
3428        return Ok(seen);
3429    }
3430
3431    Err(unrecognized(if has_markup {
3432        "markup without an autoindex marker"
3433    } else {
3434        "no known listing grammar"
3435    }))
3436}
3437
3438/// First `YYYY-MM-DD HH:MM` datetime text in the remainder of a listing row.
3439fn find_listing_datetime(rest: &str) -> Option<&str> {
3440    let bytes = rest.as_bytes();
3441    let is_digit = |index: usize| bytes.get(index).is_some_and(u8::is_ascii_digit);
3442    for start in 0..bytes.len().saturating_sub(15) {
3443        let shape_matches = is_digit(start)
3444            && is_digit(start + 1)
3445            && is_digit(start + 2)
3446            && is_digit(start + 3)
3447            && bytes[start + 4] == b'-'
3448            && is_digit(start + 5)
3449            && is_digit(start + 6)
3450            && bytes[start + 7] == b'-'
3451            && is_digit(start + 8)
3452            && is_digit(start + 9)
3453            && bytes[start + 10] == b' '
3454            && is_digit(start + 11)
3455            && is_digit(start + 12)
3456            && bytes[start + 13] == b':'
3457            && is_digit(start + 14)
3458            && is_digit(start + 15);
3459        if shape_matches {
3460            return Some(&rest[start..start + 16]);
3461        }
3462    }
3463    None
3464}
3465
3466/// Archive path marker that attributes a listed object to one catalog line
3467/// when several lines share an official filename convention.
3468const fn center_path_marker(center: AnalysisCenter) -> Option<&'static str> {
3469    match center {
3470        AnalysisCenter::CodPrd1 => Some("/IONO/P1/"),
3471        AnalysisCenter::CodPrd2 => Some("/IONO/P2/"),
3472        _ => None,
3473    }
3474}
3475
3476fn object_matches_center(center: AnalysisCenter, path: &str) -> bool {
3477    match center_path_marker(center) {
3478        // Whole-tree paths must carry the line's directory; a bare filename
3479        // cannot be attributed to either line and is never accepted.
3480        Some(marker) => {
3481            let slashed = format!("/{path}");
3482            slashed.contains(marker)
3483        }
3484        None => true,
3485    }
3486}
3487
3488/// Newest published issue for one center + product line among listed objects.
3489///
3490/// An object counts only when its name is exactly the line's official
3491/// filename convention (token, span, catalog-supported sample, content code,
3492/// extension, and the line's archive-compression suffix) and, for lines that
3493/// share a filename convention (the CODE predicted `P1`/`P2` ionosphere
3494/// lines), when its listed path carries the line's directory. The newest
3495/// object is selected by filename date and issue time; the archive-reported
3496/// modification text rides along verbatim.
3497///
3498/// `Ok(None)` means the listing was readable but contained no published
3499/// object of this line - the "nothing published here" answer, distinct from
3500/// an unreachable archive, which the transport layer reports instead.
3501pub fn newest_published_product(
3502    center: AnalysisCenter,
3503    product_type: ProductType,
3504    objects: &[PublishedObject],
3505) -> Result<Option<PublishedProduct>, DataCatalogError> {
3506    let convention = product_convention(center, product_type)?;
3507    let descriptor = product_type_convention(product_type);
3508    let suffix = format!(".{}", descriptor.extension);
3509    let tail = format!("_{}{}", descriptor.content_code, suffix);
3510
3511    let mut newest: Option<(i64, PublishedProduct)> = None;
3512    for object in objects {
3513        if !object_matches_center(center, &object.path) {
3514            continue;
3515        }
3516        let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3517        let stripped = listed_name
3518            .strip_suffix(".gz")
3519            .or_else(|| listed_name.strip_suffix(".Z"))
3520            .unwrap_or(listed_name);
3521        let Some(after_token) = stripped
3522            .strip_prefix(convention.token)
3523            .and_then(|rest| rest.strip_prefix('_'))
3524        else {
3525            continue;
3526        };
3527        let Some(middle) = after_token.strip_suffix(&tail) else {
3528            continue;
3529        };
3530        let mut parts = middle.split('_');
3531        let (Some(block), Some(span), Some(sample), None) =
3532            (parts.next(), parts.next(), parts.next(), parts.next())
3533        else {
3534            continue;
3535        };
3536        if span != convention.span || block.len() != 11 {
3537            continue;
3538        }
3539        let (Ok(year), Ok(day_of_year)) = (block[0..4].parse::<i32>(), block[4..7].parse::<u16>())
3540        else {
3541            continue;
3542        };
3543        let issue = &block[7..11];
3544        let Ok(date) = product_date_from_year_day(year, day_of_year) else {
3545            continue;
3546        };
3547        if validate_issue(issue).is_err() {
3548            continue;
3549        }
3550        // The object must be one the catalog can re-derive for that date and
3551        // issue; anything else is not this line's product.
3552        let issue_argument = (!center_catalog(center)
3553            .expect("catalog entry exists for enum variant")
3554            .issues
3555            .is_empty())
3556        .then_some(issue);
3557        match product(center, product_type, date, Some(sample), issue_argument) {
3558            Ok(spec) => {
3559                if spec.canonical_filename()? != stripped {
3560                    continue;
3561                }
3562            }
3563            Err(_) => continue,
3564        }
3565        let ordering = issue_ordering_minutes(date, issue)?;
3566        let replace = newest
3567            .as_ref()
3568            .is_none_or(|(newest_ordering, _)| ordering > *newest_ordering);
3569        if replace {
3570            newest = Some((
3571                ordering,
3572                PublishedProduct {
3573                    date,
3574                    issue: issue.to_string(),
3575                    filename: stripped.to_string(),
3576                    observed_at: object.observed_at.clone(),
3577                },
3578            ));
3579        }
3580    }
3581    Ok(newest.map(|(_, product)| product))
3582}
3583
3584/// Whole minutes from a published issue's nominal epoch to `now`.
3585///
3586/// This is the "N hours behind nominal" number for lag alerting: the newest
3587/// published issue's filename epoch compared with the caller's clock. It says
3588/// nothing about when the archive actually wrote the object; the verbatim
3589/// [`PublishedProduct::observed_at`] text carries that where the archive
3590/// exposes one.
3591pub fn published_issue_age_minutes(
3592    published: &PublishedProduct,
3593    now: ProductDateTime,
3594) -> Result<i64, DataCatalogError> {
3595    Ok(now.ordering_minutes() - issue_ordering_minutes(published.date, &published.issue)?)
3596}
3597
3598/// Archive listing URLs that can answer "what is the newest published issue"
3599/// for one center + product line, ordered newest-directory-first.
3600///
3601/// This is a bounded enumeration, not a poll: at most two URLs. Week-layout
3602/// archives get the week directory containing `around` plus the previous week
3603/// (a late archive may not have created the current week's directory yet -
3604/// the recorded 2026-08-04 BKG state). Year-layout archives are served
3605/// through AIUB's whole-tree CSV listing, one URL. The caller fetches in
3606/// order and interprets each body with [`parse_archive_listing`] and
3607/// [`newest_published_product`].
3608pub fn publication_listing_urls(
3609    center: AnalysisCenter,
3610    product_type: ProductType,
3611    around: ProductDate,
3612) -> Result<Vec<String>, DataCatalogError> {
3613    let convention = product_convention(center, product_type)?;
3614    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3615    match convention.layout {
3616        ArchiveLayout::AiubCodeRoot
3617        | ArchiveLayout::AiubCodeYear
3618        | ArchiveLayout::AiubCodeMgexYear => {
3619            Ok(vec![format!("{}/full_listing.csv", entry.root_url)])
3620        }
3621        _ => {
3622            let current = format!(
3623                "{}/{}/",
3624                entry.root_url,
3625                product_dir_path(center, convention.layout, around)?
3626            );
3627            let previous_week_date = around.add_days(-7)?;
3628            let previous = format!(
3629                "{}/{}/",
3630                entry.root_url,
3631                product_dir_path(center, convention.layout, previous_week_date)?
3632            );
3633            let mut urls = vec![current];
3634            if !urls.contains(&previous) {
3635                urls.push(previous);
3636            }
3637            Ok(urls)
3638        }
3639    }
3640}
3641
3642/// Index of the first candidate whose exact archive object is present among
3643/// listed objects.
3644///
3645/// This is the pure availability step of a candidate walk such as
3646/// [`predicted_ionex_line_candidates`]: candidates stay in preference order,
3647/// an object counts only when it is exactly the candidate's official archive
3648/// filename (with the line's compression suffix) on the candidate's line, and
3649/// the returned index preserves the candidate's own identity - resolved
3650/// provenance therefore names the line actually served.
3651pub fn resolve_first_published(
3652    candidates: &[ProductSpec],
3653    objects: &[PublishedObject],
3654) -> Result<Option<usize>, DataCatalogError> {
3655    for (index, candidate) in candidates.iter().enumerate() {
3656        let filename = candidate.canonical_filename()?;
3657        let convention = product_convention(candidate.center, candidate.product_type)?;
3658        let compression = product_archive_compression(
3659            candidate.center,
3660            candidate.product_type,
3661            candidate.date,
3662            convention.compression,
3663        )?;
3664        let archive_name = format!("{filename}{}", compression.suffix());
3665        let found = objects.iter().any(|object| {
3666            if !object_matches_center(candidate.center, &object.path) {
3667                return false;
3668            }
3669            let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3670            listed_name == archive_name || listed_name == filename
3671        });
3672        if found {
3673            return Ok(Some(index));
3674        }
3675    }
3676    Ok(None)
3677}
3678
3679fn product_date_from_year_day(
3680    year: i32,
3681    day_of_year: u16,
3682) -> Result<ProductDate, DataCatalogError> {
3683    if day_of_year == 0 {
3684        return Err(DataCatalogError::DateOutOfRange);
3685    }
3686    ProductDate::new(year, 1, 1)?
3687        .add_days(i64::from(day_of_year) - 1)
3688        .and_then(|date| {
3689            if date.year == year {
3690                Ok(date)
3691            } else {
3692                Err(DataCatalogError::DateOutOfRange)
3693            }
3694        })
3695}
3696
3697/// Build a daily station observation product.
3698pub fn station_obs(
3699    station: &str,
3700    date: ProductDate,
3701    sample: Option<&str>,
3702) -> Result<StationObservationSpec, DataCatalogError> {
3703    StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
3704}
3705
3706/// Build the canonical RINEX 3 CRINEX filename for a daily station observation.
3707pub fn station_obs_filename(
3708    station: &str,
3709    date: ProductDate,
3710    sample: &str,
3711) -> Result<String, DataCatalogError> {
3712    validate_station(station)?;
3713    validate_sample(sample)?;
3714    Ok(format!(
3715        "{}_R_{}_01D_{}_MO.crx",
3716        station,
3717        date_block(date, None),
3718        sample
3719    ))
3720}
3721
3722/// Build the full BKG IGS archive URL for a daily station observation.
3723pub fn station_obs_url(
3724    station: &str,
3725    date: ProductDate,
3726    sample: &str,
3727) -> Result<String, DataCatalogError> {
3728    let filename = station_obs_filename(station, date, sample)?;
3729    Ok(format!(
3730        "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
3731        dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
3732        filename
3733    ))
3734}
3735
3736/// The transfer protocol for the daily station observation archive.
3737#[must_use]
3738pub const fn station_obs_protocol() -> ArchiveProtocol {
3739    ArchiveProtocol::Https
3740}
3741
3742fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
3743    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
3744        Ok(())
3745    } else {
3746        Err(DataCatalogError::InvalidTileIndex {
3747            lat_index,
3748            lon_index: 0,
3749        })
3750    }
3751}
3752
3753fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
3754    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3755        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3756    {
3757        Ok(())
3758    } else {
3759        Err(DataCatalogError::InvalidTileIndex {
3760            lat_index,
3761            lon_index,
3762        })
3763    }
3764}
3765
3766fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
3767    if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3768        && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3769    {
3770        Ok(())
3771    } else {
3772        Err(HgtConversionError::InvalidTileIndex {
3773            lat_index,
3774            lon_index,
3775        })
3776    }
3777}
3778
3779fn dted_coord_field(index: i32, is_longitude: bool) -> String {
3780    let hemi = match (is_longitude, index >= 0) {
3781        (true, true) => 'E',
3782        (true, false) => 'W',
3783        (false, true) => 'N',
3784        (false, false) => 'S',
3785    };
3786    format!("{:03}0000{hemi}", index.abs())
3787}
3788
3789fn encode_dted_signed_magnitude(sample: i16) -> u16 {
3790    if sample == i16::MIN {
3791        0
3792    } else if sample >= 0 {
3793        sample as u16
3794    } else {
3795        0x8000 | (-i32::from(sample) as u16)
3796    }
3797}
3798
3799fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
3800    PRODUCT_TYPE_CONVENTIONS
3801        .iter()
3802        .find(|descriptor| descriptor.product_type == product_type)
3803        .expect("product descriptor exists for enum variant")
3804}
3805
3806const fn product_format(product_type: ProductType) -> ProductFormat {
3807    match product_type {
3808        ProductType::Sp3 => ProductFormat::Sp3,
3809        ProductType::Ionex => ProductFormat::Ionex,
3810        ProductType::Clk => ProductFormat::RinexClock,
3811        ProductType::Nav => ProductFormat::RinexNavigation,
3812    }
3813}
3814
3815fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
3816    if filename.is_empty()
3817        || filename == "."
3818        || filename == ".."
3819        || filename.contains('/')
3820        || filename.contains('\\')
3821        || filename.contains('\0')
3822        || filename.contains("..")
3823    {
3824        Err(DataCatalogError::InvalidOfficialFilename(
3825            filename.to_string(),
3826        ))
3827    } else {
3828        Ok(())
3829    }
3830}
3831
3832fn validate_product(
3833    center: AnalysisCenter,
3834    product_type: ProductType,
3835    date: ProductDate,
3836    sample: &str,
3837    issue: Option<&str>,
3838) -> Result<&'static CenterProductConvention, DataCatalogError> {
3839    let convention = product_convention(center, product_type)?;
3840    validate_sample(sample)?;
3841    validate_issue_for_center(center, issue)?;
3842    validate_product_date(center, product_type, date)?;
3843    validate_catalog_sample(center, product_type, date, sample, issue)?;
3844    Ok(convention)
3845}
3846
3847fn validate_catalog_sample(
3848    center: AnalysisCenter,
3849    product_type: ProductType,
3850    date: ProductDate,
3851    sample: &str,
3852    issue: Option<&str>,
3853) -> Result<(), DataCatalogError> {
3854    let supported = supported_samples_inner(center, product_type, date, issue)?;
3855    if supported.contains(&sample) {
3856        return Ok(());
3857    }
3858    Err(DataCatalogError::UnsupportedSample {
3859        center,
3860        product_type,
3861        sample: sample.to_string(),
3862    })
3863}
3864
3865/// Officially evidenced sampling tokens for one exact catalog product.
3866///
3867/// Syntax alone is not publication evidence: this query reports only cadences
3868/// backed by the official product line for the selected center, family, date,
3869/// and issue. Constructors enforce the same result before deriving a filename,
3870/// URL, identity, or cache key.
3871///
3872/// For issue-based product lines, omitting `issue` selects the `0000` issue,
3873/// matching [`default_sample_for_date`]. Product construction itself still
3874/// requires an explicit issue.
3875pub fn supported_samples(
3876    center: AnalysisCenter,
3877    product_type: ProductType,
3878    date: ProductDate,
3879    issue: Option<&str>,
3880) -> Result<&'static [&'static str], DataCatalogError> {
3881    ProductDate::new(date.year, date.month, date.day)?;
3882    product_convention(center, product_type)?;
3883    validate_product_date(center, product_type, date)?;
3884
3885    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3886    if entry.issues.is_empty() {
3887        validate_issue_for_center(center, issue)?;
3888    } else {
3889        validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
3890    }
3891    supported_samples_inner(center, product_type, date, issue)
3892}
3893
3894fn supported_samples_inner(
3895    center: AnalysisCenter,
3896    product_type: ProductType,
3897    date: ProductDate,
3898    issue: Option<&str>,
3899) -> Result<&'static [&'static str], DataCatalogError> {
3900    if product_type != ProductType::Sp3 {
3901        let convention = product_convention(center, product_type)?;
3902        return Ok(match convention.default_sample {
3903            "30S" => &["30S"],
3904            "01H" => &["01H"],
3905            "02H" => &["02H"],
3906            "01D" => &["01D"],
3907            _ => &[],
3908        });
3909    }
3910
3911    Ok(match center {
3912        AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
3913        AnalysisCenter::Esa
3914        | AnalysisCenter::Cod
3915        | AnalysisCenter::CodUlt
3916        | AnalysisCenter::WumNrt => &["05M"],
3917        AnalysisCenter::Gfz => {
3918            if date < GFZ_RAPID_5M_START_DATE {
3919                &["15M"]
3920            } else {
3921                &["05M"]
3922            }
3923        }
3924        AnalysisCenter::EsaUlt => {
3925            let issue = issue.unwrap_or("0000");
3926            let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3927                || (date == ESA_ULTRA_15M_LAST_DATE
3928                    && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3929            if at_or_before_last_15m {
3930                &["15M"]
3931            } else {
3932                &["05M"]
3933            }
3934        }
3935        AnalysisCenter::GfzUlt => {
3936            if date < GFZ_ULTRA_15M_LAST_DATE {
3937                &["15M"]
3938            } else if date == GFZ_ULTRA_15M_LAST_DATE {
3939                if issue.unwrap_or("0000") == "0000" {
3940                    &["15M", "05M"]
3941                } else {
3942                    &["15M"]
3943                }
3944            } else {
3945                &["05M"]
3946            }
3947        }
3948        AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
3949    })
3950}
3951
3952fn validate_product_date(
3953    center: AnalysisCenter,
3954    product_type: ProductType,
3955    date: ProductDate,
3956) -> Result<(), DataCatalogError> {
3957    // The official IGS rapid/final orbit combination began at GPS week 0730.
3958    // Earlier dates must not be assigned a syntactically plausible legacy
3959    // filename for a combined final product that did not yet exist.
3960    if center == AnalysisCenter::Igs
3961        && product_type == ProductType::Sp3
3962        && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
3963    {
3964        return Err(DataCatalogError::UnsupportedProductEra {
3965            center,
3966            product_type,
3967            date,
3968        });
3969    }
3970
3971    // AIUB documents different short-name CODE products through week 2237.
3972    // This catalog intentionally refuses those dates until their distinct
3973    // identities and distributor rules are modeled; it must not emit a
3974    // post-transition long filename that never existed.
3975    if center == AnalysisCenter::Cod
3976        && matches!(
3977            product_type,
3978            ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
3979        )
3980        && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
3981    {
3982        return Err(DataCatalogError::UnsupportedProductEra {
3983            center,
3984            product_type,
3985            date,
3986        });
3987    }
3988
3989    let start_date = match (center, product_type) {
3990        (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
3991            Some(ESA_FINAL_SERIES_START_DATE)
3992        }
3993        (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
3994            Some(GFZ_RAPID_SERIES_START_DATE)
3995        }
3996        (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
3997        (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
3998        (AnalysisCenter::WumNrt, ProductType::Sp3) => Some(WUM_NRT_SP3_START_DATE),
3999        _ => None,
4000    };
4001    let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
4002        && product_type == ProductType::Sp3
4003        && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
4004    if before_long_name_start || start_date.is_some_and(|start| date < start) {
4005        return Err(DataCatalogError::UnsupportedProductEra {
4006            center,
4007            product_type,
4008            date,
4009        });
4010    }
4011    Ok(())
4012}
4013
4014fn default_sample_for_product_issue(
4015    center: AnalysisCenter,
4016    product_type: ProductType,
4017    date: ProductDate,
4018    issue: Option<&str>,
4019) -> Result<&'static str, DataCatalogError> {
4020    ProductDate::new(date.year, date.month, date.day)?;
4021    let current = default_sample(center, product_type)?;
4022    validate_product_date(center, product_type, date)?;
4023
4024    if product_type != ProductType::Sp3 {
4025        return Ok(current);
4026    }
4027    match center {
4028        AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
4029        AnalysisCenter::EsaUlt => {
4030            // A date-only query represents the 0000/start-of-day issue. Product
4031            // construction supplies the actual issue and therefore observes
4032            // the within-day transition on 2025-02-02.
4033            let issue = issue.unwrap_or("0000");
4034            validate_issue_for_center(center, Some(issue))?;
4035            let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4036                || (date == ESA_ULTRA_15M_LAST_DATE
4037                    && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4038            if at_or_before_last_15m {
4039                Ok("15M")
4040            } else {
4041                Ok(current)
4042            }
4043        }
4044        AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
4045        _ => Ok(current),
4046    }
4047}
4048
4049fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
4050    let gps_week = identity.date.gps_week()?;
4051    let esa_mgex_final_sp3 =
4052        identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
4053    // The Wuhan near-real-time hourly line is served from the WHU archive
4054    // only; no exact CDDIS mapping is cataloged for it, so it is not
4055    // projected onto CDDIS (same rule as the ESA `ESA0MGNFIN` line).
4056    if identity.analysis_center == AnalysisCenter::WumNrt {
4057        return Err(DataCatalogError::UnsupportedDistributionEra {
4058            source: DistributionSource::NasaCddis,
4059            center: identity.analysis_center,
4060            product_type: identity.family,
4061            date: identity.date,
4062        });
4063    }
4064    let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
4065        && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
4066        && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
4067    let unmodeled_pretransition_ionex =
4068        identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
4069    if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
4070        Err(DataCatalogError::UnsupportedDistributionEra {
4071            source: DistributionSource::NasaCddis,
4072            center: identity.analysis_center,
4073            product_type: identity.family,
4074            date: identity.date,
4075        })
4076    } else {
4077        Ok(())
4078    }
4079}
4080
4081fn validate_issue_for_center(
4082    center: AnalysisCenter,
4083    issue: Option<&str>,
4084) -> Result<(), DataCatalogError> {
4085    let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4086    match (entry.issues.is_empty(), issue) {
4087        (true, None) => Ok(()),
4088        (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
4089        (false, None) => Err(DataCatalogError::MissingIssue { center }),
4090        (false, Some(issue)) => {
4091            validate_issue(issue)?;
4092            if entry.issues.contains(&issue) {
4093                Ok(())
4094            } else {
4095                Err(DataCatalogError::UnsupportedIssue {
4096                    center,
4097                    issue: issue.to_string(),
4098                })
4099            }
4100        }
4101    }
4102}
4103
4104fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
4105    if validate_period_token(sample) {
4106        Ok(())
4107    } else {
4108        Err(DataCatalogError::InvalidSample(sample.to_string()))
4109    }
4110}
4111
4112fn validate_span(span: &str) -> Result<(), DataCatalogError> {
4113    if validate_period_token(span) {
4114        Ok(())
4115    } else {
4116        Err(DataCatalogError::InvalidSpan(span.to_string()))
4117    }
4118}
4119
4120fn validate_period_token(token: &str) -> bool {
4121    let bytes = token.as_bytes();
4122    if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
4123        return false;
4124    }
4125    let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
4126    match bytes[2] {
4127        // Reject exact smaller-unit spellings where the public guideline
4128        // unambiguously provides the next sub-day unit. Do not normalize D to
4129        // W or L to Y: official IGS filenames use values such as 07D, and the
4130        // public convention treats those calendar-oriented units as valid.
4131        b'S' | b'M' => amount > 0 && amount % 60 != 0,
4132        b'H' => amount > 0 && amount % 24 != 0,
4133        b'D' | b'W' | b'L' | b'Y' => amount > 0,
4134        // IGS reserves 00U for an unspecified interval. Exact-SP3 validation
4135        // rejects it because it cannot represent a positive cadence.
4136        b'U' => amount == 0,
4137        _ => false,
4138    }
4139}
4140
4141fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
4142    let bytes = issue.as_bytes();
4143    let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
4144    if !valid_digits {
4145        return Err(DataCatalogError::InvalidIssue(issue.to_string()));
4146    }
4147    let hour = issue[0..2]
4148        .parse::<u8>()
4149        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4150    let minute = issue[2..4]
4151        .parse::<u8>()
4152        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4153    if hour <= 23 && minute <= 59 {
4154        Ok(())
4155    } else {
4156        Err(DataCatalogError::InvalidIssue(issue.to_string()))
4157    }
4158}
4159
4160fn validate_station(station: &str) -> Result<(), DataCatalogError> {
4161    let bytes = station.as_bytes();
4162    let valid = bytes.len() == 9
4163        && bytes
4164            .iter()
4165            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
4166    if valid {
4167        Ok(())
4168    } else {
4169        Err(DataCatalogError::InvalidStation(station.to_string()))
4170    }
4171}
4172
4173fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
4174    validate_issue(issue)?;
4175    let hour = issue[0..2]
4176        .parse::<u16>()
4177        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4178    let minute = issue[2..4]
4179        .parse::<u16>()
4180        .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4181    Ok(hour * 60 + minute)
4182}
4183
4184fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
4185    Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
4186}
4187
4188fn date_block(date: ProductDate, issue: Option<&str>) -> String {
4189    format!(
4190        "{}{:03}{}",
4191        date.year,
4192        date.day_of_year(),
4193        issue.unwrap_or("0000")
4194    )
4195}
4196
4197fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
4198    Ok(match layout {
4199        ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
4200        ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
4201        ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
4202        ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
4203        ArchiveLayout::BkgBrdcYearDoy => {
4204            format!("BRDC/{}/{:03}", date.year, date.day_of_year())
4205        }
4206        ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
4207        ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
4208        ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
4209        ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
4210    })
4211}
4212
4213fn product_dir_path(
4214    center: AnalysisCenter,
4215    layout: ArchiveLayout,
4216    date: ProductDate,
4217) -> Result<String, DataCatalogError> {
4218    match center {
4219        AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
4220        AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
4221        _ => dir_path(layout, date),
4222    }
4223}
4224
4225fn uses_legacy_igs_final_name(
4226    center: AnalysisCenter,
4227    product_type: ProductType,
4228    date: ProductDate,
4229) -> Result<bool, DataCatalogError> {
4230    Ok(center == AnalysisCenter::Igs
4231        && product_type == ProductType::Sp3
4232        && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
4233}
4234
4235fn product_archive_compression(
4236    center: AnalysisCenter,
4237    product_type: ProductType,
4238    date: ProductDate,
4239    default: ArchiveCompression,
4240) -> Result<ArchiveCompression, DataCatalogError> {
4241    if uses_legacy_igs_final_name(center, product_type, date)? {
4242        Ok(ArchiveCompression::UnixCompress)
4243    } else {
4244        Ok(default)
4245    }
4246}
4247
4248fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
4249    let (year, month, day) = civil_from_julian_day_number(jdn);
4250    let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
4251    let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
4252    let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
4253    ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
4254}
4255
4256#[cfg(test)]
4257mod content_start_tests {
4258    use super::*;
4259
4260    const GFZ_ISSUES: [&str; 8] = [
4261        "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
4262    ];
4263
4264    fn date(year: i32, month: u8, day: u8) -> ProductDate {
4265        ProductDate::new(year, month, day).expect("test date")
4266    }
4267
4268    fn offset(
4269        center: AnalysisCenter,
4270        product_date: ProductDate,
4271        sample: &str,
4272        issue: Option<&str>,
4273    ) -> i64 {
4274        let identity =
4275            product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
4276                .expect("cataloged SP3 identity");
4277        exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
4278    }
4279
4280    #[test]
4281    fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
4282        for issue in GFZ_ISSUES {
4283            assert_eq!(
4284                offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
4285                -86_400,
4286                "2022-09-06 issue {issue}"
4287            );
4288        }
4289    }
4290
4291    #[test]
4292    fn gfz_ultra_transition_is_cataloged_per_issue() {
4293        let day_seven = [
4294            0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
4295        ];
4296        let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
4297
4298        for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
4299            for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
4300                assert_eq!(
4301                    offset(
4302                        AnalysisCenter::GfzUlt,
4303                        date(2022, 9, product_day),
4304                        "05M",
4305                        Some(issue)
4306                    ),
4307                    expected_offset,
4308                    "2022-09-{product_day:02} issue {issue}"
4309                );
4310            }
4311        }
4312    }
4313
4314    #[test]
4315    fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
4316        for issue in GFZ_ISSUES {
4317            assert_eq!(
4318                offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
4319                0,
4320                "2022-09-09 issue {issue}"
4321            );
4322        }
4323
4324        let current = date(2026, 7, 20);
4325        let cases = [
4326            (AnalysisCenter::Igs, "15M", None),
4327            (AnalysisCenter::Esa, "05M", None),
4328            (AnalysisCenter::Cod, "05M", None),
4329            (AnalysisCenter::Gfz, "05M", None),
4330            (AnalysisCenter::IgsUlt, "15M", Some("1200")),
4331            (AnalysisCenter::CodUlt, "05M", Some("0000")),
4332            (AnalysisCenter::EsaUlt, "05M", Some("1800")),
4333            (AnalysisCenter::GfzUlt, "05M", Some("2100")),
4334        ];
4335        for (center, sample, issue) in cases {
4336            assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
4337        }
4338    }
4339
4340    #[test]
4341    fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
4342        assert_eq!(
4343            offset(
4344                AnalysisCenter::GfzUlt,
4345                date(2021, 5, 15),
4346                "15M",
4347                Some("0000")
4348            ),
4349            -86_400
4350        );
4351        assert_eq!(
4352            offset(
4353                AnalysisCenter::GfzUlt,
4354                date(2021, 5, 16),
4355                "05M",
4356                Some("0000")
4357            ),
4358            -86_400
4359        );
4360    }
4361
4362    #[test]
4363    fn public_content_start_query_enforces_center_issue_rules() {
4364        assert_eq!(
4365            sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
4366            Err(DataCatalogError::UnsupportedIssue {
4367                center: AnalysisCenter::GfzUlt,
4368                issue: "0130".to_owned(),
4369            })
4370        );
4371        assert_eq!(
4372            sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
4373            Err(DataCatalogError::UnexpectedIssue {
4374                center: AnalysisCenter::Gfz,
4375            })
4376        );
4377        assert_eq!(
4378            sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
4379            Err(DataCatalogError::MissingIssue {
4380                center: AnalysisCenter::GfzUlt,
4381            })
4382        );
4383    }
4384}