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