Skip to main content

sidereon_core/sp3/
exact.rs

1//! Semantic validation for an exact SP3 product request.
2//!
3//! [`Sp3::parse`] remains a general, permissive SP3 reader. Acquisition code
4//! that promised a particular date, span, and sample interval should additionally
5//! use [`validate_exact_sp3`] (or [`parse_exact_sp3`]) before accepting bytes as
6//! that product.
7
8use core::fmt;
9
10use crate::astro::time::civil::j2000_seconds;
11use crate::data::{AnalysisCenter, DataCatalogError, ProductDate, ProductIdentity, ProductType};
12use crate::tolerances::WHOLE_SECOND_EPS_S;
13
14use super::{Sp3, Sp3DataType, Sp3Version};
15
16/// Maximum legal epoch interval from the SP3-d specification, in seconds.
17/// The interval must be strictly less than this value.
18const SP3_MAX_EPOCH_INTERVAL_S: f64 = 100_000.0;
19/// Modified Julian day containing the J2000 epoch (at 12:00).
20const J2000_MJD_DAY: i64 = 51_544;
21/// Modified Julian day at the GPS week-numbering origin, 1980-01-06 00:00.
22const GPS_ZERO_MJD_DAY: i64 = 44_244;
23const SECONDS_PER_DAY_I64: i64 = 86_400;
24const SECONDS_PER_WEEK_I64: i64 = 604_800;
25
26/// The two regular-grid representations accepted for an exact declared span.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ExactSp3Coverage {
29    /// The boundary is excluded. A 24-hour, five-minute product has 288 epochs,
30    /// ending at 23:55.
31    HalfOpen,
32    /// The boundary is included. A 24-hour, five-minute product has 289 epochs,
33    /// ending at the following midnight.
34    Inclusive,
35}
36
37/// Requested identity fields needed to validate decompressed SP3 content.
38///
39/// This source-independent request is useful to acquisition resolvers whose
40/// candidates are defined by an official archive convention but are not catalog
41/// entries. [`ExactSp3Request::from_identity`] supplies the same values from a
42/// full [`ProductIdentity`].
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ExactSp3Request {
45    date: ProductDate,
46    issue: Option<String>,
47    span: String,
48    sample: String,
49    format_version: Option<String>,
50    expected_agency: Option<String>,
51    /// Whole seconds from the filename epoch to the cataloged first content
52    /// epoch. Only [`Self::from_identity`] can set a nonzero value.
53    content_start_offset_s: i64,
54}
55
56impl ExactSp3Request {
57    /// Build and validate a source-independent exact SP3 request.
58    pub fn new(
59        date: ProductDate,
60        issue: Option<&str>,
61        span: &str,
62        sample: &str,
63    ) -> Result<Self, ExactSp3ValidationError> {
64        // ProductDate's fields are public, so revalidate caller-built values.
65        ProductDate::new(date.year, date.month, date.day)
66            .map_err(ExactSp3ValidationError::Catalog)?;
67        parse_issue(issue)?;
68        parse_duration_token(span, DurationField::Span)?;
69        let cadence_s = parse_duration_token(sample, DurationField::Sample)?;
70        if cadence_s as f64 >= SP3_MAX_EPOCH_INTERVAL_S {
71            return Err(ExactSp3ValidationError::UnsupportedSampleToken {
72                token: sample.to_owned(),
73            });
74        }
75
76        Ok(Self {
77            date,
78            issue: issue.map(str::to_owned),
79            span: span.to_owned(),
80            sample: sample.to_owned(),
81            format_version: None,
82            expected_agency: None,
83            content_start_offset_s: 0,
84        })
85    }
86
87    /// Build a request from a complete catalog identity.
88    pub fn from_identity(identity: &ProductIdentity) -> Result<Self, ExactSp3ValidationError> {
89        if identity.family != ProductType::Sp3 {
90            return Err(ExactSp3ValidationError::WrongProductFamily {
91                actual: identity.family,
92            });
93        }
94        identity
95            .validate()
96            .map_err(ExactSp3ValidationError::Catalog)?;
97        let mut request = Self::new(
98            identity.date,
99            identity.issue.as_deref(),
100            &identity.span,
101            &identity.sample,
102        )?;
103        request.format_version = identity.format_version.clone();
104        request.content_start_offset_s = crate::data::exact_sp3_content_start_offset_s(identity)
105            .map_err(ExactSp3ValidationError::Catalog)?;
106        request.expected_agency = Some(
107            match identity.analysis_center {
108                AnalysisCenter::Igs => "IGS",
109                AnalysisCenter::Esa | AnalysisCenter::EsaUlt => "ESOC",
110                AnalysisCenter::Gfz | AnalysisCenter::GfzUlt => "GFZ",
111                AnalysisCenter::Cod
112                | AnalysisCenter::CodRap
113                | AnalysisCenter::CodPrd1
114                | AnalysisCenter::CodPrd2
115                | AnalysisCenter::CodUlt => "AIUB",
116                AnalysisCenter::IgsUlt => "IGS",
117                AnalysisCenter::WumNrt => "WHU",
118            }
119            .to_owned(),
120        );
121        Ok(request)
122    }
123
124    /// Require the producing-agency code declared on SP3 header line 1.
125    ///
126    /// Agency codes are one to four upper-case ASCII alphanumeric characters,
127    /// matching the SP3 `A4` field and official IGS producer codes.
128    pub fn with_expected_agency(mut self, agency: &str) -> Result<Self, ExactSp3ValidationError> {
129        if agency.is_empty()
130            || agency.len() > 4
131            || !agency
132                .bytes()
133                .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
134        {
135            return Err(ExactSp3ValidationError::InvalidExpectedAgency {
136                agency: agency.to_owned(),
137            });
138        }
139        self.expected_agency = Some(agency.to_owned());
140        Ok(self)
141    }
142
143    /// Requested filename date.
144    ///
145    /// For requests built with [`Self::new`], this is also the required first
146    /// content date. [`Self::from_identity`] may apply a cataloged historical
147    /// content-start convention while retaining this filename date.
148    pub fn date(&self) -> ProductDate {
149        self.date
150    }
151
152    /// Optional requested `HHMM` filename issue/epoch token.
153    ///
154    /// No issue means midnight. As with [`Self::date`], a catalog-derived
155    /// request may require a historical content start before this epoch.
156    pub fn issue(&self) -> Option<&str> {
157        self.issue.as_deref()
158    }
159
160    /// Requested coverage-period token.
161    pub fn span(&self) -> &str {
162        &self.span
163    }
164
165    /// Requested sample-interval token.
166    pub fn sample(&self) -> &str {
167        &self.sample
168    }
169
170    /// Optional content revision inherited from a resolved product identity.
171    pub fn format_version(&self) -> Option<&str> {
172        self.format_version.as_deref()
173    }
174
175    /// Producing-agency code required from SP3 header line 1, when constrained.
176    pub fn expected_agency(&self) -> Option<&str> {
177        self.expected_agency.as_deref()
178    }
179}
180
181/// Integrity failure while parsing or validating an exact SP3 product.
182#[derive(Debug, Clone, PartialEq)]
183#[non_exhaustive]
184pub enum ExactSp3ValidationError {
185    /// The bytes are not a parseable SP3 product.
186    Parse(crate::Error),
187    /// The full catalog identity is invalid.
188    Catalog(DataCatalogError),
189    /// A non-SP3 identity was supplied to the SP3 validator.
190    WrongProductFamily {
191        /// Supplied family.
192        actual: ProductType,
193    },
194    /// The optional start/issue token is not a valid `HHMM` value.
195    InvalidIssue {
196        /// Supplied token; an absent issue is represented by midnight and is
197        /// never invalid.
198        issue: String,
199    },
200    /// The requested span token is not a supported fixed-duration token.
201    UnsupportedSpanToken {
202        /// Supplied token.
203        token: String,
204    },
205    /// The requested sample token is not a supported positive SP3 interval.
206    UnsupportedSampleToken {
207        /// Supplied token.
208        token: String,
209    },
210    /// A valid fixed-duration span token did not use the longest exact unit.
211    NonCanonicalSpanToken {
212        /// Supplied token.
213        token: String,
214        /// Equivalent canonical token.
215        canonical: String,
216    },
217    /// A valid fixed-duration sample token did not use the longest exact unit.
218    NonCanonicalSampleToken {
219        /// Supplied token.
220        token: String,
221        /// Equivalent canonical token.
222        canonical: String,
223    },
224    /// An expected agency constraint is not a valid SP3 `A4` producer code.
225    InvalidExpectedAgency {
226        /// Supplied agency code.
227        agency: String,
228    },
229    /// Parsed SP3 producing agency differs from the exact request.
230    AgencyMismatch {
231        /// Required agency code.
232        expected: String,
233        /// Parsed header agency code.
234        actual: String,
235    },
236    /// The product omitted its terminal `EOF` record.
237    MissingEof,
238    /// The product contained an EOF-like record that violated the accepted
239    /// logical-record grammar.
240    MalformedEofRecord {
241        /// One-based logical-record line number.
242        line_number: usize,
243        /// Length of the offending logical record in bytes.
244        record_length: usize,
245    },
246    /// Nonblank records appeared after the terminal `EOF` marker.
247    TrailingContentAfterEof,
248    /// A mandatory SP3 header record count is incomplete or inconsistent.
249    MandatoryHeaderRecordCount {
250        /// Record prefix (`+`, `++`, `%c`, `%f`, or `%i`).
251        record: &'static str,
252        /// Required exact or minimum count.
253        expected: usize,
254        /// Parsed count.
255        actual: usize,
256    },
257    /// The first `+` record does not carry a parseable declared satellite count.
258    MissingDeclaredSatelliteCount,
259    /// The line-3 count differs from the number of non-padding raw declaration
260    /// tokens across all `+` records.
261    DeclaredSatelliteCountMismatch {
262        /// Count from line 3.
263        declared: usize,
264        /// Number of raw non-padding tokens.
265        tokens: usize,
266    },
267    /// A satellite token appears more than once in the header declaration.
268    DuplicateDeclaredSatellite {
269        /// Duplicate raw satellite token.
270        token: String,
271        /// First declaration index.
272        first_index: usize,
273        /// Duplicate declaration index.
274        duplicate_index: usize,
275    },
276    /// The header declares no satellites.
277    NoDeclaredSatellites,
278    /// One epoch's raw P or V records do not exactly match the declared
279    /// satellite count and order.
280    SatelliteRecordSequenceMismatch {
281        /// Record type (`P` or `V`).
282        record: &'static str,
283        /// Zero-based parsed epoch index.
284        epoch_index: usize,
285        /// Raw declared satellite order.
286        expected: Vec<String>,
287        /// Raw record satellite order.
288        actual: Vec<String>,
289    },
290    /// P/V records have the right per-type tokens but not the required body
291    /// order (for a velocity product, `P(sat), V(sat)` pairs).
292    BodyRecordInterleavingMismatch {
293        /// Zero-based parsed epoch index.
294        epoch_index: usize,
295        /// Required tagged record sequence, such as `PG01`, `VG01`.
296        expected: Vec<String>,
297        /// Parsed tagged record sequence.
298        actual: Vec<String>,
299    },
300    /// The SP3 header declares a non-finite epoch interval.
301    NonFiniteHeaderCadence,
302    /// The SP3 header declares a zero or negative epoch interval.
303    NonPositiveHeaderCadence {
304        /// Declared interval.
305        actual_s: f64,
306    },
307    /// The SP3 header interval is outside the range allowed by the format.
308    UnsupportedHeaderCadence {
309        /// Declared interval.
310        actual_s: f64,
311    },
312    /// Header cadence does not equal the exact requested sample interval.
313    CadenceMismatch {
314        /// Cadence derived from the trusted request token.
315        requested_s: f64,
316        /// Cadence declared on SP3 header line 2.
317        header_s: f64,
318    },
319    /// Header line 1's declared number of epochs differs from the parsed grid.
320    DeclaredEpochCountMismatch {
321        /// Count from header line 1.
322        declared: u64,
323        /// Number of parsed epoch records.
324        parsed: usize,
325    },
326    /// Header line 1 does not contain an interpretable start epoch.
327    MissingDeclaredStart,
328    /// Header line 1's start does not equal the exact requested start.
329    DeclaredStartMismatch {
330        /// Exact requested start, seconds since J2000.
331        requested_j2000_s: f64,
332        /// Header line 1 start, seconds since J2000.
333        declared_j2000_s: f64,
334    },
335    /// The requested start predates the GPS week-numbering epoch that SP3 line
336    /// 2 uses.
337    RequestBeforeGpsEpoch,
338    /// A floating-point SP3 line-2 start field is not finite.
339    NonFiniteHeaderStartMetadata {
340        /// SP3 header field name.
341        field: &'static str,
342    },
343    /// An SP3 line-2 start field is outside its specified range.
344    InvalidHeaderStartMetadata {
345        /// SP3 header field name.
346        field: &'static str,
347        /// Parsed field value.
348        actual: f64,
349    },
350    /// SP3 line-2 week/SOW or MJD metadata does not represent the trusted
351    /// requested start.
352    HeaderStartMetadataMismatch {
353        /// Logical SP3 header field (`gps_week`, `seconds_of_week`, or `mjd`).
354        field: &'static str,
355        /// Value derived from the trusted request.
356        requested: f64,
357        /// Parsed header value.
358        actual: f64,
359    },
360    /// The product contains no parsed epoch records.
361    EmptyEpochGrid,
362    /// The first parsed epoch does not equal the exact requested start.
363    FirstEpochMismatch {
364        /// Exact requested start, seconds since J2000.
365        requested_j2000_s: f64,
366        /// First parsed epoch, seconds since J2000.
367        actual_j2000_s: f64,
368    },
369    /// Parsed epochs are not a strictly increasing regular requested-cadence grid.
370    IrregularEpochGrid {
371        /// Index of the later epoch in the failing pair.
372        epoch_index: usize,
373        /// Exact requested cadence.
374        requested_s: f64,
375        /// Difference between this epoch and its predecessor.
376        actual_s: f64,
377    },
378    /// The declared span is not an integer multiple of the requested cadence.
379    SpanNotMultipleOfCadence {
380        /// Requested span.
381        span_s: u64,
382        /// Requested cadence.
383        cadence_s: u64,
384    },
385    /// A regular grid does not represent either the half-open or inclusive
386    /// form of the exact requested span.
387    SpanMismatch {
388        /// Parsed epoch count.
389        parsed: usize,
390        /// Required count for half-open coverage.
391        half_open: usize,
392        /// Required count for inclusive coverage.
393        inclusive: usize,
394    },
395    /// A resolved identity constrained an SP3 content revision that does not
396    /// match the parsed header.
397    FormatVersionMismatch {
398        /// Requested revision string.
399        requested: String,
400        /// Parsed revision string.
401        actual: String,
402    },
403}
404
405impl fmt::Display for ExactSp3ValidationError {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        match self {
408            Self::Parse(error) => write!(f, "exact SP3 parse failed: {error}"),
409            Self::Catalog(error) => write!(f, "invalid exact SP3 identity: {error}"),
410            Self::WrongProductFamily { actual } => {
411                write!(f, "exact SP3 validation cannot validate {actual}")
412            }
413            Self::InvalidIssue { issue } => {
414                write!(f, "invalid exact SP3 issue time {issue:?}; expected HHMM")
415            }
416            Self::UnsupportedSpanToken { token } => {
417                write!(f, "unsupported exact SP3 span token {token:?}")
418            }
419            Self::UnsupportedSampleToken { token } => {
420                write!(f, "unsupported exact SP3 sample token {token:?}")
421            }
422            Self::NonCanonicalSpanToken { token, canonical } => write!(
423                f,
424                "noncanonical exact SP3 span token {token:?}; use {canonical:?}"
425            ),
426            Self::NonCanonicalSampleToken { token, canonical } => write!(
427                f,
428                "noncanonical exact SP3 sample token {token:?}; use {canonical:?}"
429            ),
430            Self::InvalidExpectedAgency { agency } => {
431                write!(f, "invalid exact SP3 expected agency {agency:?}")
432            }
433            Self::AgencyMismatch { expected, actual } => write!(
434                f,
435                "SP3 agency mismatch: requested {expected:?}, header declares {actual:?}"
436            ),
437            Self::MissingEof => write!(f, "SP3 product is missing its EOF record"),
438            Self::MalformedEofRecord {
439                line_number,
440                record_length,
441            } => write!(
442                f,
443                "SP3 product contains a malformed EOF record at line {line_number} ({record_length} bytes)"
444            ),
445            Self::TrailingContentAfterEof => {
446                write!(f, "SP3 product contains nonblank records after EOF")
447            }
448            Self::MandatoryHeaderRecordCount {
449                record,
450                expected,
451                actual,
452            } => write!(
453                f,
454                "SP3 header record {record} count is {actual}, expected {expected}"
455            ),
456            Self::MissingDeclaredSatelliteCount => {
457                write!(f, "SP3 line 3 has no valid declared satellite count")
458            }
459            Self::DeclaredSatelliteCountMismatch { declared, tokens } => write!(
460                f,
461                "SP3 declared satellite-count mismatch: line 3 declares {declared}, header contains {tokens} tokens"
462            ),
463            Self::DuplicateDeclaredSatellite {
464                token,
465                first_index,
466                duplicate_index,
467            } => write!(
468                f,
469                "SP3 header satellite {token:?} is duplicated at indices {first_index} and {duplicate_index}"
470            ),
471            Self::NoDeclaredSatellites => {
472                write!(f, "SP3 product declares no satellites")
473            }
474            Self::SatelliteRecordSequenceMismatch {
475                record,
476                epoch_index,
477                expected,
478                actual,
479            } => write!(
480                f,
481                "SP3 epoch {epoch_index} {record}-record sequence mismatch: expected {expected:?}, got {actual:?}"
482            ),
483            Self::BodyRecordInterleavingMismatch {
484                epoch_index,
485                expected,
486                actual,
487            } => write!(
488                f,
489                "SP3 epoch {epoch_index} body record ordering mismatch: expected {expected:?}, got {actual:?}"
490            ),
491            Self::NonFiniteHeaderCadence => {
492                write!(f, "SP3 header cadence is not finite")
493            }
494            Self::NonPositiveHeaderCadence { actual_s } => {
495                write!(f, "SP3 header cadence must be positive, got {actual_s}")
496            }
497            Self::UnsupportedHeaderCadence { actual_s } => write!(
498                f,
499                "SP3 header cadence {actual_s} s is outside the supported format range"
500            ),
501            Self::CadenceMismatch {
502                requested_s,
503                header_s,
504            } => write!(
505                f,
506                "SP3 cadence mismatch: requested {requested_s} s, header declares {header_s} s"
507            ),
508            Self::DeclaredEpochCountMismatch { declared, parsed } => write!(
509                f,
510                "SP3 epoch-count mismatch: header declares {declared}, parsed {parsed}"
511            ),
512            Self::MissingDeclaredStart => {
513                write!(f, "SP3 header line 1 has no valid declared start epoch")
514            }
515            Self::DeclaredStartMismatch {
516                requested_j2000_s,
517                declared_j2000_s,
518            } => write!(
519                f,
520                "SP3 declared start mismatch: requested {requested_j2000_s} J2000 s, header declares {declared_j2000_s} J2000 s"
521            ),
522            Self::RequestBeforeGpsEpoch => {
523                write!(f, "exact SP3 request starts before the GPS week epoch")
524            }
525            Self::NonFiniteHeaderStartMetadata { field } => {
526                write!(f, "SP3 header start field {field} is not finite")
527            }
528            Self::InvalidHeaderStartMetadata { field, actual } => {
529                write!(f, "SP3 header start field {field} is out of range: {actual}")
530            }
531            Self::HeaderStartMetadataMismatch {
532                field,
533                requested,
534                actual,
535            } => write!(
536                f,
537                "SP3 header start mismatch in {field}: requested {requested}, header has {actual}"
538            ),
539            Self::EmptyEpochGrid => write!(f, "SP3 product has no epoch records"),
540            Self::FirstEpochMismatch {
541                requested_j2000_s,
542                actual_j2000_s,
543            } => write!(
544                f,
545                "SP3 first epoch mismatch: requested {requested_j2000_s} J2000 s, parsed {actual_j2000_s} J2000 s"
546            ),
547            Self::IrregularEpochGrid {
548                epoch_index,
549                requested_s,
550                actual_s,
551            } => write!(
552                f,
553                "SP3 epoch grid is irregular at index {epoch_index}: requested step {requested_s} s, got {actual_s} s"
554            ),
555            Self::SpanNotMultipleOfCadence {
556                span_s,
557                cadence_s,
558            } => write!(
559                f,
560                "exact SP3 span {span_s} s is not a multiple of cadence {cadence_s} s"
561            ),
562            Self::SpanMismatch {
563                parsed,
564                half_open,
565                inclusive,
566            } => write!(
567                f,
568                "SP3 span mismatch: parsed {parsed} epochs, expected {half_open} half-open or {inclusive} inclusive"
569            ),
570            Self::FormatVersionMismatch { requested, actual } => write!(
571                f,
572                "SP3 format-version mismatch: requested {requested:?}, parsed {actual:?}"
573            ),
574        }
575    }
576}
577
578impl std::error::Error for ExactSp3ValidationError {
579    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
580        match self {
581            Self::Parse(error) => Some(error),
582            Self::Catalog(error) => Some(error),
583            _ => None,
584        }
585    }
586}
587
588/// Parse and semantically validate bytes for one exact SP3 request.
589///
590/// The returned coverage value states which of the two supported boundary
591/// representations was present.
592pub fn parse_exact_sp3(
593    bytes: &[u8],
594    request: &ExactSp3Request,
595) -> Result<(Sp3, ExactSp3Coverage), ExactSp3ValidationError> {
596    let product = Sp3::parse(bytes).map_err(ExactSp3ValidationError::Parse)?;
597    let coverage = validate_exact_sp3(&product, request)?;
598    Ok((product, coverage))
599}
600
601/// Validate a parsed product against an exact date, span, and sample request.
602///
603/// Durations and permitted epoch counts are derived from the validated request
604/// tokens. Header cadence is checked independently and never becomes the source
605/// of truth for the allowed coverage.
606pub fn validate_exact_sp3(
607    product: &Sp3,
608    request: &ExactSp3Request,
609) -> Result<ExactSp3Coverage, ExactSp3ValidationError> {
610    let cadence_s = parse_duration_token(&request.sample, DurationField::Sample)?;
611    let span_s = parse_duration_token(&request.span, DurationField::Span)?;
612
613    validate_mandatory_structure(product)?;
614    if let Some(expected) = request.expected_agency.as_deref() {
615        let actual = product.header.agency.trim();
616        if actual != expected {
617            return Err(ExactSp3ValidationError::AgencyMismatch {
618                expected: expected.to_owned(),
619                actual: actual.to_owned(),
620            });
621        }
622    }
623
624    let header_cadence_s = product.header.epoch_interval_s;
625    if !header_cadence_s.is_finite() {
626        return Err(ExactSp3ValidationError::NonFiniteHeaderCadence);
627    }
628    if header_cadence_s <= 0.0 {
629        return Err(ExactSp3ValidationError::NonPositiveHeaderCadence {
630            actual_s: header_cadence_s,
631        });
632    }
633    if header_cadence_s >= SP3_MAX_EPOCH_INTERVAL_S {
634        return Err(ExactSp3ValidationError::UnsupportedHeaderCadence {
635            actual_s: header_cadence_s,
636        });
637    }
638    if !seconds_match(header_cadence_s, cadence_s as f64) {
639        return Err(ExactSp3ValidationError::CadenceMismatch {
640            requested_s: cadence_s as f64,
641            header_s: header_cadence_s,
642        });
643    }
644
645    if product.declared_num_epochs != product.epoch_j2000_s.len() as u64 {
646        return Err(ExactSp3ValidationError::DeclaredEpochCountMismatch {
647            declared: product.declared_num_epochs,
648            parsed: product.epoch_j2000_s.len(),
649        });
650    }
651
652    let requested_start_j2000_s = requested_start_j2000_s(request);
653    let declared_start_j2000_s = product
654        .declared_start_j2000_s
655        .ok_or(ExactSp3ValidationError::MissingDeclaredStart)?;
656    if !seconds_match(declared_start_j2000_s, requested_start_j2000_s) {
657        return Err(ExactSp3ValidationError::DeclaredStartMismatch {
658            requested_j2000_s: requested_start_j2000_s,
659            declared_j2000_s: declared_start_j2000_s,
660        });
661    }
662    validate_line2_start_metadata(product, requested_start_j2000_s)?;
663
664    let first_j2000_s = product
665        .epoch_j2000_s
666        .first()
667        .copied()
668        .ok_or(ExactSp3ValidationError::EmptyEpochGrid)?;
669    if !seconds_match(first_j2000_s, requested_start_j2000_s) {
670        return Err(ExactSp3ValidationError::FirstEpochMismatch {
671            requested_j2000_s: requested_start_j2000_s,
672            actual_j2000_s: first_j2000_s,
673        });
674    }
675
676    for (index, pair) in product.epoch_j2000_s.windows(2).enumerate() {
677        let actual_s = pair[1] - pair[0];
678        if !actual_s.is_finite() || !seconds_match(actual_s, cadence_s as f64) {
679            return Err(ExactSp3ValidationError::IrregularEpochGrid {
680                epoch_index: index + 1,
681                requested_s: cadence_s as f64,
682                actual_s,
683            });
684        }
685    }
686
687    if span_s % cadence_s != 0 {
688        return Err(ExactSp3ValidationError::SpanNotMultipleOfCadence { span_s, cadence_s });
689    }
690    let half_open = usize::try_from(span_s / cadence_s).unwrap_or(usize::MAX);
691    let inclusive = half_open.saturating_add(1);
692    match product.epoch_j2000_s.len() {
693        count if count == half_open => Ok(ExactSp3Coverage::HalfOpen),
694        count if count == inclusive => Ok(ExactSp3Coverage::Inclusive),
695        parsed => Err(ExactSp3ValidationError::SpanMismatch {
696            parsed,
697            half_open,
698            inclusive,
699        }),
700    }
701    .and_then(|coverage| {
702        validate_format_version(product.header.version, request.format_version.as_deref())?;
703        Ok(coverage)
704    })
705}
706
707fn validate_mandatory_structure(product: &Sp3) -> Result<(), ExactSp3ValidationError> {
708    if let Some(malformed) = product.terminal_record.first_malformed_record {
709        return Err(ExactSp3ValidationError::MalformedEofRecord {
710            line_number: malformed.line_number,
711            record_length: malformed.record_length,
712        });
713    }
714    if !product.terminal_record.had_valid_record {
715        return Err(ExactSp3ValidationError::MissingEof);
716    }
717    if product.terminal_record.had_trailing_content {
718        return Err(ExactSp3ValidationError::TrailingContentAfterEof);
719    }
720    if product.satellite_header_lines < 5 {
721        return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
722            record: "+",
723            expected: 5,
724            actual: product.satellite_header_lines,
725        });
726    }
727    if product.accuracy_header_lines != product.satellite_header_lines {
728        return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
729            record: "++",
730            expected: product.satellite_header_lines,
731            actual: product.accuracy_header_lines,
732        });
733    }
734    for (record, actual) in [
735        ("%c", product.time_system_header_lines),
736        ("%f", product.float_header_lines),
737        ("%i", product.integer_header_lines),
738    ] {
739        if actual != 2 {
740            return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
741                record,
742                expected: 2,
743                actual,
744            });
745        }
746    }
747    if product.header_comment_lines < 4 {
748        return Err(ExactSp3ValidationError::MandatoryHeaderRecordCount {
749            record: "/*",
750            expected: 4,
751            actual: product.header_comment_lines,
752        });
753    }
754    let declared_count = product
755        .declared_satellite_count
756        .ok_or(ExactSp3ValidationError::MissingDeclaredSatelliteCount)?;
757    if declared_count != product.declared_satellite_tokens.len() {
758        return Err(ExactSp3ValidationError::DeclaredSatelliteCountMismatch {
759            declared: declared_count,
760            tokens: product.declared_satellite_tokens.len(),
761        });
762    }
763    for duplicate_index in 0..product.declared_satellite_tokens.len() {
764        if let Some(first_index) = product.declared_satellite_tokens[..duplicate_index]
765            .iter()
766            .position(|token| token == &product.declared_satellite_tokens[duplicate_index])
767        {
768            return Err(ExactSp3ValidationError::DuplicateDeclaredSatellite {
769                token: product.declared_satellite_tokens[duplicate_index].clone(),
770                first_index,
771                duplicate_index,
772            });
773        }
774    }
775    if product.header.satellites.is_empty() {
776        return Err(ExactSp3ValidationError::NoDeclaredSatellites);
777    }
778    for epoch_index in 0..product.epochs.len() {
779        let positions = product
780            .epoch_position_tokens
781            .get(epoch_index)
782            .cloned()
783            .unwrap_or_default();
784        if positions != product.declared_satellite_tokens {
785            return Err(ExactSp3ValidationError::SatelliteRecordSequenceMismatch {
786                record: "P",
787                epoch_index,
788                expected: product.declared_satellite_tokens.clone(),
789                actual: positions,
790            });
791        }
792        let velocities = product
793            .epoch_velocity_tokens
794            .get(epoch_index)
795            .cloned()
796            .unwrap_or_default();
797        let expected_velocities = match product.header.data_type {
798            Sp3DataType::Position => Vec::new(),
799            Sp3DataType::Velocity => product.declared_satellite_tokens.clone(),
800        };
801        if velocities != expected_velocities {
802            return Err(ExactSp3ValidationError::SatelliteRecordSequenceMismatch {
803                record: "V",
804                epoch_index,
805                expected: expected_velocities,
806                actual: velocities,
807            });
808        }
809        let mut expected_body = Vec::with_capacity(match product.header.data_type {
810            Sp3DataType::Position => product.declared_satellite_tokens.len(),
811            Sp3DataType::Velocity => product.declared_satellite_tokens.len() * 2,
812        });
813        for token in &product.declared_satellite_tokens {
814            expected_body.push(format!("P{token}"));
815            if matches!(product.header.data_type, Sp3DataType::Velocity) {
816                expected_body.push(format!("V{token}"));
817            }
818        }
819        let actual_body = product
820            .epoch_state_record_sequence
821            .get(epoch_index)
822            .map(|records| {
823                records
824                    .iter()
825                    .map(|(record, token)| format!("{record}{token}"))
826                    .collect::<Vec<_>>()
827            })
828            .unwrap_or_default();
829        if actual_body != expected_body {
830            return Err(ExactSp3ValidationError::BodyRecordInterleavingMismatch {
831                epoch_index,
832                expected: expected_body,
833                actual: actual_body,
834            });
835        }
836    }
837    Ok(())
838}
839
840#[derive(Debug, Clone, Copy)]
841enum DurationField {
842    Span,
843    Sample,
844}
845
846fn parse_duration_token(token: &str, field: DurationField) -> Result<u64, ExactSp3ValidationError> {
847    let bytes = token.as_bytes();
848    let invalid = || match field {
849        DurationField::Span => ExactSp3ValidationError::UnsupportedSpanToken {
850            token: token.to_owned(),
851        },
852        DurationField::Sample => ExactSp3ValidationError::UnsupportedSampleToken {
853            token: token.to_owned(),
854        },
855    };
856    if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
857        return Err(invalid());
858    }
859    let amount = u64::from(bytes[0] - b'0') * 10 + u64::from(bytes[1] - b'0');
860    if amount == 0 {
861        return Err(invalid());
862    }
863    let unit_s = match bytes[2] {
864        b'S' => 1,
865        b'M' => 60,
866        b'H' => 3_600,
867        b'D' => 86_400,
868        b'W' if matches!(field, DurationField::Span) => 604_800,
869        // Month/year duration depends on the start calendar and `U` has no
870        // declared duration; neither is an exact fixed interval here.
871        _ => return Err(invalid()),
872    };
873    let canonical = match bytes[2] {
874        b'S' if amount % 60 == 0 => Some(format!("{:02}M", amount / 60)),
875        b'M' if amount % 60 == 0 => Some(format!("{:02}H", amount / 60)),
876        b'H' if amount % 24 == 0 => Some(format!("{:02}D", amount / 24)),
877        _ => None,
878    };
879    if let Some(canonical) = canonical {
880        return Err(match field {
881            DurationField::Span => ExactSp3ValidationError::NonCanonicalSpanToken {
882                token: token.to_owned(),
883                canonical,
884            },
885            DurationField::Sample => ExactSp3ValidationError::NonCanonicalSampleToken {
886                token: token.to_owned(),
887                canonical,
888            },
889        });
890    }
891    amount.checked_mul(unit_s).ok_or_else(invalid)
892}
893
894fn parse_issue(issue: Option<&str>) -> Result<(u8, u8), ExactSp3ValidationError> {
895    let Some(issue) = issue else {
896        return Ok((0, 0));
897    };
898    let bytes = issue.as_bytes();
899    if bytes.len() != 4 || !bytes.iter().all(u8::is_ascii_digit) {
900        return Err(ExactSp3ValidationError::InvalidIssue {
901            issue: issue.to_owned(),
902        });
903    }
904    let hour = (bytes[0] - b'0') * 10 + (bytes[1] - b'0');
905    let minute = (bytes[2] - b'0') * 10 + (bytes[3] - b'0');
906    if hour > 23 || minute > 59 {
907        return Err(ExactSp3ValidationError::InvalidIssue {
908            issue: issue.to_owned(),
909        });
910    }
911    Ok((hour, minute))
912}
913
914fn requested_start_j2000_s(request: &ExactSp3Request) -> f64 {
915    let (hour, minute) = parse_issue(request.issue.as_deref())
916        .expect("ExactSp3Request construction validates its issue token");
917    let filename_epoch_j2000_s = j2000_seconds(
918        request.date.year,
919        i32::from(request.date.month),
920        i32::from(request.date.day),
921        i32::from(hour),
922        i32::from(minute),
923        0.0,
924    );
925    // The exact equality is unchanged. Catalog evidence determines the
926    // required instant before the product bytes are parsed; no caller-facing
927    // override can move it.
928    filename_epoch_j2000_s + request.content_start_offset_s as f64
929}
930
931/// Cross-check SP3 line 2 against the trusted civil start.
932///
933/// SP3-d states that all time fields in a file use the `%c` time system, even
934/// Gregorian and Modified Julian representations. Therefore this is coordinate
935/// arithmetic in the product's declared time system; applying a GPS/UTC leap
936/// conversion between line 1, line 2, and epoch records would be incorrect.
937fn validate_line2_start_metadata(
938    product: &Sp3,
939    requested_start_j2000_s: f64,
940) -> Result<(), ExactSp3ValidationError> {
941    // ExactSp3Request starts on a whole minute, so this conversion is exact over
942    // its supported civil-year range.
943    let requested_start_j2000_s = requested_start_j2000_s as i64;
944    let requested_mjd_total_s =
945        requested_start_j2000_s + J2000_MJD_DAY * SECONDS_PER_DAY_I64 + SECONDS_PER_DAY_I64 / 2;
946    let gps_zero_mjd_total_s = GPS_ZERO_MJD_DAY * SECONDS_PER_DAY_I64;
947    let since_gps_zero_s = requested_mjd_total_s - gps_zero_mjd_total_s;
948    if since_gps_zero_s < 0 {
949        return Err(ExactSp3ValidationError::RequestBeforeGpsEpoch);
950    }
951
952    let requested_week = since_gps_zero_s.div_euclid(SECONDS_PER_WEEK_I64);
953    let requested_sow_s = since_gps_zero_s.rem_euclid(SECONDS_PER_WEEK_I64) as f64;
954    if i64::from(product.header.gnss_week) != requested_week {
955        return Err(ExactSp3ValidationError::HeaderStartMetadataMismatch {
956            field: "gps_week",
957            requested: requested_week as f64,
958            actual: f64::from(product.header.gnss_week),
959        });
960    }
961
962    let header_sow_s = product.header.seconds_of_week;
963    if !header_sow_s.is_finite() {
964        return Err(ExactSp3ValidationError::NonFiniteHeaderStartMetadata {
965            field: "seconds_of_week",
966        });
967    }
968    if !(0.0..SECONDS_PER_WEEK_I64 as f64).contains(&header_sow_s) {
969        return Err(ExactSp3ValidationError::InvalidHeaderStartMetadata {
970            field: "seconds_of_week",
971            actual: header_sow_s,
972        });
973    }
974    if !seconds_match(header_sow_s, requested_sow_s) {
975        return Err(ExactSp3ValidationError::HeaderStartMetadataMismatch {
976            field: "seconds_of_week",
977            requested: requested_sow_s,
978            actual: header_sow_s,
979        });
980    }
981
982    let header_mjd_fraction = product.header.mjd_fraction;
983    if !header_mjd_fraction.is_finite() {
984        return Err(ExactSp3ValidationError::NonFiniteHeaderStartMetadata {
985            field: "mjd_fraction",
986        });
987    }
988    if !(0.0..1.0).contains(&header_mjd_fraction) {
989        return Err(ExactSp3ValidationError::InvalidHeaderStartMetadata {
990            field: "mjd_fraction",
991            actual: header_mjd_fraction,
992        });
993    }
994    let header_mjd_total_s =
995        i64::from(product.header.mjd) as f64 * 86_400.0 + header_mjd_fraction * 86_400.0;
996    if !seconds_match(header_mjd_total_s, requested_mjd_total_s as f64) {
997        return Err(ExactSp3ValidationError::HeaderStartMetadataMismatch {
998            field: "mjd",
999            requested: requested_mjd_total_s as f64 / 86_400.0,
1000            actual: header_mjd_total_s / 86_400.0,
1001        });
1002    }
1003    Ok(())
1004}
1005
1006fn seconds_match(left: f64, right: f64) -> bool {
1007    (left - right).abs() <= WHOLE_SECOND_EPS_S
1008}
1009
1010fn validate_format_version(
1011    actual: Sp3Version,
1012    requested: Option<&str>,
1013) -> Result<(), ExactSp3ValidationError> {
1014    let Some(requested) = requested else {
1015        return Ok(());
1016    };
1017    let actual = match actual {
1018        Sp3Version::A => "SP3-a",
1019        Sp3Version::B => "SP3-b",
1020        Sp3Version::C => "SP3-c",
1021        Sp3Version::D => "SP3-d",
1022    };
1023    if requested.eq_ignore_ascii_case(actual) {
1024        Ok(())
1025    } else {
1026        Err(ExactSp3ValidationError::FormatVersionMismatch {
1027            requested: requested.to_owned(),
1028            actual: actual.to_owned(),
1029        })
1030    }
1031}