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