diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd7f52..3dba1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,64 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.2] - 2026-05-28 + +### Added + +- Added `ExactDuration`, an opaque signed nanosecond duration backed by `i128` with range ≈ ±5.4 × 10²¹ yr. Provides three boundary-projection variants: `as_seconds_i64_nanos_checked` (exact, returns `Err(DurationError::Overflow)` when the seconds component does not fit in `i64`), `as_seconds_i64_nanos_saturating` (documented lossy for extreme values), and `as_seconds_i64_nanos` (panics on overflow). Also provides `from_canonical_seconds_nanos` (requires `|nanos| < 1_000_000_000` and matching signs when `seconds ≠ 0`) plus explicit lossy `f64` conversion, quantum-based rounding, `qtty` conversion, and serde support as `{"sec": i64, "ns": i32}`. +- Added `DurationError::NonCanonical` variant returned by `from_canonical_seconds_nanos` when the signs of `seconds` and `nanos` do not match. +- Added `try_add_exact` / `try_sub_exact` fallible variants on `Time` that return `Err(DurationError)` when the duration's seconds component exceeds the `i64` range. The infallible `add_exact` / `sub_exact` delegate to these and panic on overflow; no silent saturation occurs. +- Added `ExactDuration::from_nanoseconds_i(qtty::i64::Nanosecond)` and `as_nanoseconds_i(self) -> Result` for typed integer nanosecond access without going through `f64`. +- Added `ExactDuration::from_seconds_i(qtty::i64::Second)` for whole-second construction via a typed quantity. +- Added `GnssWeek::subsecond_nanoseconds_u() -> qtty::u32::Nanosecond` and `GnssWeek::seconds_of_week_u() -> qtty::u32::Second` typed read accessors, and `GnssWeek::new_with_nanoseconds_u(week, seconds_of_week, qtty::u32::Nanosecond)` typed constructor. Unsigned quantities are used because week, seconds-of-week, and subsecond nanoseconds are non-negative fields. +- Added exact-duration integration for `Time`: ExactDuration-based difference and add/subtract helpers with split-f64 precision limits documented; epoch-relative round/floor/ceil helpers. Existing subtraction behavior is preserved for compatibility. +- Added new sealed time-scale markers: `ET`, `GPST`, `GST`, `BDT`, and `QZSST`, including GNSS reference validation data. +- Added `TimeSeries`, a time iterator with exact integer-nanosecond step scheduling and deterministic by-index construction (no accumulated repeated-add drift); produced `Time` values retain split-f64 precision limits. +- Added `tempoch-validation` as a non-published workspace crate with reference datasets and provenance metadata. +- Added property tests for exact-duration invariants, rounding behavior, scale-conversion round trips, and exact time add/subtract behavior. +- Added native ISO 8601 / RFC 3339 parser/formatter for `Time` (`parse_rfc3339`, `format_rfc3339`) with: + - Leap-second-aware **parsing** of the `23:59:60[.x]` form, validated against the compiled UTC-TAI table. + - Leap-second-aware **formatting**: `format_rfc3339_with` / `try_format_rfc3339_with` emit `23:59:60[.fraction]Z` for instants inside an announced positive leap-second window. Detection uses `Time::is_leap_second_with(ctx)`, which consults the compiled UTC–TAI table, as the authoritative signal. + - Configurable subsecond precision (0–9 digits) with `Truncate` / `RoundHalfToEven` rounding applied uniformly, including correct carry overflow into the next second. + - Parser hardening: empty fractional part (e.g. `12:34:56.Z`) and more than 9 fractional digits are rejected. + - Fallible formatting API `try_format_rfc3339_with(...) -> Result`; the infallible `format_rfc3339_with` delegates to it and returns `""` on error (documented). +- Added GNSS week / seconds-of-week format (`GnssWeek` + `GnssWeekScale` trait) implemented for `GPST`, `GST`, `BDT`, and `QZSST`, with documented rollover periods (1024 / 4096 / 8192 / 1024) and overflow detection in `to_gnss_week` (returns `ConversionError::OutOfRange` for week numbers exceeding `u32::MAX`). +- Added `tempoch_core::data::provenance` with a programmatic `ProvenanceSnapshot` (source URLs, SHA-256, validity horizons), and an `assert_fresh(now, max_age)` freshness checker exposed at the crate root as `time_data_provenance()` and `assert_time_data_fresh(...)`. + +### Fixed + +- `ExactDuration` serde `Serialize` now returns an error if the stored value exceeds the `i64` seconds range; previously the boundary projection silently saturated. +- `ExactDuration::from_quantity` now panics unconditionally on non-finite or overflowing input in all build profiles; the previous release-mode silent-fallback path has been removed. +- `ExactDuration::round_to`, `floor_to`, and `ceil_to` now use saturating arithmetic throughout to prevent overflow on extreme (`i128::MIN/MAX`) inputs. +- RFC 3339 parser now validates `:60` leap-second inputs against the compiled UTC-TAI table; dates that were not announced leap seconds (e.g. `2023-06-15T23:59:60Z`) now return `ConversionError::InvalidLeapSecond` instead of being accepted. +- RFC 3339 parser now rejects empty fractional parts (e.g. `2024-06-15T12:34:56.Z`) and inputs with more than 9 fractional digits; previously these were silently accepted or truncated. +- RFC 3339 formatter now applies `FormatPrecision::Truncate` and `FormatPrecision::RoundHalfToEven` uniformly for all `subsecond_digits` in 0–9, including correct carry overflow at `.999999999` into the next second. +- `Time::add_exact` and `sub_exact` now panic on overflow instead of silently saturating when the `ExactDuration`'s seconds component exceeds the `i64` range. New fallible `try_add_exact` / `try_sub_exact` variants return `Err(DurationError::Overflow)` in that case. Both use a two-step split-arithmetic path: the `ExactDuration` is decomposed into whole-second and nanosecond-remainder components, each added to the split-f64 storage separately. This avoids collapsing the full duration to a single `f64` before addition, preserving sub-millisecond precision for typical astronomical epochs. +- `Time::diff_exact` documentation now accurately states that the result is bounded by split-f64 storage precision (~100–150 ns near J2000 ± 50 years), not sub-nanosecond fidelity for arbitrary instants. +- `Time::to_gnss_week` now uses integer arithmetic on the split-f64 storage pair: the integer-second component is extracted and subtracted from the (exact-integer) epoch constant in `i128`, and the subsecond remainder is computed from the fractional part alone. This eliminates the catastrophic cancellation that previously occurred when subtracting the epoch from the total J2000 seconds in a single `f64`. +- `Time::from_gnss_week` now constructs the epoch `Time` and calls `add_exact` with the exact `ExactDuration` since epoch, instead of collapsing total nanoseconds to `f64` before adding the epoch offset. Nanosecond fields are preserved to within split-f64 storage precision (≤ 200 ns for typical GNSS epochs). +- GNSS round-trip tests now validate `subsecond_nanos` within ±200 ns and require exact `seconds_of_week` matching; the previous test allowed ≤1 second drift and did not validate subsecond fields. +- `render_with_digits` (non-standard subsecond digit counts 1, 2, 4, 5, 7, 8) now carries rounding overflow into the seconds field; previously a round-up at `.999...` would produce a digit count exceeding the requested width. + +### Changed + +- Extended the scale-conversion matrix to cover the new ET and GNSS scales alongside existing TAI, TT, TDB, TCG, TCB, UT1, and UTC routes. +- Documented shipped accuracy classes, validation status, and remaining roadmap items without referencing external comparison projects. + +### Roadmap + +- Still pending: `no_std` split for `tempoch-core`, CCSDS time-code parsers, formal-verification (Kani) harnesses, and FFI/WASM/Python ABI updates. + + ## [0.6.1] - 2026-05-25 +### Changed + +- Bumped workspace and crate versions to `0.6.2` for a patch release. Aligned + `qtty` dependency to `0.8.4` in `tempoch-time-data` and synchronized the + `tempoch-ffi` ABI line to `0.6.2 -> 602`. + + ### Changed - `tempoch-ffi`: marked `publish = false` in `Cargo.toml`. FFI crates are not diff --git a/Cargo.lock b/Cargo.lock index 3a2c621..aa779bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.1" @@ -129,9 +144,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cbindgen" -version = "0.29.2" +version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" +checksum = "c95537b45400390270fae69ac098d057c8f5399001cde9d04f700c105ddfff2d" dependencies = [ "clap", "heck", @@ -254,9 +269,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -301,6 +316,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -361,6 +382,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -369,7 +402,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -602,9 +635,9 @@ checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "miniz_oxide" @@ -658,6 +691,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -677,11 +719,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "qtty" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43953c1990c78d8a98a3018f9b51071f12b478c4c519f2eaf7fff954b315d08e" +checksum = "3ad9c85853b4435eb62be239f8cd0e8740ad67b65245c165d68004672c0eb1da" dependencies = [ "qtty-core", "qtty-derive", @@ -689,9 +750,9 @@ dependencies = [ [[package]] name = "qtty-core" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abd20de3f1a4b6a4979f42f0032900b0ec030df95eaaa2b0294a2918107ffa6" +checksum = "c0977cb3d61376379cdb24781f2d1c53ad09fbadcfc925813703442b84249a97" dependencies = [ "libm", "qtty-derive", @@ -701,9 +762,9 @@ dependencies = [ [[package]] name = "qtty-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ddc68ce74f35034435b61807f624f09001cbf68d3a0d4b1288d9cb68a40e1c2" +checksum = "593b4191ff7f54e773a9560a22b24851383ca56b35c6fbdb6fb30cdcf9be96c2" dependencies = [ "proc-macro2", "quote", @@ -723,6 +784,12 @@ dependencies = [ "syn", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -732,12 +799,62 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "ring" version = "0.17.14" @@ -806,6 +923,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "semver" version = "1.0.28" @@ -954,7 +1083,7 @@ dependencies = [ [[package]] name = "tempoch" -version = "0.6.1" +version = "0.6.2" dependencies = [ "chrono", "qtty", @@ -964,10 +1093,11 @@ dependencies = [ [[package]] name = "tempoch-core" -version = "0.6.1" +version = "0.6.2" dependencies = [ "affn", "chrono", + "proptest", "qtty", "serde", "serde_json", @@ -976,7 +1106,7 @@ dependencies = [ [[package]] name = "tempoch-ffi" -version = "0.6.1" +version = "0.6.2" dependencies = [ "cbindgen", "chrono", @@ -1004,6 +1134,14 @@ dependencies = [ "tempoch-time-data", ] +[[package]] +name = "tempoch-validation" +version = "0.1.0" +dependencies = [ + "qtty", + "tempoch", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1059,6 +1197,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1123,6 +1267,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1520,6 +1673,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 4e6de8c..456e1f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = [ "tempoch-ffi", "tempoch-time-data", "tempoch-time-data-updater", + "tempoch-validation", ] diff --git a/README.md b/README.md index 27284d6..095c206 100644 --- a/README.md +++ b/README.md @@ -179,21 +179,21 @@ the horizon. Use the exported `DELTA_T_PREDICTION_HORIZON_MJD` typed ```toml [dependencies] -tempoch = "0.6.1" +tempoch = "0.6.2" ``` Enable `serde` if you want to serialize typed times and periods: ```toml [dependencies] -tempoch = { version = "0.6.1", features = ["serde"] } +tempoch = { version = "0.6.2", features = ["serde"] } ``` The `serde` feature composes with the ordinary runtime refresh behavior: ```toml [dependencies] -tempoch = { version = "0.6.1", features = ["serde", "runtime-data-fetch"] } +tempoch = { version = "0.6.2", features = ["serde", "runtime-data-fetch"] } ``` ## Serde diff --git a/tempoch-core/Cargo.toml b/tempoch-core/Cargo.toml index 409f935..ceddffd 100644 --- a/tempoch-core/Cargo.toml +++ b/tempoch-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tempoch-core" -version = "0.6.1" +version = "0.6.2" edition = "2021" authors = ["VPRamon "] description = "Core astronomical time primitives for tempoch." @@ -19,9 +19,11 @@ path = "src/lib.rs" [dependencies] affn = { version = "0.7" } chrono = "0.4.43" -qtty = { version = "0.8", features = ["julian-time"] } +qtty = { version = "0.8.4", features = ["julian-time"] } serde = { version = "1.0", default-features = false, features = ["derive", "alloc"], optional = true } tempoch-time-data = { path = "../tempoch-time-data", version = "0.1.3" } [dev-dependencies] serde_json = "1" +proptest = "1" + diff --git a/tempoch-core/src/data/mod.rs b/tempoch-core/src/data/mod.rs index b01b78f..c4acc7b 100644 --- a/tempoch-core/src/data/mod.rs +++ b/tempoch-core/src/data/mod.rs @@ -3,4 +3,8 @@ //! Runtime access to bundled and optionally refreshed time-data tables. +pub mod provenance; pub mod runtime_data; +pub use provenance::{ + assert_fresh, provenance, DataHorizons, FreshnessError, ProvenanceSnapshot, SourceUrls, +}; diff --git a/tempoch-core/src/data/provenance.rs b/tempoch-core/src/data/provenance.rs new file mode 100644 index 0000000..c68e5b7 --- /dev/null +++ b/tempoch-core/src/data/provenance.rs @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Programmatic provenance & freshness reporting for the compiled and +//! runtime time-data bundles. +//! +//! The bundled `UTC-TAI`, EOP, and ΔT tables ship with metadata captured at +//! build time (or at runtime refresh time when the `runtime-data-fetch` +//! feature is active). This module exposes that metadata as a versioned +//! `ProvenanceSnapshot` struct so downstream code can: +//! +//! * audit which upstream files were used, +//! * surface staleness diagnostics in operator UIs and pipelines, +//! * enforce a maximum acceptable age (`assert_fresh`), +//! * and refuse to silently extrapolate past documented horizons. +//! +//! The structured snapshot is *additive* over the existing +//! `TimeDataProvenance` accessor surface; existing callers continue to work. + +use chrono::{DateTime, Utc}; + +use crate::data::runtime_data::active_time_data; + +/// Programmatic snapshot of the currently active time-data bundle. +/// +/// Captured at call time from the runtime store; safe to pass across +/// threads. +#[derive(Debug, Clone, PartialEq)] +pub struct ProvenanceSnapshot { + /// When the bundle's source files were fetched, as recorded by the + /// updater. May be `None` for the compiled-in bundle if the recorded + /// timestamp is not parseable as ISO 8601. + pub fetched_at: Option>, + /// Raw stored timestamp string (the on-disk form). + pub fetched_utc: String, + /// SHA-256 of the upstream `UTC-TAI.history` file. + pub utc_tai_sha256: String, + /// SHA-256 of the upstream `deltat.data` file. + pub delta_t_observed_sha256: String, + /// SHA-256 of the upstream `deltat.preds` file. + pub delta_t_predictions_sha256: String, + /// SHA-256 of the upstream IERS `finals2000A.all` file. + pub eop_finals_sha256: String, + /// Documented validity horizons for the bundled tables. + pub horizons: DataHorizons, + /// Documented upstream source URLs (constant, listed for traceability). + pub source_urls: SourceUrls, +} + +/// Documented validity horizons of the compiled-in / loaded time-data +/// tables, expressed in MJD UTC days. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DataHorizons { + pub eop_start_mjd: f64, + pub eop_observed_end_mjd: f64, + pub eop_end_mjd: f64, + pub modern_delta_t_observed_end_mjd: f64, + pub delta_t_prediction_horizon_mjd: f64, +} + +/// Documented upstream URLs for the bundled data products. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SourceUrls { + pub utc_tai: &'static str, + pub delta_t_observed: &'static str, + pub delta_t_predictions: &'static str, + pub eop_finals: &'static str, +} + +impl SourceUrls { + /// The constant URLs the updater fetches from. + pub const DEFAULT: Self = Self { + utc_tai: tempoch_time_data::UTC_TAI_HISTORY_URL, + delta_t_observed: tempoch_time_data::DELTA_T_OBSERVED_URL, + delta_t_predictions: tempoch_time_data::DELTA_T_PREDICTIONS_URL, + eop_finals: tempoch_time_data::EOP_FINALS_URL, + }; +} + +/// Errors raised by freshness checks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FreshnessError { + /// The bundle's `fetched_at` could not be parsed (compiled-in bundle + /// may report a non-ISO 8601 timestamp). + MissingTimestamp, + /// The bundle is older than `max_age` relative to `now`. Carries the + /// observed age in seconds. + Stale { + age_seconds: i64, + max_age_seconds: i64, + }, +} + +impl core::fmt::Display for FreshnessError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::MissingTimestamp => { + f.write_str("time-data bundle has no parseable fetched_at timestamp") + } + Self::Stale { + age_seconds, + max_age_seconds, + } => write!( + f, + "time-data bundle is {age_seconds}s old; max allowed is {max_age_seconds}s", + ), + } + } +} + +impl std::error::Error for FreshnessError {} + +/// Capture a `ProvenanceSnapshot` of the currently active bundle. +/// +/// Safe to call at any time; reads the active runtime store under a lock. +pub fn provenance() -> ProvenanceSnapshot { + let bundle = active_time_data(); + let raw = bundle.provenance(); + ProvenanceSnapshot { + fetched_at: raw.fetched_at(), + fetched_utc: raw.fetched_utc().to_string(), + utc_tai_sha256: raw.utc_tai_sha256().to_string(), + delta_t_observed_sha256: raw.delta_t_observed_sha256().to_string(), + delta_t_predictions_sha256: raw.delta_t_predictions_sha256().to_string(), + eop_finals_sha256: raw.eop_finals_sha256().to_string(), + horizons: DataHorizons { + eop_start_mjd: crate::EOP_START_MJD.value(), + eop_observed_end_mjd: crate::EOP_OBSERVED_END_MJD.value(), + eop_end_mjd: crate::EOP_END_MJD.value(), + modern_delta_t_observed_end_mjd: crate::MODERN_DELTA_T_OBSERVED_END_MJD.value(), + delta_t_prediction_horizon_mjd: crate::DELTA_T_PREDICTION_HORIZON_MJD.value(), + }, + source_urls: SourceUrls::DEFAULT, + } +} + +/// Assert the active bundle is no older than `max_age` relative to `now`. +/// +/// Use this at pipeline start-up to surface a clear error before any +/// conversions silently use a stale UTC-TAI table. +pub fn assert_fresh(now: DateTime, max_age: chrono::Duration) -> Result<(), FreshnessError> { + let snap = provenance(); + let fetched = snap.fetched_at.ok_or(FreshnessError::MissingTimestamp)?; + let age = now.signed_duration_since(fetched); + if age > max_age { + return Err(FreshnessError::Stale { + age_seconds: age.num_seconds(), + max_age_seconds: max_age.num_seconds(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_has_documented_horizons() { + let s = provenance(); + assert!(s.horizons.eop_end_mjd > s.horizons.eop_start_mjd); + assert!(s.horizons.eop_observed_end_mjd >= s.horizons.eop_start_mjd); + assert!(s.horizons.eop_observed_end_mjd <= s.horizons.eop_end_mjd); + assert!(s.horizons.delta_t_prediction_horizon_mjd > 0.0); + } + + #[test] + fn snapshot_exposes_source_urls() { + let s = provenance(); + assert!(s.source_urls.utc_tai.starts_with("https://")); + assert!(s.source_urls.eop_finals.contains("finals")); + } + + #[test] + fn snapshot_carries_sha256_strings() { + let s = provenance(); + // Compiled-in bundle has placeholder shas; runtime bundles have 64-hex. + // Just check the fields are non-empty. + assert!(!s.utc_tai_sha256.is_empty()); + assert!(!s.eop_finals_sha256.is_empty()); + } + + #[test] + fn assert_fresh_rejects_extremely_old_bundle() { + // Force "now" so far ahead that even a recently-fetched bundle is stale. + let far_future = DateTime::::from_timestamp(10_000_000_000, 0).unwrap(); + let max_age = chrono::Duration::seconds(1); + let res = assert_fresh(far_future, max_age); + // Either Stale or MissingTimestamp is acceptable; both surface staleness. + assert!(matches!( + res, + Err(FreshnessError::Stale { .. }) | Err(FreshnessError::MissingTimestamp) + )); + } + + #[test] + fn freshness_error_implements_display_and_error() { + let e = FreshnessError::Stale { + age_seconds: 100, + max_age_seconds: 50, + }; + let s = format!("{e}"); + assert!(s.contains("100")); + let _: &dyn std::error::Error = &e; + } +} diff --git a/tempoch-core/src/data/runtime_data/eop.rs b/tempoch-core/src/data/runtime_data/eop.rs index 87757bb..ad6bad4 100644 --- a/tempoch-core/src/data/runtime_data/eop.rs +++ b/tempoch-core/src/data/runtime_data/eop.rs @@ -69,12 +69,12 @@ pub(crate) fn time_data_eop_at(data: &TimeDataBundle, mjd_utc: DayQuantity) -> O Some(EopValues { mjd_utc, - pm_xp_arcsec: lerp_opt(lo.pm_xp_arcsec, hi.pm_xp_arcsec), - pm_yp_arcsec: lerp_opt(lo.pm_yp_arcsec, hi.pm_yp_arcsec), + pm_xp: lerp_opt(lo.pm_xp_arcsec, hi.pm_xp_arcsec).map(qtty::f64::Arcsecond::new), + pm_yp: lerp_opt(lo.pm_yp_arcsec, hi.pm_yp_arcsec).map(qtty::f64::Arcsecond::new), ut1_minus_utc, - lod_milliseconds, - dx_milliarcsec: lerp_opt(lo.dx_milliarcsec, hi.dx_milliarcsec), - dy_milliarcsec: lerp_opt(lo.dy_milliarcsec, hi.dy_milliarcsec), + lod: lod_milliseconds.map(qtty::f64::Millisecond::new), + dx: lerp_opt(lo.dx_milliarcsec, hi.dx_milliarcsec).map(qtty::f64::MilliArcsecond::new), + dy: lerp_opt(lo.dy_milliarcsec, hi.dy_milliarcsec).map(qtty::f64::MilliArcsecond::new), ut1_observed: lo.ut1_observed && hi.ut1_observed, }) } diff --git a/tempoch-core/src/earth/delta_t.rs b/tempoch-core/src/earth/delta_t.rs index 1458e32..01d83be 100644 --- a/tempoch-core/src/earth/delta_t.rs +++ b/tempoch-core/src/earth/delta_t.rs @@ -346,7 +346,7 @@ fn delta_t_seconds_unconstrained(jd_ut: Day) -> Second { /// /// `delta_t_seconds` accepts a UT1 Julian Date and returns /// [`ConversionError::Ut1HorizonExceeded`] beyond this horizon. For a typed -/// [`Time`] view, build via [`crate::format::MJD`] helpers from this day. +/// `Time` view, build via `crate::format::MJD` helpers from this day. pub const DELTA_T_PREDICTION_HORIZON_MJD: Day = MODERN_DELTA_T_END_MJD; #[cfg(test)] diff --git a/tempoch-core/src/earth/eop.rs b/tempoch-core/src/earth/eop.rs index 710ada8..589063e 100644 --- a/tempoch-core/src/earth/eop.rs +++ b/tempoch-core/src/earth/eop.rs @@ -21,13 +21,12 @@ use crate::{EOP_END_MJD, EOP_START_MJD}; /// Interpolated IERS Earth Orientation Parameters at a UTC MJD. /// -/// Fields carry the units used by the upstream IERS `finals2000A.all` file: +/// All fields carry SI-coherent qtty typed quantities: /// -/// - `pm_xp`, `pm_yp` are *arcseconds* of polar motion. -/// - `ut1_minus_utc` is *seconds of time* (DUT1). -/// - `lod` is *milliseconds of time* excess over 86 400 SI seconds. It is -/// `None` whenever the bracketing rows do not both supply a LOD value. -/// - `dx`, `dy` are IAU 2000A celestial pole offsets in *milliarcseconds*. +/// - `pm_xp`, `pm_yp` — polar motion in arcseconds. +/// - `ut1_minus_utc` — DUT1 in seconds of time. +/// - `lod` — length-of-day excess in milliseconds of time. +/// - `dx`, `dy` — IAU 2000A celestial pole offsets in milliarcseconds. /// /// Optional fields stay `None` when either bracketing upstream row leaves the /// source column blank; the API does not fabricate zero-valued PM or nutation @@ -35,12 +34,12 @@ use crate::{EOP_END_MJD, EOP_START_MJD}; #[derive(Debug, Clone, Copy, PartialEq)] pub struct EopValues { pub mjd_utc: Day, - pub pm_xp_arcsec: Option, - pub pm_yp_arcsec: Option, + pub pm_xp: Option, + pub pm_yp: Option, pub ut1_minus_utc: Second, - pub lod_milliseconds: Option, - pub dx_milliarcsec: Option, - pub dy_milliarcsec: Option, + pub lod: Option, + pub dx: Option, + pub dy: Option, /// `true` when both bracketing rows are flagged observed (`I`). pub ut1_observed: bool, } @@ -79,16 +78,16 @@ mod tests { fn exact_point_matches_source() { let mid = EOP_POINTS[EOP_POINTS.len() / 2]; let got = builtin_eop_at(Day::new(mid.mjd as f64)).unwrap(); - assert_eq!(got.pm_xp_arcsec, mid.pm_xp_arcsec); - assert_eq!(got.pm_yp_arcsec, mid.pm_yp_arcsec); + assert_eq!(got.pm_xp.map(|v| v.value()), mid.pm_xp_arcsec); + assert_eq!(got.pm_yp.map(|v| v.value()), mid.pm_yp_arcsec); assert!( (got.ut1_minus_utc.value() - mid.ut1_minus_utc_seconds).abs() < 1e-12, "ut1: {} vs {}", got.ut1_minus_utc.value(), mid.ut1_minus_utc_seconds ); - assert_eq!(got.dx_milliarcsec, mid.dx_milliarcsec); - assert_eq!(got.dy_milliarcsec, mid.dy_milliarcsec); + assert_eq!(got.dx.map(|v| v.value()), mid.dx_milliarcsec); + assert_eq!(got.dy.map(|v| v.value()), mid.dy_milliarcsec); } #[test] @@ -110,8 +109,8 @@ mod tests { .expect("generated EOP tail should include rows with blank nutation fields"); let lo = EOP_POINTS[idx]; let got = builtin_eop_at(Day::new(lo.mjd as f64 + 0.5)).unwrap(); - assert_eq!(got.dx_milliarcsec, None); - assert_eq!(got.dy_milliarcsec, None); + assert_eq!(got.dx, None); + assert_eq!(got.dy, None); } #[test] diff --git a/tempoch-core/src/format/gnss_week.rs b/tempoch-core/src/format/gnss_week.rs new file mode 100644 index 0000000..b235da9 --- /dev/null +++ b/tempoch-core/src/format/gnss_week.rs @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! GNSS week-number and seconds-of-week formatting for `Time` on the +//! supported continuous GNSS scales (`GPST`, `GST`, `BDT`, `QZSST`). +//! +//! Each constellation has its own epoch: +//! +//! | System | Scale | Epoch (UTC) | Week-number rollover | +//! |--------|--------|------------------------|----------------------| +//! | GPS | `GPST` | 1980-01-06T00:00:00Z | 1024 weeks | +//! | Galileo| `GST` | 1999-08-22T00:00:00Z | 4096 weeks | +//! | BeiDou | `BDT` | 2006-01-01T00:00:00Z | 8192 weeks | +//! | QZSS | `QZSST`| Same as GPS | 1024 weeks (legacy) | +//! +//! Each epoch above is given in *system time* (continuous, leap-second free), +//! aligned with TAI minus the scale's fixed nominal offset. The conversions +//! below operate in continuous system time only; the values do not represent +//! UTC labels. +//! +//! ## Precision +//! +//! `from_gnss_week` constructs the result by starting at the constellation's +//! epoch (stored as a split-f64 `Time`) and calling `add_exact`, which +//! adds the integer whole-second and nanosecond components separately. This +//! avoids collapsing the full duration into a single `f64` before adding, +//! and produces results accurate to within the split-f64 storage precision +//! (typically < 1 μs for instants within a few hundred years of J2000). +//! +//! `to_gnss_week` extracts the integer-second and fractional-second components +//! from the split-f64 pair and performs all week/seconds decomposition in +//! integer arithmetic. Nanosecond fields are preserved as accurately as the +//! split-f64 storage allows; for instants near 2024 the storage precision is +//! approximately ±100 ns, so `subsecond_nanos` may differ from the +//! constructed value by at most that amount. +//! +//! See: +//! * IS-GPS-200 §20.3.3.3.1.1 (GPS week) +//! * Galileo OS-SIS-ICD §5.1.2 (GST) +//! * BeiDou ICD-OS §3.4 (BDT) +//! * IS-QZSS-PNT (QZSS week, GPS-compatible) + +use crate::foundation::error::ConversionError; +use crate::model::scale::{CoordinateScale, BDT, GPST, GST, QZSST}; +use crate::model::time::Time; + +const SECONDS_PER_WEEK: qtty::i128::Second = qtty::i128::Second::new(7 * 86_400); + +/// Decomposed GNSS week-number form. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GnssWeek { + /// Full week number since the constellation's defined epoch (no rollover). + pub week: qtty::u32::Week, + /// Seconds since the start of `week` in `[0, 604800)`. + pub seconds_of_week: qtty::u32::Second, + /// Subsecond nanoseconds remainder in `[0, 1_000_000_000)`. + pub subsecond_nanos: qtty::u32::Nanosecond, +} + +impl GnssWeek { + /// Construct, validating ranges. + pub fn new( + week: qtty::u32::Week, + seconds_of_week: qtty::u32::Second, + subsecond_nanos: qtty::u32::Nanosecond, + ) -> Result { + if seconds_of_week.value() as i128 >= SECONDS_PER_WEEK.value() + || subsecond_nanos.value() >= 1_000_000_000 + { + return Err(ConversionError::OutOfRange); + } + Ok(Self { + week, + seconds_of_week, + subsecond_nanos, + }) + } + + /// Return the subsecond nanoseconds remainder as a typed unsigned integer quantity. + /// + /// The returned value is always in `[0, 1_000_000_000)` nanoseconds. + pub fn subsecond_nanoseconds_u(&self) -> qtty::u32::Nanosecond { + self.subsecond_nanos + } + + /// Return the seconds since the start of the week as a typed unsigned integer quantity. + /// + /// The returned value is always in `[0, 604_800)` seconds. + pub fn seconds_of_week_u(&self) -> qtty::u32::Second { + self.seconds_of_week + } + + /// Construct from a typed unsigned nanosecond quantity. + /// + /// Rejects values ≥ 1 × 10⁹ ns. + pub fn new_with_nanoseconds_u( + week: qtty::u32::Week, + seconds_of_week: qtty::u32::Second, + subsecond: qtty::u32::Nanosecond, + ) -> Result { + Self::new(week, seconds_of_week, subsecond) + } + + /// Convert back to a total ExactDuration since the scale's epoch. + pub fn to_duration_since_epoch(&self) -> crate::ExactDuration { + let week_count = self.week.value() as i128; + let sow = self.seconds_of_week.value() as i128; + let seconds = week_count * SECONDS_PER_WEEK.value() + sow; + let nanos = seconds * 1_000_000_000 + self.subsecond_nanos.value() as i128; + crate::ExactDuration::from_nanos(nanos) + } +} + +/// Sealed trait providing the J2000-second offset of each GNSS scale's epoch. +/// +/// Implemented for `GPST`, `GST`, `BDT`, `QZSST` only. +pub trait GnssWeekScale: CoordinateScale { + /// Nominal start-of-week-zero in *system time* J2000 seconds (computed + /// from the constellation's epoch expressed as TAI minus the fixed + /// system-time offset). + fn epoch_j2000_seconds() -> f64; + + /// Maximum representable week number before rollover, for documentation + /// and validation purposes (the conversion itself uses full weeks). + fn rollover_period_weeks() -> u32; +} + +// Empirically anchored constants: each value is the J2000-coordinate-seconds +// of the constellation's defined week-0/second-0 epoch, where week 0 starts +// at the listed UTC instant converted to the GNSS scale's continuous +// coordinate axis. These are *definitions* tied to the system's published +// week-numbering scheme, not derived from a calendar formula. +// +// To regenerate: convert the published epoch from UTC into the target GNSS +// scale via `Time::::from(parse_rfc3339_utc(epoch)).to_j2000s()` and read +// the total J2000 seconds. +const GPST_EPOCH_J2000_SECONDS: f64 = -630_763_200.0; +const GST_EPOCH_J2000_SECONDS: f64 = -11_447_987.0; +const BDT_EPOCH_J2000_SECONDS: f64 = 189_345_600.0; +const QZSST_EPOCH_J2000_SECONDS: f64 = GPST_EPOCH_J2000_SECONDS; + +impl GnssWeekScale for GPST { + fn epoch_j2000_seconds() -> f64 { + GPST_EPOCH_J2000_SECONDS + } + fn rollover_period_weeks() -> u32 { + 1024 + } +} +impl GnssWeekScale for GST { + fn epoch_j2000_seconds() -> f64 { + GST_EPOCH_J2000_SECONDS + } + fn rollover_period_weeks() -> u32 { + 4096 + } +} +impl GnssWeekScale for BDT { + fn epoch_j2000_seconds() -> f64 { + BDT_EPOCH_J2000_SECONDS + } + fn rollover_period_weeks() -> u32 { + 8192 + } +} +impl GnssWeekScale for QZSST { + fn epoch_j2000_seconds() -> f64 { + QZSST_EPOCH_J2000_SECONDS + } + fn rollover_period_weeks() -> u32 { + 1024 + } +} + +impl Time { + /// Decompose this GNSS-scale instant into `(week, seconds_of_week, + /// subsecond_nanos)` since the constellation's defined epoch. + /// + /// The week number is *full* (no rollover applied); callers wanting the + /// modular broadcast value should compute + /// `week % S::rollover_period_weeks()`. + /// + /// The whole-second decomposition uses integer arithmetic on the split-f64 + /// storage pair. The `subsecond_nanos` field is computed from the + /// fractional remainder; see the module doc for precision limits. + pub fn to_gnss_week(&self) -> Result { + let (hi, lo) = self.to_j2000s().raw_seconds_pair(); + let hi_val = hi.value(); + let lo_val = lo.value(); + + // Round hi to the nearest integer second so the residual stays small. + let hi_int = hi_val.round(); + // sub_sec is the fractional-second part: the error of rounding hi, plus lo. + let sub_sec = (hi_val - hi_int) + lo_val; + + // All epoch constants are exact integers expressible in f64 and i128. + let epoch_i128 = S::epoch_j2000_seconds() as i128; + // hi_int is within J2000-seconds range; cast via i64 then i128 is safe. + let hi_i128 = hi_int as i64 as i128; + let mut secs_since_epoch = hi_i128 - epoch_i128; + + // Convert sub-second residual to nanoseconds, handling carry. + let raw_nanos = (sub_sec * 1.0e9).round() as i64; + let sub_nanos = if raw_nanos < 0 { + secs_since_epoch -= 1; + (raw_nanos + 1_000_000_000) as u32 + } else if raw_nanos >= 1_000_000_000 { + secs_since_epoch += 1; + (raw_nanos - 1_000_000_000) as u32 + } else { + raw_nanos as u32 + }; + + if secs_since_epoch < 0 { + return Err(ConversionError::OutOfRange); + } + + let total_secs = secs_since_epoch as u64; + let week_u64 = total_secs / SECONDS_PER_WEEK.value() as u64; + if week_u64 > u32::MAX as u64 { + return Err(ConversionError::OutOfRange); + } + let week = week_u64 as u32; + let seconds_of_week = (total_secs % SECONDS_PER_WEEK.value() as u64) as u32; + + Ok(GnssWeek { + week: qtty::u32::Week::new(week), + seconds_of_week: qtty::u32::Second::new(seconds_of_week), + subsecond_nanos: qtty::u32::Nanosecond::new(sub_nanos), + }) + } + + /// Build a GNSS-scale instant from `(week, seconds_of_week, + /// subsecond_nanos)` since the constellation's defined epoch. + /// + /// Uses `add_exact` to add the integer whole-second and nanosecond + /// components to the epoch separately, preserving sub-millisecond + /// precision within the split-f64 storage limits. + pub fn from_gnss_week(gw: GnssWeek) -> Result { + let epoch = Time::::from_raw_j2000_seconds(qtty::Second::new(S::epoch_j2000_seconds()))?; + Ok(epoch.add_exact(gw.to_duration_since_epoch())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::format::iso::parse_rfc3339_utc; + + #[test] + fn gps_epoch_is_week_zero_second_zero() { + let utc = parse_rfc3339_utc("1980-01-06T00:00:00Z").unwrap(); + let gpst: Time = utc.to::(); + let gw = gpst.to_gnss_week().unwrap(); + assert_eq!(gw.week.value(), 0, "expected week 0, got {gw:?}"); + assert_eq!(gw.seconds_of_week.value(), 0, "expected sow=0, got {gw:?}"); + assert_eq!(gw.subsecond_nanos.value(), 0, "expected ns=0, got {gw:?}"); + } + + #[test] + fn galileo_epoch_is_week_zero_second_zero() { + let utc = parse_rfc3339_utc("1999-08-22T00:00:00Z").unwrap(); + let gst: Time = utc.to::(); + let gw = gst.to_gnss_week().unwrap(); + assert_eq!(gw.week.value(), 0, "expected GST week 0, got {gw:?}"); + assert_eq!(gw.seconds_of_week.value(), 0, "expected sow=0, got {gw:?}"); + assert_eq!(gw.subsecond_nanos.value(), 0, "expected ns=0, got {gw:?}"); + } + + #[test] + fn beidou_epoch_is_week_zero_second_zero() { + let utc = parse_rfc3339_utc("2006-01-01T00:00:00Z").unwrap(); + let bdt: Time = utc.to::(); + let gw = bdt.to_gnss_week().unwrap(); + assert_eq!(gw.week.value(), 0, "expected BDT week 0, got {gw:?}"); + assert_eq!(gw.seconds_of_week.value(), 0, "expected sow=0, got {gw:?}"); + assert_eq!(gw.subsecond_nanos.value(), 0, "expected ns=0, got {gw:?}"); + } + + #[test] + fn qzsst_aligned_with_gpst() { + let utc = parse_rfc3339_utc("1980-01-06T00:00:00Z").unwrap(); + let q: Time = utc.to::(); + let gp: Time = utc.to::(); + let qw = q.to_gnss_week().unwrap(); + let gw = gp.to_gnss_week().unwrap(); + assert_eq!(qw.week, gw.week); + assert_eq!(qw.seconds_of_week, gw.seconds_of_week); + assert_eq!(qw.subsecond_nanos, gw.subsecond_nanos); + } + + /// Round-trip test at GPS week 2200, sow 345600, subsecond 123_456_789 ns. + /// The integer-arithmetic path must preserve all three fields exactly + /// within the split-f64 storage tolerance. + #[test] + fn gps_week_round_trip_nanosecond_accurate() { + let gw = GnssWeek::new( + qtty::u32::Week::new(2200), + qtty::u32::Second::new(345_600), + qtty::u32::Nanosecond::new(123_456_789), + ) + .unwrap(); + let t = Time::::from_gnss_week(gw).unwrap(); + let back = t.to_gnss_week().unwrap(); + assert_eq!(back.week, gw.week, "week mismatch: {back:?} vs {gw:?}"); + assert_eq!( + back.seconds_of_week, gw.seconds_of_week, + "sow mismatch: {back:?} vs {gw:?}" + ); + // subsecond_nanos must be within ±200 ns of the original (split-f64 + // storage precision near ~700 M seconds from J2000 is ~120 ns ULP). + let ns_delta = + (back.subsecond_nanos.value() as i64 - gw.subsecond_nanos.value() as i64).abs(); + assert!( + ns_delta <= 200, + "subsecond_nanos drift {ns_delta} ns: {back:?} vs {gw:?}" + ); + } + + /// Week boundary: sow = 604_799, subsecond = 999_999_999 ns. + #[test] + fn gps_week_boundary() { + let gw = GnssWeek::new( + qtty::u32::Week::new(2200), + qtty::u32::Second::new(604_799), + qtty::u32::Nanosecond::new(999_999_999), + ) + .unwrap(); + let t = Time::::from_gnss_week(gw).unwrap(); + let back = t.to_gnss_week().unwrap(); + assert_eq!(back.week, gw.week, "week mismatch at boundary: {back:?}"); + assert_eq!( + back.seconds_of_week, gw.seconds_of_week, + "sow mismatch at boundary: {back:?}" + ); + let ns_delta = + (back.subsecond_nanos.value() as i64 - gw.subsecond_nanos.value() as i64).abs(); + assert!( + ns_delta <= 200, + "subsecond_nanos drift {ns_delta} ns at boundary: {back:?}" + ); + } + + /// GPS week 1024 rollover: the full week number must not wrap. + #[test] + fn gps_week_1024_no_rollover() { + let gw = GnssWeek::new( + qtty::u32::Week::new(1024), + qtty::u32::Second::new(0), + qtty::u32::Nanosecond::new(0), + ) + .unwrap(); + let t = Time::::from_gnss_week(gw).unwrap(); + let back = t.to_gnss_week().unwrap(); + assert_eq!(back.week.value(), 1024); + assert_eq!(back.seconds_of_week.value(), 0); + assert_eq!(back.subsecond_nanos.value(), 0); + } + + /// GPS week 2048 (second rollover boundary). + #[test] + fn gps_week_2048_no_rollover() { + let gw = GnssWeek::new( + qtty::u32::Week::new(2048), + qtty::u32::Second::new(0), + qtty::u32::Nanosecond::new(0), + ) + .unwrap(); + let t = Time::::from_gnss_week(gw).unwrap(); + let back = t.to_gnss_week().unwrap(); + assert_eq!(back.week.value(), 2048); + assert_eq!(back.seconds_of_week.value(), 0); + assert_eq!(back.subsecond_nanos.value(), 0); + } + + #[test] + fn rollover_periods_are_documented() { + assert_eq!(::rollover_period_weeks(), 1024); + assert_eq!(::rollover_period_weeks(), 4096); + assert_eq!(::rollover_period_weeks(), 8192); + assert_eq!(::rollover_period_weeks(), 1024); + } + + #[test] + fn out_of_range_inputs_rejected() { + assert!(GnssWeek::new( + qtty::u32::Week::new(0), + qtty::u32::Second::new(604_800), + qtty::u32::Nanosecond::new(0), + ) + .is_err()); + assert!(GnssWeek::new( + qtty::u32::Week::new(0), + qtty::u32::Second::new(0), + qtty::u32::Nanosecond::new(1_000_000_000), + ) + .is_err()); + } + + #[test] + fn subsecond_nanoseconds_u_matches_field() { + let gw = GnssWeek::new( + qtty::u32::Week::new(100), + qtty::u32::Second::new(12_345), + qtty::u32::Nanosecond::new(987_654_321), + ) + .unwrap(); + assert_eq!(gw.subsecond_nanoseconds_u().value(), 987_654_321_u32); + } + + #[test] + fn new_with_nanoseconds_u_accepts_valid() { + let ns = qtty::u32::Nanosecond::new(123_456_789); + let gw = GnssWeek::new_with_nanoseconds_u( + qtty::u32::Week::new(500), + qtty::u32::Second::new(100_000), + ns, + ) + .unwrap(); + assert_eq!(gw.subsecond_nanos.value(), 123_456_789); + } + + #[test] + fn new_with_nanoseconds_u_rejects_invalid() { + // out of range + let big = qtty::u32::Nanosecond::new(1_000_000_000); + assert!(GnssWeek::new_with_nanoseconds_u( + qtty::u32::Week::new(0), + qtty::u32::Second::new(0), + big, + ) + .is_err()); + } + + #[test] + fn to_gnss_week_overflow_returns_out_of_range() { + // Build a huge positive ExactDuration that maps to more than u32::MAX weeks, + // then build a Time that far in the future. Easiest way: construct a + // Time via from_raw_j2000_seconds with a very large positive offset + // corresponding to > u32::MAX * 604800 seconds past the GPST epoch. + // u32::MAX * 604800 = 2_600_468_889_600 seconds ≈ 2.6e12 s + // GPST epoch J2000 = -630_763_200 s + // So target J2000 seconds = -630_763_200 + 2_600_468_889_600 + 1 = ~2_599_838_126_401 s + // That's beyond the f64 exact-integer range so use a moderate approach: + // Create a GnssWeek with week u32::MAX; to_duration_since_epoch() returns + // a huge ExactDuration. Then from_gnss_week should succeed (it just adds), + // and to_gnss_week on the result should return the correct week (u32::MAX). + // Actually: let's verify that from_gnss_week does not silently wrap week. + let gw_max = GnssWeek { + week: qtty::u32::Week::new(u32::MAX), + seconds_of_week: qtty::u32::Second::new(0), + subsecond_nanos: qtty::u32::Nanosecond::new(0), + }; + // The duration is u32::MAX * 604800 * 1e9 ns ≈ 2.6e21 ns which fits in i128. + let dur = gw_max.to_duration_since_epoch(); + let (_s, _n) = dur + .as_seconds_i64_nanos_checked() + .expect("should fit in i64"); + // s ≈ 2.6e12 which is < i64::MAX, so add_exact should succeed. + let epoch = + Time::::from_raw_j2000_seconds(qtty::Second::new(GPST_EPOCH_J2000_SECONDS)) + .unwrap(); + let t = epoch.add_exact(dur); + // Convert back — week_u64 = u32::MAX, which is exactly u32::MAX, should succeed. + let back = t.to_gnss_week().unwrap(); + assert_eq!(back.week.value(), u32::MAX); + + // Now test actual overflow: construct a raw j2000 instant such that + // secs_since_epoch / 604800 > u32::MAX. + // (u32::MAX + 1) * 604800 seconds past epoch: + let overflow_secs = (u32::MAX as i128 + 1) * SECONDS_PER_WEEK.value(); + let epoch_j2000 = GPST_EPOCH_J2000_SECONDS as i128; + let j2000_secs = epoch_j2000 + overflow_secs; + // This is ~2.6e12 s past J2000, well within f64 precision for large integers. + let t2 = + Time::::from_raw_j2000_seconds(qtty::Second::new(j2000_secs as f64)).unwrap(); + let result = t2.to_gnss_week(); + assert!( + result.is_err(), + "expected OutOfRange for week > u32::MAX, got {result:?}" + ); + } +} diff --git a/tempoch-core/src/format/iso.rs b/tempoch-core/src/format/iso.rs new file mode 100644 index 0000000..d69a347 --- /dev/null +++ b/tempoch-core/src/format/iso.rs @@ -0,0 +1,647 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! ISO 8601 / RFC 3339 / RFC 2822 parsing and formatting for `Time` +//! and (via scale conversion) `Time`. +//! +//! The civil layer is `chrono`-backed today (chrono is a hard dependency +//! of `tempoch-core`); this module wraps the conversion to provide: +//! +//! * Subsecond precision configurable from 0..9 digits. +//! * `FormatPrecision::{Truncate, RoundHalfToEven}` rounding policy. +//! * Leap-second-aware formatting: `23:59:60[.x]` is emitted *iff* the +//! instant lands during an announced positive leap second, and accepted on +//! parse. +//! * A small `FormatOptions` value type so callers can opt into different +//! subsecond/leap-second/timezone formatting policies without affecting +//! the existing `chrono` bridge. +//! +//! The conversion goes through `Time` storage, so the +//! resulting instants are usable on any scale via the unified +//! `to::()` / `to_with::()` API. +//! +//! # Examples +//! +//! ``` +//! use tempoch_core::format::iso::FormatOptions; +//! use tempoch_core::{Time, UTC}; +//! +//! let t = Time::::parse_rfc3339("2024-06-15T12:34:56.789Z").unwrap(); +//! let s = t.format_rfc3339(FormatOptions::milliseconds()); +//! assert!(s.starts_with("2024-06-15T12:34:56.789")); +//! ``` + +use chrono::{DateTime, NaiveDateTime, Utc}; + +use crate::data::runtime_data::time_data_tai_seconds_is_in_leap_window; +use crate::earth::context::TimeContext; +use crate::foundation::error::ConversionError; +use crate::model::scale::UTC; +use crate::model::time::Time; + +/// Subsecond rounding policy used by the formatter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatPrecision { + /// Round half-to-even at the requested subsecond digit (default). + RoundHalfToEven, + /// Truncate toward zero at the requested subsecond digit. + Truncate, +} + +/// Format options for ISO 8601 / RFC 3339 output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FormatOptions { + /// Number of subsecond digits to emit (0..=9). Values above 9 are + /// clamped to 9 because tempoch's exact storage is 1 ns. + pub subsecond_digits: u8, + /// Rounding policy when truncating below the requested precision. + pub precision: FormatPrecision, + /// When true, emit the trailing `Z` (RFC 3339); when false, emit no + /// timezone suffix (bare ISO 8601 naive datetime). UTC offsets other + /// than `Z` are not supported because the underlying scale is UTC. + pub include_zulu: bool, +} + +impl FormatOptions { + /// Default RFC 3339 form with seconds resolution and `Z` suffix. + pub const SECONDS: Self = Self { + subsecond_digits: 0, + precision: FormatPrecision::Truncate, + include_zulu: true, + }; + + /// Milliseconds resolution (3 fractional digits). + pub const fn milliseconds() -> Self { + Self { + subsecond_digits: 3, + precision: FormatPrecision::RoundHalfToEven, + include_zulu: true, + } + } + + /// Microseconds resolution (6 fractional digits). + pub const fn microseconds() -> Self { + Self { + subsecond_digits: 6, + precision: FormatPrecision::RoundHalfToEven, + include_zulu: true, + } + } + + /// Nanoseconds resolution (9 fractional digits). + pub const fn nanoseconds() -> Self { + Self { + subsecond_digits: 9, + precision: FormatPrecision::RoundHalfToEven, + include_zulu: true, + } + } +} + +impl Default for FormatOptions { + fn default() -> Self { + Self::nanoseconds() + } +} + +/// Parse an RFC 3339 timestamp into the canonical UTC `J2000s` storage. +/// +/// Accepts the leap-second form `23:59:60[.x]` during announced positive +/// leap seconds; rejects it otherwise. +#[inline] +pub fn parse_rfc3339_utc(s: &str) -> Result, ConversionError> { + parse_rfc3339_utc_with(s, &TimeContext::new()) +} + +/// Like [`parse_rfc3339_utc`], but uses an explicit [`TimeContext`]. +pub fn parse_rfc3339_utc_with(s: &str, ctx: &TimeContext) -> Result, ConversionError> { + // Pre-validate: reject more than 9 fractional digits before passing to chrono. + if let Some(after_dot) = s.find('.') { + // Find the end of the fractional part (Z, +, or -) + let frac_start = after_dot + 1; + if let Some(zone_pos) = s[frac_start..].find(['Z', '+', '-']) { + let frac_len = zone_pos; + if frac_len == 0 { + return Err(ConversionError::OutOfRange); + } + if frac_len > 9 { + return Err(ConversionError::OutOfRange); + } + } + } + + // Try `chrono::DateTime::parse_from_rfc3339` first; it accepts a wide range of valid forms. + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + let utc = dt.with_timezone(&Utc); + return Time::::try_from_chrono_with(utc, ctx); + } + + // chrono rejects ":60" in the seconds field except via try_parse paths; + // fall back to a manual leap-second-aware parser for the standard form + // YYYY-MM-DDTHH:MM:SS[.fraction](Z|±HH:MM) + parse_rfc3339_manual(s, ctx) +} + +fn parse_rfc3339_manual(s: &str, ctx: &TimeContext) -> Result, ConversionError> { + // Minimum length: "YYYY-MM-DDTHH:MM:SSZ" = 20 chars. + if s.len() < 20 { + return Err(ConversionError::OutOfRange); + } + let bytes = s.as_bytes(); + if bytes[4] != b'-' + || bytes[7] != b'-' + || (bytes[10] != b'T' && bytes[10] != b' ') + || bytes[13] != b':' + || bytes[16] != b':' + { + return Err(ConversionError::OutOfRange); + } + let year: i32 = s[..4].parse().map_err(|_| ConversionError::OutOfRange)?; + let month: u32 = s[5..7].parse().map_err(|_| ConversionError::OutOfRange)?; + let day: u32 = s[8..10].parse().map_err(|_| ConversionError::OutOfRange)?; + let hour: u32 = s[11..13].parse().map_err(|_| ConversionError::OutOfRange)?; + let minute: u32 = s[14..16].parse().map_err(|_| ConversionError::OutOfRange)?; + let second_str = &s[17..19]; + let second: u32 = second_str + .parse() + .map_err(|_| ConversionError::OutOfRange)?; + + // Trailing portion may be: [.fraction][Z|±HH:MM] + let tail = &s[19..]; + let (frac_str, zone_str) = split_fraction_and_zone(tail)?; + let frac_nanos = parse_fraction_nanos(frac_str)?; + + if zone_str != "Z" { + // Only Z is supported in this path (the chrono fast path covers the + // general timezone case). + return Err(ConversionError::OutOfRange); + } + + // Handle leap-second labelling: second == 60 must occur during an + // announced positive leap second on this UTC date. + if second == 60 { + // Construct the instant at HH:59:59.999999999 and add 1s−frac. + let base = NaiveDateTime::parse_from_str( + &format!("{year:04}-{month:02}-{day:02}T{hour:02}:59:59.999999999"), + "%Y-%m-%dT%H:%M:%S%.9f", + ) + .map_err(|_| ConversionError::OutOfRange)? + .and_utc(); + let utc_almost = Time::::try_from_chrono_with(base, ctx)?; + // 1ns nudge → instant equivalent to HH:60:00, leap-second second; then add `frac_nanos` ns. + let shifted = + utc_almost.add_exact(crate::ExactDuration::from_nanos(1 + frac_nanos as i128)); + // Validate that this date/time actually had an announced positive leap second. + if !time_data_tai_seconds_is_in_leap_window( + ctx.time_data(), + shifted.to_j2000s().total_seconds(), + ) { + return Err(ConversionError::InvalidLeapSecond); + } + return Ok(shifted); + } + + if second >= 60 || minute >= 60 || hour >= 24 { + return Err(ConversionError::OutOfRange); + } + + let naive_str = if frac_nanos == 0 { + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}") + } else { + format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:09}", + frac_nanos + ) + }; + let parsed = if frac_nanos == 0 { + NaiveDateTime::parse_from_str(&naive_str, "%Y-%m-%dT%H:%M:%S") + } else { + NaiveDateTime::parse_from_str(&naive_str, "%Y-%m-%dT%H:%M:%S%.9f") + } + .map_err(|_| ConversionError::OutOfRange)? + .and_utc(); + Time::::try_from_chrono_with(parsed, ctx) +} + +fn split_fraction_and_zone(tail: &str) -> Result<(&str, &str), ConversionError> { + if let Some(stripped) = tail.strip_prefix('.') { + // fraction up to the timezone delimiter (Z, +, -) + let zone_pos = stripped + .find(['Z', '+', '-']) + .ok_or(ConversionError::OutOfRange)?; + let frac = &stripped[..zone_pos]; + // Reject empty fraction: "2024-06-15T12:34:56.Z" is not valid RFC 3339. + if frac.is_empty() { + return Err(ConversionError::OutOfRange); + } + let zone = &stripped[zone_pos..]; + Ok((frac, zone)) + } else { + Ok(("", tail)) + } +} + +fn parse_fraction_nanos(s: &str) -> Result { + if s.is_empty() { + return Ok(0); + } + // Reject more than 9 fractional digits; tempoch's resolution is 1 ns. + if s.len() > 9 { + return Err(ConversionError::OutOfRange); + } + let mut padded = [b'0'; 9]; + padded[..s.len()].copy_from_slice(s.as_bytes()); + core::str::from_utf8(&padded) + .ok() + .and_then(|p| p.parse::().ok()) + .ok_or(ConversionError::OutOfRange) +} + +impl Time { + /// Parse an RFC 3339 / ISO 8601 timestamp (UTC, `Z` suffix or named + /// offset). Accepts the leap-second form `23:59:60[.x]` during + /// announced positive leap seconds. + #[inline] + pub fn parse_rfc3339(s: &str) -> Result { + parse_rfc3339_utc(s) + } + + /// Like [`parse_rfc3339`](Self::parse_rfc3339), with an explicit + /// [`TimeContext`]. + #[inline] + pub fn parse_rfc3339_with(s: &str, ctx: &TimeContext) -> Result { + parse_rfc3339_utc_with(s, ctx) + } + + /// Format this UTC instant as RFC 3339 with the given options. + /// + /// Emits `23:59:60[.fraction]Z` when the instant lies during an announced + /// positive leap second according to the default [`TimeContext`]. + pub fn format_rfc3339(&self, opts: FormatOptions) -> String { + self.format_rfc3339_with(opts, &TimeContext::new()) + } + + /// Like [`format_rfc3339`](Self::format_rfc3339), with an explicit + /// [`TimeContext`]. + /// + /// Returns `""` if the instant cannot be converted to civil UTC. + /// Use [`try_format_rfc3339_with`](Self::try_format_rfc3339_with) to + /// handle that case explicitly. + pub fn format_rfc3339_with(&self, opts: FormatOptions, ctx: &TimeContext) -> String { + match self.try_format_rfc3339_with(opts, ctx) { + Ok(s) => s, + Err(_) => "".to_string(), + } + } + + /// Fallible variant of [`format_rfc3339_with`](Self::format_rfc3339_with). + /// + /// Returns [`ConversionError`] if the underlying UTC↔chrono conversion + /// fails (e.g. out-of-range dates). + pub fn try_format_rfc3339_with( + &self, + opts: FormatOptions, + ctx: &TimeContext, + ) -> Result { + // Use the explicit table/context-driven check as the authoritative source + // for leap-second detection. This is independent of how the chrono bridge + // internally represents subsecond nanoseconds. + let is_leap = self.is_leap_second_with(ctx); + let dt = self.try_to_chrono_with(ctx)?; + format_utc_datetime_rfc3339(dt, is_leap, opts) + } +} + +/// Apply rounding/truncation to `nanos` (0..1_000_000_000) and return +/// `(fractional_value_at_digits, carry_into_next_second)`. +fn round_subsecond(nanos: u32, digits: usize, precision: FormatPrecision) -> (u32, bool) { + debug_assert!(digits <= 9); + if digits == 9 { + return (nanos, false); + } + let scale = 10_u32.pow(9 - digits as u32); + let truncated = nanos / scale; + let rem = nanos % scale; + let mut result = truncated; + if matches!(precision, FormatPrecision::RoundHalfToEven) { + let half = scale / 2; + if rem > half || (rem == half && truncated % 2 == 1) { + result = result.saturating_add(1); + } + } + let threshold = 10_u32.pow(digits as u32); + let carry = result >= threshold; + if carry { + result -= threshold; + } + (result, carry) +} + +/// Format a `DateTime` as RFC 3339, applying uniform rounding and +/// emitting `23:59:60` for leap-second instants. +/// +/// The `is_leap` flag is the authoritative signal: it must be supplied by the +/// caller via `Time::is_leap_second_with(ctx)`, which consults the compiled +/// UTC–TAI table. Chrono must represent the leap-second instant with +/// `timestamp_subsec_nanos() >= 1_000_000_000`; if it does not, the function +/// returns `Err(ConversionError::InvalidLeapSecond)` to avoid silently producing +/// an incorrect fractional value. +fn format_utc_datetime_rfc3339( + dt: DateTime, + is_leap: bool, + opts: FormatOptions, +) -> Result { + use crate::foundation::error::ConversionError; + let digits = opts.subsecond_digits.min(9) as usize; + let raw_nanos = dt.timestamp_subsec_nanos(); + + if is_leap { + // chrono encodes leap-second instants with timestamp_subsec_nanos() ≥ 1_000_000_000. + // If that invariant is violated, the fractional position within the leap second + // cannot be reliably derived; return an error rather than silently producing + // a wrong value. + if raw_nanos < 1_000_000_000 { + return Err(ConversionError::InvalidLeapSecond); + } + let leap_nanos = raw_nanos - 1_000_000_000; + let (frac, carry) = round_subsecond(leap_nanos, digits, opts.precision); + if carry { + // Rounding caused the leap second itself to overflow into next second + // (i.e. 23:59:60.999999500 rounded up to 23:59:61 → 2017-01-01T00:00:00). + let next = dt + chrono::TimeDelta::try_seconds(1).unwrap_or_default(); + return Ok(format_normal_dt(next, 0, digits, opts)); + } + let date = dt.format("%Y-%m-%d"); + if digits == 0 { + let zulu = if opts.include_zulu { "Z" } else { "" }; + Ok(format!("{date}T23:59:60{zulu}")) + } else { + let zulu = if opts.include_zulu { "Z" } else { "" }; + Ok(format!( + "{date}T23:59:60.{:0width$}{zulu}", + frac, + width = digits + )) + } + } else { + let (frac, carry) = round_subsecond(raw_nanos, digits, opts.precision); + let effective_dt = if carry { + dt + chrono::TimeDelta::try_seconds(1).unwrap_or_default() + } else { + dt + }; + Ok(format_normal_dt(effective_dt, frac, digits, opts)) + } +} + +fn format_normal_dt(dt: DateTime, frac: u32, digits: usize, opts: FormatOptions) -> String { + let base = dt.format("%Y-%m-%dT%H:%M:%S"); + if digits == 0 { + let zulu = if opts.include_zulu { "Z" } else { "" }; + format!("{base}{zulu}") + } else { + let zulu = if opts.include_zulu { "Z" } else { "" }; + format!("{base}.{:0width$}{zulu}", frac, width = digits) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_basic_z() { + let t = Time::::parse_rfc3339("2000-01-01T12:00:00Z").unwrap(); + let s = t.format_rfc3339(FormatOptions::SECONDS); + assert_eq!(s, "2000-01-01T12:00:00Z"); + } + + #[test] + fn parse_with_milliseconds() { + let t = Time::::parse_rfc3339("2024-06-15T12:34:56.789Z").unwrap(); + let s = t.format_rfc3339(FormatOptions::milliseconds()); + assert_eq!(s, "2024-06-15T12:34:56.789Z"); + } + + #[test] + fn parse_with_microseconds_within_chrono_bridge_precision() { + // The chrono bridge collapses split storage to f64 J2000 seconds (~150 ns + // precision near 2024). Test that microsecond round-trip is within + // single-digit microseconds, which is the documented bridge tolerance. + let t = Time::::parse_rfc3339("2024-06-15T12:34:56.123456Z").unwrap(); + let s = t.format_rfc3339(FormatOptions::microseconds()); + assert!(s.starts_with("2024-06-15T12:34:56.1234"), "got {s}"); + } + + #[test] + fn parse_with_nanoseconds_within_chrono_bridge_precision() { + // Same bridge precision caveat as above; nanosecond digits will drift by + // ~150 ns near 2024. The format itself supports 9 digits. + let t = Time::::parse_rfc3339("2024-06-15T12:34:56.123456789Z").unwrap(); + let s = t.format_rfc3339(FormatOptions::nanoseconds()); + assert!(s.starts_with("2024-06-15T12:34:56.1234"), "got {s}"); + assert_eq!(s.len(), "2024-06-15T12:34:56.123456789Z".len()); + } + + #[test] + fn parse_with_named_offset_normalizes_to_utc() { + let t = Time::::parse_rfc3339("2024-06-15T14:34:56+02:00").unwrap(); + let s = t.format_rfc3339(FormatOptions::SECONDS); + assert_eq!(s, "2024-06-15T12:34:56Z"); + } + + #[test] + fn format_leap_second_emits_colon_sixty() { + // 2016-12-31T23:59:60Z was an announced positive leap second. + let t = Time::::parse_rfc3339("2016-12-31T23:59:60Z").unwrap(); + let s = t.format_rfc3339(FormatOptions::SECONDS); + assert_eq!(s, "2016-12-31T23:59:60Z"); + } + + #[test] + fn format_leap_second_with_fraction() { + // The chrono bridge has ~150 ns precision near 2016; test at millisecond level. + let t = Time::::parse_rfc3339("2016-12-31T23:59:60.500Z").unwrap(); + let s = t.format_rfc3339(FormatOptions::milliseconds()); + assert_eq!(s, "2016-12-31T23:59:60.500Z"); + } + + #[test] + fn reject_malformed_input() { + assert!(Time::::parse_rfc3339("not a date").is_err()); + assert!(Time::::parse_rfc3339("2024-13-01T00:00:00Z").is_err()); + assert!(Time::::parse_rfc3339("2024-06-15T25:00:00Z").is_err()); + } + + #[test] + fn reject_empty_fraction() { + // "2024-06-15T12:34:56.Z" has an empty fraction field — must be rejected. + let result = Time::::parse_rfc3339("2024-06-15T12:34:56.Z"); + assert!(result.is_err(), "expected Err for empty fraction, got Ok"); + } + + #[test] + fn reject_more_than_nine_fractional_digits() { + // 10 digits — must be rejected. + let result = Time::::parse_rfc3339("2024-06-15T12:34:56.1234567890Z"); + assert!(result.is_err(), "expected Err for >9 fractional digits"); + } + + #[test] + fn round_trip_seconds_precision() { + for s in ["2000-01-01T00:00:00Z", "1999-12-31T23:59:59Z"] { + let t = Time::::parse_rfc3339(s).unwrap(); + let back = t.format_rfc3339(FormatOptions::SECONDS); + assert_eq!(back, s, "round trip mismatch for {s}"); + } + } + + #[test] + fn format_options_constants_are_consistent() { + assert_eq!(FormatOptions::SECONDS.subsecond_digits, 0); + assert_eq!(FormatOptions::milliseconds().subsecond_digits, 3); + assert_eq!(FormatOptions::microseconds().subsecond_digits, 6); + assert_eq!(FormatOptions::nanoseconds().subsecond_digits, 9); + } + + #[test] + fn arbitrary_precision_digits_are_supported() { + let t = Time::::parse_rfc3339("2024-06-15T12:34:56.123456789Z").unwrap(); + let opts = FormatOptions { + subsecond_digits: 4, + precision: FormatPrecision::Truncate, + include_zulu: true, + }; + let s = t.format_rfc3339(opts); + // 4-digit subsecond resolution survives the chrono-bridge drift (~150 ns). + assert!(s.starts_with("2024-06-15T12:34:56.1234"), "got {s}"); + } + + #[test] + fn truncate_vs_round_differs_on_5() { + // Use a year-2000 epoch where chrono-bridge precision is sub-ms. + let t = Time::::parse_rfc3339("2000-06-15T12:34:56.55Z").unwrap(); + let trunc = FormatOptions { + subsecond_digits: 1, + precision: FormatPrecision::Truncate, + include_zulu: true, + }; + let round = FormatOptions { + subsecond_digits: 1, + precision: FormatPrecision::RoundHalfToEven, + include_zulu: true, + }; + let st = t.format_rfc3339(trunc); + let sr = t.format_rfc3339(round); + assert!(st.ends_with(".5Z"), "truncate got {st}"); + // Half-to-even: .5 with truncated = 5 (odd) rounds up to .6. + assert!(sr.ends_with(".6Z"), "round-half-to-even got {sr}"); + } + + #[test] + fn omit_zulu_suffix() { + let t = Time::::parse_rfc3339("2024-06-15T12:34:56Z").unwrap(); + let opts = FormatOptions { + subsecond_digits: 0, + precision: FormatPrecision::Truncate, + include_zulu: false, + }; + let s = t.format_rfc3339(opts); + assert_eq!(s, "2024-06-15T12:34:56"); + } + + #[test] + fn reject_invalid_leap_second_date() { + // 2023-06-15 was NOT a leap-second day; :60 must be rejected. + let result = Time::::parse_rfc3339("2023-06-15T23:59:60Z"); + assert!( + matches!(result, Err(ConversionError::InvalidLeapSecond)), + "expected InvalidLeapSecond, got {result:?}" + ); + // 2016-12-31 WAS a leap-second day; must parse successfully. + assert!( + Time::::parse_rfc3339("2016-12-31T23:59:60Z").is_ok(), + "expected Ok for valid leap-second date" + ); + } + + #[test] + fn rounding_truncate_standard_digits() { + // Use a simple value well within chrono-bridge precision (J2000 era). + // .123Z at 0 digits truncate → no subsecond part (no carry since .123 < .5) + let t = Time::::parse_rfc3339("2000-01-01T12:34:56.123Z").unwrap(); + let opts = FormatOptions { + subsecond_digits: 0, + precision: FormatPrecision::Truncate, + include_zulu: true, + }; + let s = t.format_rfc3339(opts); + assert_eq!(s, "2000-01-01T12:34:56Z"); + } + + #[test] + fn rounding_carry_into_next_second() { + // .999Z with 0 digits and RoundHalfToEven should carry into next second. + let t = Time::::parse_rfc3339("2000-01-01T12:34:56.999Z").unwrap(); + let opts = FormatOptions { + subsecond_digits: 0, + precision: FormatPrecision::RoundHalfToEven, + include_zulu: true, + }; + let s = t.format_rfc3339(opts); + // .999 rounds to 1.0 → carry → 12:34:57 + assert_eq!(s, "2000-01-01T12:34:57Z", "got {s}"); + } + + #[test] + fn rounding_half_even_milliseconds() { + // 500.000000 ms at 3 digits, RoundHalfToEven: + // truncated = 500, remainder = 0 (no tie) → stays .500. + // Using exactly 500ms avoids a bridge-precision boundary: ±150 ns near J2000 + // cannot shift a 0-remainder to the tie point (500_000 out of 1_000_000). + let t = Time::::parse_rfc3339("2000-01-01T12:00:00.500000000Z").unwrap(); + let opts = FormatOptions { + subsecond_digits: 3, + precision: FormatPrecision::RoundHalfToEven, + include_zulu: true, + }; + let s = t.format_rfc3339(opts); + assert_eq!(s, "2000-01-01T12:00:00.500Z", "got {s}"); + } + + #[test] + fn round_subsecond_helper_truncate() { + assert_eq!( + round_subsecond(999_999_999, 3, FormatPrecision::Truncate), + (999, false) + ); + assert_eq!( + round_subsecond(500_000_000, 3, FormatPrecision::Truncate), + (500, false) + ); + assert_eq!(round_subsecond(0, 0, FormatPrecision::Truncate), (0, false)); + } + + #[test] + fn round_subsecond_helper_carry() { + // 999_999_999 at 0 digits with RoundHalfToEven: rounds to 1 (carry). + let (v, carry) = round_subsecond(999_999_999, 0, FormatPrecision::RoundHalfToEven); + assert!(carry, "expected carry for 999_999_999 at 0 digits"); + assert_eq!(v, 0); + } + + #[test] + fn round_subsecond_helper_half_even() { + // Exact half: 500_000_000 at 0 digits. truncated = 0 (even) → no round up. + let (v, carry) = round_subsecond(500_000_000, 0, FormatPrecision::RoundHalfToEven); + assert!( + !carry, + "500_000_000 half-to-even at 0 digits: 0 is even, no carry" + ); + assert_eq!(v, 0); + // 1_500_000_000 / 1e9 is not possible but at 9 digits identity is returned. + let (v9, carry9) = round_subsecond(999_999_999, 9, FormatPrecision::RoundHalfToEven); + assert!(!carry9); + assert_eq!(v9, 999_999_999); + } +} diff --git a/tempoch-core/src/format/mod.rs b/tempoch-core/src/format/mod.rs index 43c911d..41234d5 100644 --- a/tempoch-core/src/format/mod.rs +++ b/tempoch-core/src/format/mod.rs @@ -16,14 +16,14 @@ //! //! [`JulianDate`], [`ModifiedJulianDate`], [`UnixTime`], and [`GpsTime`] implement //! [`Into`] into the default-tagged [`crate::Time`] instant on their scale (`Time`, -//! [`Time`](crate::Time), [`Time`](crate::Time)), equivalent to [`Time::to_j2000s`]. +//! [`Time`](crate::Time), [`Time`](crate::Time)), equivalent to `Time::to_j2000s`. //! [`crate::Interval::try_new`] therefore accepts encoded endpoints wherever `Into>` is required (including [`crate::Period`]). //! //! [`J2000Seconds`] is a type alias for [`crate::Time`]; prefer it when you want an explicit name for the default tag. //! //! # Main types //! -//! - [`TimeFormat`](crate::format::TimeFormat) — sealed marker trait (`JD`, `MJD`, …). +//! - [`TimeFormat`] — sealed marker trait (`JD`, `MJD`, …). //! - [`FormatForScale`] — witness that format `F` can encode scale `S`. //! - [`InfallibleFormatForScale`] — witness that the round-trip is //! context-free (except where the format itself requires a context, e.g. Unix). @@ -40,6 +40,10 @@ pub use traits::{FormatForScale, InfallibleFormatForScale}; mod impls; mod chrono; +pub mod iso; +pub use iso::{FormatOptions, FormatPrecision}; +pub mod gnss_week; +pub use gnss_week::{GnssWeek, GnssWeekScale}; /// Julian day instant on scale `S` (`JD` tag). pub type JulianDate = crate::model::time::Time; diff --git a/tempoch-core/src/foundation/duration.rs b/tempoch-core/src/foundation/duration.rs new file mode 100644 index 0000000..b601380 --- /dev/null +++ b/tempoch-core/src/foundation/duration.rs @@ -0,0 +1,1021 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Exact-precision duration container. +//! +//! [`ExactDuration`] is the canonical duration type for `tempoch`. Its +//! representation is **deliberately opaque**: today it is backed by a single +//! `i128` of nanoseconds (range ≈ ±5.4 × 10²¹ yr at 1 ns resolution). A future +//! internal migration to `i128` attoseconds or another sub-nanosecond +//! representation is a non-breaking change as long as callers go through the +//! named accessors (`as_seconds_i64_nanos`, `as_seconds_f64`, …). +//! +//! # Design choices +//! +//! * **Sign convention** — a single signed integer carries the sign uniformly. +//! This avoids the classic `{whole_seconds: i64, sub_nanos: u32}` pitfall +//! where `-0.5 s` must be represented as `{-1, 500_000_000}` and negation +//! becomes asymmetric near zero. +//! * **No `f64` in the public exact API** — `f64` boundaries are reachable only +//! through explicitly named methods (`from_seconds_f64_lossy`, +//! `as_seconds_f64`) so users see the lossy step in code review. +//! * **qtty interop** — [`ExactDuration::from_quantity`] / +//! [`ExactDuration::as_quantity`] bridge to typed `Quantity` for any +//! [`qtty::time::TimeUnit`]. The bridge through `f64` is intentional: `qtty` +//! itself is a floating-point quantity system; users wanting exact duration +//! math should keep values inside [`ExactDuration`]. +//! * **Overflow** — arithmetic uses checked operations and reports +//! [`DurationError::Overflow`] when the result leaves the i128 range; the +//! public `+`/`-` operators panic on overflow (debug + release) to match +//! `Duration`/`std::time` ergonomics. Use [`ExactDuration::checked_add`] / +//! [`ExactDuration::checked_sub`] / [`ExactDuration::checked_neg`] for +//! non-panicking callers (FFI, parsers, formal-verification harnesses). +//! +//! # Future-proofing +//! +//! Because the storage is opaque and the boundary projection +//! `(seconds: i64, nanos: u32)` is the only serde shape, migrating to a +//! sub-nanosecond representation is non-breaking; callers requesting +//! attosecond precision in serde will opt in through a future +//! `serde-attos` feature. + +use core::cmp::Ordering; +use core::ops::{Add, AddAssign, Neg, Sub, SubAssign}; + +use qtty::time::TimeUnit; +use qtty::unit::Second as SecondUnit; +use qtty::{Quantity, Second}; + +/// Nanoseconds per second; convenience constant for boundary code. +pub const NANOS_PER_SECOND: i128 = 1_000_000_000; + +/// Error type for fallible [`ExactDuration`] operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DurationError { + /// Arithmetic overflowed the `i128`-nanosecond representation. + Overflow, + /// Input scalar was NaN or infinite. + NonFinite, + /// `(seconds, nanos)` pair violates the canonical sign invariant: + /// `seconds > 0` requires `nanos >= 0`, and `seconds < 0` requires `nanos <= 0`. + NonCanonical, +} + +impl core::fmt::Display for DurationError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Overflow => f.write_str("ExactDuration arithmetic overflowed i128 nanoseconds"), + Self::NonFinite => f.write_str("ExactDuration input was NaN or infinite"), + Self::NonCanonical => f.write_str( + "ExactDuration (seconds, nanos) pair is non-canonical: \ + signs must agree (seconds > 0 ⇒ nanos ≥ 0; seconds < 0 ⇒ nanos ≤ 0)", + ), + } + } +} + +impl std::error::Error for DurationError {} + +/// Exact-precision signed duration. +/// +/// Internally an `i128` of nanoseconds. Range ≈ ±5.4 × 10²¹ yr at 1 ns +/// resolution (i128::MAX ≈ 1.7 × 10³⁸ ns ÷ 3.156 × 10¹⁶ ns/yr ≈ 5.4 × 10²¹ yr). +/// +/// Construction: +/// +/// * [`ExactDuration::ZERO`] +/// * [`ExactDuration::from_nanos`] +/// * [`ExactDuration::from_seconds_and_nanos`] +/// * [`ExactDuration::from_canonical_seconds_nanos`] (strict canonical form) +/// * [`ExactDuration::from_quantity`] / [`ExactDuration::try_from_quantity`] +/// * [`ExactDuration::from_seconds_f64_lossy`] (explicit lossy boundary) +/// +/// Accessors: +/// +/// * [`ExactDuration::as_nanos_i128`] +/// * [`ExactDuration::as_seconds_i64_nanos`] (panics if seconds outside i64 range) +/// * [`ExactDuration::as_seconds_i64_nanos_checked`] (returns Err on overflow) +/// * [`ExactDuration::as_seconds_i64_nanos_saturating`] (saturates; lossy for extreme values) +/// * [`ExactDuration::as_seconds_f64`] +/// * [`ExactDuration::as_quantity`] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ExactDuration { + nanos: i128, +} + +impl ExactDuration { + /// Zero duration. + pub const ZERO: Self = Self { nanos: 0 }; + + /// Smallest representable positive duration (1 ns). + pub const NANOSECOND: Self = Self { nanos: 1 }; + + /// One second. + pub const SECOND: Self = Self { + nanos: NANOS_PER_SECOND, + }; + + /// Maximum representable duration. + pub const MAX: Self = Self { nanos: i128::MAX }; + + /// Minimum (most negative) representable duration. + pub const MIN: Self = Self { nanos: i128::MIN }; + + /// Build from a raw nanosecond count. + #[inline] + pub const fn from_nanos(nanos: i128) -> Self { + Self { nanos } + } + + /// Build from `(seconds, nanos)` boundary projection. + /// + /// The fractional `nanos` is interpreted with the same sign as `seconds` + /// when `seconds != 0`; when `seconds == 0`, `nanos` carries the sign + /// directly. This matches the unambiguous total + /// `result_nanos = seconds * 1e9 + nanos`. + /// + /// Returns [`DurationError::Overflow`] if the multiplication overflows. + #[inline] + pub const fn from_seconds_and_nanos(seconds: i64, nanos: i32) -> Result { + // `seconds as i128 * NANOS_PER_SECOND` cannot overflow i128 because + // i64::MAX * 1e9 < i128::MAX, but the addition can if nanos has the + // same sign and a sufficiently extreme value — practically not, but we + // still go through checked_add for correctness. + let secs_nanos = (seconds as i128).wrapping_mul(NANOS_PER_SECOND); + match secs_nanos.checked_add(nanos as i128) { + Some(n) => Ok(Self { nanos: n }), + None => Err(DurationError::Overflow), + } + } + + /// Strict canonical constructor: requires `nanos ∈ (-1_000_000_000, 1_000_000_000)` and + /// that `seconds` and `nanos` share the same sign (or `nanos == 0`). + /// + /// The full invariant: + /// - `|nanos| < 1_000_000_000` + /// - if `seconds > 0`, then `nanos >= 0` + /// - if `seconds < 0`, then `nanos <= 0` + /// - if `seconds == 0`, either sign of `nanos` is valid + /// + /// Returns [`DurationError::Overflow`] if `|nanos| >= 1_000_000_000` or if the + /// multiplication overflows, and [`DurationError::NonCanonical`] if the sign + /// invariant is violated. + #[inline] + pub const fn from_canonical_seconds_nanos( + seconds: i64, + nanos: i32, + ) -> Result { + if nanos <= -(NANOS_PER_SECOND as i32) || nanos >= NANOS_PER_SECOND as i32 { + return Err(DurationError::Overflow); + } + // Sign invariant: seconds and nanos must agree in sign (or one is zero). + if (seconds > 0 && nanos < 0) || (seconds < 0 && nanos > 0) { + return Err(DurationError::NonCanonical); + } + let secs_nanos = (seconds as i128).wrapping_mul(NANOS_PER_SECOND); + match secs_nanos.checked_add(nanos as i128) { + Some(n) => Ok(Self { nanos: n }), + None => Err(DurationError::Overflow), + } + } + + /// Build from a `qtty::Quantity` of any time unit. Returns + /// [`DurationError::NonFinite`] for NaN/inf inputs and + /// [`DurationError::Overflow`] if the value does not fit in i128 ns. + #[inline] + pub fn try_from_quantity(q: Quantity) -> Result { + let secs = q.to::().value(); + if !secs.is_finite() { + return Err(DurationError::NonFinite); + } + // f64 mantissa is 53 bits; for |secs| < 2^53 / 1e9 ≈ 9.0e6 the conversion + // is exact. Outside that range we still produce the closest i128 ns + // representation; callers needing better precision should construct via + // `from_seconds_and_nanos` or `from_nanos`. + let nanos_f = secs * (NANOS_PER_SECOND as f64); + if nanos_f >= (i128::MAX as f64) || nanos_f <= (i128::MIN as f64) { + return Err(DurationError::Overflow); + } + Ok(Self { + nanos: nanos_f as i128, + }) + } + + /// Infallible variant for callers that already know the input is finite + /// and in-range. Panics on non-finite or overflowing input. + /// For fallible conversion, use [`try_from_quantity`](Self::try_from_quantity). + #[inline] + pub fn from_quantity(q: Quantity) -> Self { + Self::try_from_quantity(q).unwrap_or_else(|e| panic!("ExactDuration::from_quantity: {e}")) + } + + /// Explicit lossy `f64` → `ExactDuration` boundary. Named so the lossy + /// step is visible in code review. Returns `None` on non-finite input or + /// when the value does not fit in i128 ns. + #[inline] + pub fn from_seconds_f64_lossy(seconds: f64) -> Option { + if !seconds.is_finite() { + return None; + } + let nanos_f = seconds * (NANOS_PER_SECOND as f64); + if nanos_f >= (i128::MAX as f64) || nanos_f <= (i128::MIN as f64) { + return None; + } + Some(Self { + nanos: nanos_f as i128, + }) + } + + /// Raw signed nanosecond count. + #[inline] + pub const fn as_nanos_i128(self) -> i128 { + self.nanos + } + + /// Exact boundary projection `(seconds, nanos)` such that + /// `seconds * 1_000_000_000 + nanos == self.as_nanos_i128()` and + /// `nanos ∈ (-1_000_000_000, 1_000_000_000)`. + /// + /// Returns [`DurationError::Overflow`] when the seconds component does not + /// fit in `i64` (i.e. `|self| > i64::MAX * 1e9 ns ≈ ±292 billion years`). + /// + /// Use this as the canonical API when the invariant must be preserved. + /// For guaranteed-small durations you may use + /// [`as_seconds_i64_nanos`](Self::as_seconds_i64_nanos) (panics on overflow). + #[inline] + pub const fn as_seconds_i64_nanos_checked(self) -> Result<(i64, i32), DurationError> { + let secs = self.nanos / NANOS_PER_SECOND; + let rem = (self.nanos - secs * NANOS_PER_SECOND) as i32; + if secs > i64::MAX as i128 || secs < i64::MIN as i128 { + Err(DurationError::Overflow) + } else { + Ok((secs as i64, rem)) + } + } + + /// Boundary projection `(seconds, nanos)` that saturates the seconds + /// component to `i64::MAX` / `i64::MIN` for extreme values. + /// + /// **Lossy / non-canonical for durations outside the `i64` seconds range + /// (≈ ±292 billion years).** The invariant + /// `seconds * 1_000_000_000 + nanos == as_nanos_i128()` is **not** preserved + /// when saturation occurs. Use [`as_seconds_i64_nanos_checked`](Self::as_seconds_i64_nanos_checked) + /// for exact behaviour. + #[inline] + pub const fn as_seconds_i64_nanos_saturating(self) -> (i64, i32) { + let secs = self.nanos / NANOS_PER_SECOND; + let rem = (self.nanos - secs * NANOS_PER_SECOND) as i32; + let secs_i64 = if secs > i64::MAX as i128 { + i64::MAX + } else if secs < i64::MIN as i128 { + i64::MIN + } else { + secs as i64 + }; + (secs_i64, rem) + } + + /// Boundary projection `(seconds, nanos)`. Panics if the seconds component + /// does not fit in `i64`. + /// + /// For durations within ≈ ±292 billion years this never panics in practice. Use + /// [`as_seconds_i64_nanos_checked`](Self::as_seconds_i64_nanos_checked) to + /// handle the overflow case explicitly. + #[inline] + pub const fn as_seconds_i64_nanos(self) -> (i64, i32) { + match self.as_seconds_i64_nanos_checked() { + Ok(pair) => pair, + Err(_) => panic!("ExactDuration::as_seconds_i64_nanos: seconds out of i64 range"), + } + } + + /// Explicit lossy `ExactDuration` → `f64 seconds` boundary. + #[inline] + pub fn as_seconds_f64(self) -> f64 { + (self.nanos as f64) / (NANOS_PER_SECOND as f64) + } + + /// Build from a typed `qtty::i64::Nanosecond` integer quantity. + /// + /// The `i64` value is widened to `i128` without loss; this conversion is + /// always exact. For the low-level raw interface, see [`from_nanos`](Self::from_nanos). + #[inline] + pub fn from_nanoseconds_i(nanos: qtty::i64::Nanosecond) -> Self { + Self::from_nanos(nanos.value() as i128) + } + + /// Build from a typed `qtty::i64::Second` integer quantity (whole-second precision). + /// + /// The second value is multiplied by 1 × 10⁹ and widened to `i128` without + /// loss for any `i64` input. For sub-second precision use + /// [`from_canonical_seconds_nanos`](Self::from_canonical_seconds_nanos) or + /// [`from_nanoseconds_i`](Self::from_nanoseconds_i). + #[inline] + pub fn from_seconds_i(seconds: qtty::i64::Second) -> Self { + Self::from_nanos(seconds.value() as i128 * NANOS_PER_SECOND) + } + + /// Project to a typed `qtty::i64::Nanosecond` integer quantity. + /// + /// Returns [`DurationError::Overflow`] when the stored nanosecond count does + /// not fit in `i64` (durations outside ≈ ±292 billion years at 1 ns resolution). + #[inline] + pub fn as_nanoseconds_i(self) -> Result { + if self.nanos > i64::MAX as i128 || self.nanos < i64::MIN as i128 { + Err(DurationError::Overflow) + } else { + Ok(qtty::i64::Nanosecond::new(self.nanos as i64)) + } + } + + /// Project back into a `qtty::Quantity`. Lossy in general (f64). + #[inline] + pub fn as_quantity(self) -> Quantity { + Second::new(self.as_seconds_f64()).to::() + } + + /// True iff exactly zero. + #[inline] + pub const fn is_zero(self) -> bool { + self.nanos == 0 + } + + /// True iff strictly negative. + #[inline] + pub const fn is_negative(self) -> bool { + self.nanos < 0 + } + + /// Absolute value. Returns [`DurationError::Overflow`] on + /// [`ExactDuration::MIN`] (i128::MIN has no representable positive). + #[inline] + pub const fn checked_abs(self) -> Result { + match self.nanos.checked_abs() { + Some(n) => Ok(Self { nanos: n }), + None => Err(DurationError::Overflow), + } + } + + /// Checked addition. + #[inline] + pub const fn checked_add(self, rhs: Self) -> Result { + match self.nanos.checked_add(rhs.nanos) { + Some(n) => Ok(Self { nanos: n }), + None => Err(DurationError::Overflow), + } + } + + /// Checked subtraction. + #[inline] + pub const fn checked_sub(self, rhs: Self) -> Result { + match self.nanos.checked_sub(rhs.nanos) { + Some(n) => Ok(Self { nanos: n }), + None => Err(DurationError::Overflow), + } + } + + /// Checked negation. + #[inline] + pub const fn checked_neg(self) -> Result { + match self.nanos.checked_neg() { + Some(n) => Ok(Self { nanos: n }), + None => Err(DurationError::Overflow), + } + } + + /// Saturating addition. + #[inline] + pub const fn saturating_add(self, rhs: Self) -> Self { + Self { + nanos: self.nanos.saturating_add(rhs.nanos), + } + } + + /// Saturating subtraction. + #[inline] + pub const fn saturating_sub(self, rhs: Self) -> Self { + Self { + nanos: self.nanos.saturating_sub(rhs.nanos), + } + } + + /// Round this duration to the nearest multiple of `quantum` (banker's + /// rounding / half-to-even). `quantum` must be strictly positive; a + /// non-positive quantum returns `self` unchanged to avoid surprising + /// errors in formatting paths. + #[inline] + pub const fn round_to(self, quantum: ExactDuration) -> Self { + let q = quantum.nanos; + if q <= 0 { + return self; + } + let n = self.nanos; + // Round-half-to-even on positive quantum, treating negative `n` symmetrically. + let div = n / q; + let rem = n - div * q; + let abs_rem = if rem < 0 { -rem } else { rem }; + let half = q / 2; + let result = if abs_rem.saturating_mul(2) < q { + div + } else if abs_rem.saturating_mul(2) > q { + if n >= 0 { + div.saturating_add(1) + } else { + div.saturating_sub(1) + } + } else { + // Exact half — banker's rounding to even. + let _ = half; + if div % 2 == 0 { + div + } else if n >= 0 { + div.saturating_add(1) + } else { + div.saturating_sub(1) + } + }; + Self { + nanos: result.saturating_mul(q), + } + } + + /// Floor this duration toward negative infinity at `quantum`. + #[inline] + pub const fn floor_to(self, quantum: ExactDuration) -> Self { + let q = quantum.nanos; + if q <= 0 { + return self; + } + let n = self.nanos; + let div = n / q; + let rem = n - div * q; + let floor_div = if rem < 0 { div.saturating_sub(1) } else { div }; + Self { + nanos: floor_div.saturating_mul(q), + } + } + + /// Ceil this duration toward positive infinity at `quantum`. + #[inline] + pub const fn ceil_to(self, quantum: ExactDuration) -> Self { + let q = quantum.nanos; + if q <= 0 { + return self; + } + let n = self.nanos; + let div = n / q; + let rem = n - div * q; + let ceil_div = if rem > 0 { div.saturating_add(1) } else { div }; + Self { + nanos: ceil_div.saturating_mul(q), + } + } +} + +impl Default for ExactDuration { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl PartialOrd for ExactDuration { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ExactDuration { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + self.nanos.cmp(&other.nanos) + } +} + +impl core::fmt::Display for ExactDuration { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self.as_seconds_i64_nanos_checked() { + Ok((s, n)) => { + if n == 0 { + write!(f, "{s} s") + } else if s == 0 { + if n < 0 { + write!(f, "-0.{:09} s", -n) + } else { + write!(f, "0.{:09} s", n) + } + } else { + write!(f, "{s}.{:09} s", n.abs()) + } + } + Err(_) => { + // Extreme duration outside i64 seconds range — fall back to raw nanos. + write!(f, "{} ns", self.nanos) + } + } + } +} + +// ───────────────── Operators ───────────────── +// Panics on overflow to match `Duration` ergonomics; use `checked_*` to opt out. + +impl Add for ExactDuration { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + self.checked_add(rhs) + .expect("ExactDuration::add overflowed i128 ns") + } +} + +impl Sub for ExactDuration { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + self.checked_sub(rhs) + .expect("ExactDuration::sub overflowed i128 ns") + } +} + +impl Neg for ExactDuration { + type Output = Self; + #[inline] + fn neg(self) -> Self { + self.checked_neg() + .expect("ExactDuration::neg overflowed i128 ns") + } +} + +impl AddAssign for ExactDuration { + #[inline] + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl SubAssign for ExactDuration { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + *self = *self - rhs; + } +} + +#[cfg(feature = "serde")] +mod serde_impl { + use super::ExactDuration; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize, Deserialize)] + struct Boundary { + sec: i64, + ns: i32, + } + + impl Serialize for ExactDuration { + fn serialize(&self, serializer: S) -> Result { + let (sec, ns) = self + .as_seconds_i64_nanos_checked() + .map_err(|e| serde::ser::Error::custom(e.to_string()))?; + Boundary { sec, ns }.serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for ExactDuration { + fn deserialize>(deserializer: D) -> Result { + let b = Boundary::deserialize(deserializer)?; + ExactDuration::from_canonical_seconds_nanos(b.sec, b.ns) + .map_err(|e| serde::de::Error::custom(e.to_string())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use qtty::unit::{Day as DayUnit, Millisecond as MsUnit}; + + #[test] + fn zero_and_constants() { + assert_eq!(ExactDuration::ZERO.as_nanos_i128(), 0); + assert_eq!(ExactDuration::NANOSECOND.as_nanos_i128(), 1); + assert_eq!(ExactDuration::SECOND.as_nanos_i128(), NANOS_PER_SECOND); + assert!(ExactDuration::ZERO.is_zero()); + assert!(!ExactDuration::SECOND.is_negative()); + assert!((-ExactDuration::SECOND).is_negative()); + } + + #[test] + fn from_seconds_and_nanos_signs() { + let half_neg = ExactDuration::from_seconds_and_nanos(-1, 500_000_000).unwrap(); + assert_eq!(half_neg.as_nanos_i128(), -NANOS_PER_SECOND + 500_000_000); + let half_pos = ExactDuration::from_seconds_and_nanos(0, 500_000_000).unwrap(); + assert_eq!(half_pos.as_nanos_i128(), 500_000_000); + } + + #[test] + fn boundary_projection_round_trip() { + for nanos in [ + 0_i128, + 1, + -1, + NANOS_PER_SECOND, + -NANOS_PER_SECOND, + 1_234_567_890, + -9_876_543_210, + ] { + let d = ExactDuration::from_nanos(nanos); + let (s, n) = d.as_seconds_i64_nanos(); + let recovered = (s as i128) * NANOS_PER_SECOND + n as i128; + assert_eq!(recovered, nanos, "round trip failed for {nanos}"); + } + } + + #[test] + fn neg_round_trip_and_min_overflow() { + let d = ExactDuration::from_nanos(1_500_000_000); + assert_eq!((-(-d)), d); + assert!(matches!( + ExactDuration::MIN.checked_neg(), + Err(DurationError::Overflow) + )); + } + + #[test] + fn ordering_matches_i128() { + let a = ExactDuration::from_nanos(-5); + let b = ExactDuration::from_nanos(0); + let c = ExactDuration::from_nanos(5); + assert!(a < b && b < c); + assert_eq!(a.cmp(&a), Ordering::Equal); + } + + #[test] + fn checked_add_sub_overflow() { + assert_eq!( + ExactDuration::MAX.checked_add(ExactDuration::NANOSECOND), + Err(DurationError::Overflow) + ); + assert_eq!( + ExactDuration::MIN.checked_sub(ExactDuration::NANOSECOND), + Err(DurationError::Overflow) + ); + assert_eq!( + ExactDuration::ZERO + .checked_add(ExactDuration::SECOND) + .unwrap(), + ExactDuration::SECOND + ); + } + + #[test] + fn saturating_add_sub() { + assert_eq!( + ExactDuration::MAX.saturating_add(ExactDuration::SECOND), + ExactDuration::MAX + ); + assert_eq!( + ExactDuration::MIN.saturating_sub(ExactDuration::SECOND), + ExactDuration::MIN + ); + } + + #[test] + fn quantity_round_trip_within_mantissa() { + let q = Second::new(123.456_789_012_345); + let d = ExactDuration::try_from_quantity(q).unwrap(); + let back = d.as_quantity::(); + assert!((back.value() - q.value()).abs() < 1e-9); + } + + #[test] + fn quantity_non_finite_errors() { + assert_eq!( + ExactDuration::try_from_quantity(Second::new(f64::NAN)), + Err(DurationError::NonFinite) + ); + assert_eq!( + ExactDuration::try_from_quantity(Second::new(f64::INFINITY)), + Err(DurationError::NonFinite) + ); + } + + #[test] + fn quantity_overflow_errors() { + // 1e25 seconds is far outside i128 ns range (1.7e29 ns max). + // Use a value that triggers overflow when multiplied by 1e9. + let q = Second::new(1.0e30); + assert_eq!( + ExactDuration::try_from_quantity(q), + Err(DurationError::Overflow) + ); + } + + #[test] + fn quantity_unit_conversion() { + let ms = Quantity::::new(1500.0); + let d = ExactDuration::try_from_quantity(ms).unwrap(); + assert_eq!(d.as_nanos_i128(), 1_500_000_000); + + let day = Quantity::::new(1.0); + let d2 = ExactDuration::try_from_quantity(day).unwrap(); + assert_eq!(d2.as_nanos_i128(), 86_400 * NANOS_PER_SECOND); + } + + #[test] + fn from_seconds_f64_lossy_handles_edges() { + assert!(ExactDuration::from_seconds_f64_lossy(f64::NAN).is_none()); + assert!(ExactDuration::from_seconds_f64_lossy(f64::INFINITY).is_none()); + assert_eq!( + ExactDuration::from_seconds_f64_lossy(1.5) + .unwrap() + .as_nanos_i128(), + 1_500_000_000 + ); + } + + #[test] + fn display_basic() { + assert_eq!(ExactDuration::SECOND.to_string(), "1 s"); + assert_eq!(ExactDuration::from_nanos(0).to_string(), "0 s"); + assert_eq!( + ExactDuration::from_seconds_and_nanos(3, 250_000_000) + .unwrap() + .to_string(), + "3.250000000 s" + ); + } + + #[test] + fn add_sub_neg_operators() { + let a = ExactDuration::SECOND; + let b = ExactDuration::NANOSECOND; + assert_eq!((a + b).as_nanos_i128(), 1_000_000_001); + assert_eq!((a - b).as_nanos_i128(), 999_999_999); + assert_eq!((-a).as_nanos_i128(), -1_000_000_000); + + let mut c = a; + c += b; + assert_eq!(c.as_nanos_i128(), 1_000_000_001); + c -= b; + assert_eq!(c.as_nanos_i128(), 1_000_000_000); + } + + #[test] + #[should_panic(expected = "overflowed")] + fn add_panics_on_overflow() { + let _ = ExactDuration::MAX + ExactDuration::NANOSECOND; + } + + #[test] + fn checked_abs_works() { + assert_eq!( + ExactDuration::from_nanos(-5) + .checked_abs() + .unwrap() + .as_nanos_i128(), + 5 + ); + assert!(matches!( + ExactDuration::MIN.checked_abs(), + Err(DurationError::Overflow) + )); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_round_trip() { + let cases = [0_i128, 1, -1, 1_500_000_000, -2_345_678_901]; + for n in cases { + let d = ExactDuration::from_nanos(n); + let s = serde_json::to_string(&d).unwrap(); + let back: ExactDuration = serde_json::from_str(&s).unwrap(); + assert_eq!(back, d, "serde round-trip {n}"); + } + } + + #[test] + fn floor_ceil_round_basic() { + let q = ExactDuration::from_nanos(1_000_000_000); // 1 s + assert_eq!( + ExactDuration::from_nanos(1_500_000_000) + .floor_to(q) + .as_nanos_i128(), + 1_000_000_000 + ); + assert_eq!( + ExactDuration::from_nanos(1_500_000_000) + .ceil_to(q) + .as_nanos_i128(), + 2_000_000_000 + ); + // Half-to-even: 1.5 rounds to 2 (even); 2.5 rounds to 2 (even); 0.5 rounds to 0 (even). + assert_eq!( + ExactDuration::from_nanos(1_500_000_000) + .round_to(q) + .as_nanos_i128(), + 2_000_000_000 + ); + assert_eq!( + ExactDuration::from_nanos(2_500_000_000) + .round_to(q) + .as_nanos_i128(), + 2_000_000_000 + ); + assert_eq!( + ExactDuration::from_nanos(500_000_000) + .round_to(q) + .as_nanos_i128(), + 0 + ); + } + + #[test] + fn floor_ceil_round_negative() { + let q = ExactDuration::from_nanos(1_000_000_000); + // -1.5 s + let n = ExactDuration::from_nanos(-1_500_000_000); + assert_eq!(n.floor_to(q).as_nanos_i128(), -2_000_000_000); + assert_eq!(n.ceil_to(q).as_nanos_i128(), -1_000_000_000); + // half-to-even on -1.5 → -2 (even) + assert_eq!(n.round_to(q).as_nanos_i128(), -2_000_000_000); + } + + #[test] + fn round_with_non_positive_quantum_is_identity() { + let n = ExactDuration::from_nanos(123); + assert_eq!(n.round_to(ExactDuration::ZERO), n); + assert_eq!(n.floor_to(ExactDuration::from_nanos(-1)), n); + assert_eq!(n.ceil_to(ExactDuration::ZERO), n); + } + + #[test] + fn round_floor_ceil_saturate_at_extremes() { + let q = ExactDuration::SECOND; + // Near i128::MAX: result should not panic, may saturate. + let near_max = ExactDuration::MAX; + let _ = near_max.round_to(q); + let _ = near_max.floor_to(q); + let _ = near_max.ceil_to(q); + let near_min = ExactDuration::MIN; + let _ = near_min.round_to(q); + let _ = near_min.floor_to(q); + let _ = near_min.ceil_to(q); + } + + #[test] + #[should_panic(expected = "ExactDuration::from_quantity")] + fn from_quantity_panics_on_nan() { + let _ = ExactDuration::from_quantity(Second::new(f64::NAN)); + } + + #[test] + #[should_panic(expected = "ExactDuration::from_quantity")] + fn from_quantity_panics_on_overflow() { + let _ = ExactDuration::from_quantity(Second::new(1.0e40)); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serialize_fails_on_out_of_range() { + // Duration of ~300 billion years — exceeds i64 seconds range. + let huge = ExactDuration::MAX; + let result = serde_json::to_string(&huge); + assert!( + result.is_err(), + "expected serde error for out-of-range duration" + ); + } + + #[test] + fn checked_projection_overflow_on_max() { + assert_eq!( + ExactDuration::MAX.as_seconds_i64_nanos_checked(), + Err(DurationError::Overflow) + ); + assert_eq!( + ExactDuration::MIN.as_seconds_i64_nanos_checked(), + Err(DurationError::Overflow) + ); + } + + #[test] + fn checked_projection_small_round_trips() { + for nanos in [0_i128, 1, -1, 999_999_999, -999_999_999, 1_500_000_000] { + let d = ExactDuration::from_nanos(nanos); + let (s, n) = d.as_seconds_i64_nanos_checked().unwrap(); + let recovered = (s as i128) * NANOS_PER_SECOND + n as i128; + assert_eq!(recovered, nanos, "checked round-trip failed for {nanos}"); + } + } + + #[test] + fn saturating_projection_extremes() { + let (s_max, _) = ExactDuration::MAX.as_seconds_i64_nanos_saturating(); + assert_eq!(s_max, i64::MAX); + let (s_min, _) = ExactDuration::MIN.as_seconds_i64_nanos_saturating(); + assert_eq!(s_min, i64::MIN); + } + + #[test] + #[should_panic(expected = "as_seconds_i64_nanos: seconds out of i64 range")] + fn panicking_projection_panics_on_max() { + let _ = ExactDuration::MAX.as_seconds_i64_nanos(); + } + + #[test] + fn canonical_constructor_validates_nanos() { + // Valid canonical forms + assert!(ExactDuration::from_canonical_seconds_nanos(5, 0).is_ok()); + assert!(ExactDuration::from_canonical_seconds_nanos(0, 999_999_999).is_ok()); + assert!(ExactDuration::from_canonical_seconds_nanos(-1, -999_999_999).is_ok()); + assert!(ExactDuration::from_canonical_seconds_nanos(0, 0).is_ok()); + assert!(ExactDuration::from_canonical_seconds_nanos(0, -999_999_999).is_ok()); + // Invalid: |nanos| >= 1_000_000_000 + assert_eq!( + ExactDuration::from_canonical_seconds_nanos(0, 1_000_000_000), + Err(DurationError::Overflow) + ); + assert_eq!( + ExactDuration::from_canonical_seconds_nanos(0, -1_000_000_000), + Err(DurationError::Overflow) + ); + // Invalid: sign mismatch — returns NonCanonical + assert_eq!( + ExactDuration::from_canonical_seconds_nanos(1, -1), + Err(DurationError::NonCanonical) + ); + assert_eq!( + ExactDuration::from_canonical_seconds_nanos(-1, 1), + Err(DurationError::NonCanonical) + ); + assert_eq!( + ExactDuration::from_canonical_seconds_nanos(100, -500_000_000), + Err(DurationError::NonCanonical) + ); + assert_eq!( + ExactDuration::from_canonical_seconds_nanos(-100, 500_000_000), + Err(DurationError::NonCanonical) + ); + } + + #[test] + fn display_extreme_falls_back_to_raw_nanos() { + // ExactDuration::MAX seconds > i64 range; display must not panic. + let s = ExactDuration::MAX.to_string(); + assert!(s.contains("ns"), "expected raw-ns fallback, got: {s}"); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_rejects_non_canonical_pairs() { + // sec=0, ns=1_000_000_000: nanos out of range + let r: Result = serde_json::from_str(r#"{"sec":0,"ns":1000000000}"#); + assert!(r.is_err(), "expected Err for ns=1e9, got {:?}", r); + + // sec=0, ns=-1_000_000_000: nanos out of range + let r: Result = serde_json::from_str(r#"{"sec":0,"ns":-1000000000}"#); + assert!(r.is_err(), "expected Err for ns=-1e9, got {:?}", r); + + // sec=1, ns=-1: sign mismatch + let r: Result = serde_json::from_str(r#"{"sec":1,"ns":-1}"#); + assert!(r.is_err(), "expected Err for sec=1,ns=-1, got {:?}", r); + + // sec=-1, ns=1: sign mismatch + let r: Result = serde_json::from_str(r#"{"sec":-1,"ns":1}"#); + assert!(r.is_err(), "expected Err for sec=-1,ns=1, got {:?}", r); + } + + #[test] + fn qtty_integer_nanosecond_round_trip() { + // Small positive + let d = ExactDuration::from_nanos(123_456_789); + let q = d.as_nanoseconds_i().unwrap(); + assert_eq!(q.value(), 123_456_789_i64); + let back = ExactDuration::from_nanoseconds_i(q); + assert_eq!(back, d); + + // Small negative + let d2 = ExactDuration::from_nanos(-999_000_000); + let q2 = d2.as_nanoseconds_i().unwrap(); + assert_eq!(q2.value(), -999_000_000_i64); + assert_eq!(ExactDuration::from_nanoseconds_i(q2), d2); + + // Zero + let q0 = ExactDuration::ZERO.as_nanoseconds_i().unwrap(); + assert_eq!(q0.value(), 0_i64); + } + + #[test] + fn qtty_integer_nanosecond_overflow() { + // ExactDuration::MAX nanos >> i64::MAX → overflow + assert_eq!( + ExactDuration::MAX.as_nanoseconds_i(), + Err(DurationError::Overflow) + ); + assert_eq!( + ExactDuration::MIN.as_nanoseconds_i(), + Err(DurationError::Overflow) + ); + } +} diff --git a/tempoch-core/src/foundation/mod.rs b/tempoch-core/src/foundation/mod.rs index 876950d..0990df0 100644 --- a/tempoch-core/src/foundation/mod.rs +++ b/tempoch-core/src/foundation/mod.rs @@ -4,7 +4,9 @@ //! Shared crate foundations used by every domain layer. pub mod constats; +pub mod duration; pub mod error; pub(crate) mod sealed; +pub use duration::{DurationError, ExactDuration, NANOS_PER_SECOND}; pub use error::{ConversionError, TimeDataError}; diff --git a/tempoch-core/src/lib.rs b/tempoch-core/src/lib.rs index 1a0f343..3f2b33d 100644 --- a/tempoch-core/src/lib.rs +++ b/tempoch-core/src/lib.rs @@ -21,7 +21,7 @@ //! and interpreted through the active UTC-TAI table when civil labels are //! needed. //! -//! - [`Time::new`] builds from a raw scalar when `F` is [`InfallibleFormatForScale`](crate::InfallibleFormatForScale) for `S` (**NaN panics**; ±∞ allowed at rest). POSIX [`Unix`] instants still use [`Time::try_new`] / [`Time::try_new_with`] because decoding depends on leap-second tables. +//! - [`Time::new`] builds from a raw scalar when `F` is [`InfallibleFormatForScale`] for `S` (**NaN panics**; ±∞ allowed at rest). POSIX [`Unix`] instants still use [`Time::try_new`] / [`Time::try_new_with`] because decoding depends on leap-second tables. //! - [`Time::try_new`] / [`Time::try_new_with`] surface **domain** decode failures only (UTC policy, leap seconds, …); callers must not pass **NaN**. //! - `UTC`: civil (`chrono`) and POSIX ([`Unix`]); `TAI`: GPS ([`GPS`]) //! - Unified targets: [`Time::to`], [`Time::try_to`], [`Time::to_with`]. Prefer @@ -39,7 +39,7 @@ //! //! - [`foundation`]: shared sealed traits, typed constants, and error types. //! - [`model`]: [`Time`], scale markers, and conversion targets. -//! - [`format`]: external format markers and format conversion traits. +//! - `format`: external format markers and format conversion traits. //! - `encoding`: crate-local JD/MJD/J2000/Unix arithmetic helpers. //! - [`earth`]: ΔT, EOP, and [`TimeContext`] Earth-rotation policy. //! - [`data`]: runtime access to bundled and optionally refreshed time-data tables. @@ -70,6 +70,10 @@ pub(crate) use tempoch_time_data::generated::{MODERN_DELTA_T_END_MJD, MODERN_DEL pub use earth::eop; pub use foundation::{constats, error}; +pub use data::provenance::{ + assert_fresh as assert_time_data_fresh, provenance as time_data_provenance, DataHorizons, + FreshnessError, ProvenanceSnapshot, SourceUrls, +}; #[cfg(feature = "runtime-data-fetch")] pub use data::runtime_data::{ fetch_latest_time_data, refresh_runtime_time_data, update_runtime_time_data, @@ -80,8 +84,9 @@ pub use earth::delta_t::{ }; pub use features::TimeInstant; pub use format::{ - FormatForScale, GpsTime, InfallibleFormatForScale, J2000Seconds, J2000s, JulianDate, - ModifiedJulianDate, TimeFormat, Unix, UnixTime, GPS, JD, MJD, + FormatForScale, FormatOptions, FormatPrecision, GnssWeek, GnssWeekScale, GpsTime, + InfallibleFormatForScale, J2000Seconds, J2000s, JulianDate, ModifiedJulianDate, TimeFormat, + Unix, UnixTime, GPS, JD, MJD, }; pub use foundation::constats::{ gps_epoch_jd_tai, gps_epoch_jd_utc, gps_epoch_tai, iau_time_epoch_t0_jd, j2000_jd_tt, @@ -92,11 +97,18 @@ pub use foundation::constats::{ TDB_TT_MODEL_HIGH_ACCURACY_START_JD_DAY, TT_MINUS_TAI, UNIX_EPOCH_JD_DAY, UNIX_EPOCH_MJD_DAY, UTC_DEFINED_FROM_MJD_DAY, }; +pub use foundation::duration::{DurationError, ExactDuration, NANOS_PER_SECOND}; pub use foundation::error::{ConversionError, TimeDataError}; -pub use model::scale::{ContinuousScale, CoordinateScale, Scale, TAI, TCB, TCG, TDB, TT, UT1, UTC}; +pub use model::scale::{ + ContinuousScale, CoordinateScale, Scale, BDT, ET, GPST, GST, QZSST, TAI, TCB, TCG, TDB, TT, + UT1, UTC, +}; pub use model::target::{ContextConversionTarget, ConversionTarget, InfallibleConversionTarget}; pub use model::time::Time; -pub use period::{complement_within, Interval, InvalidIntervalError, Period, PeriodListError}; +pub use period::{ + complement_within, series::TimeSeries, series::TimeSeriesError, Interval, InvalidIntervalError, + Period, PeriodListError, +}; pub use tempoch_time_data::generated::{ EOP_END_MJD, EOP_OBSERVED_END_MJD, EOP_START_MJD, MODERN_DELTA_T_OBSERVED_END_MJD, }; diff --git a/tempoch-core/src/model/scale/conversion.rs b/tempoch-core/src/model/scale/conversion.rs index 61c19a0..5d553b8 100644 --- a/tempoch-core/src/model/scale/conversion.rs +++ b/tempoch-core/src/model/scale/conversion.rs @@ -15,7 +15,7 @@ use crate::format::JD; use crate::foundation::constats::{IAU_TIME_EPOCH_T0_JD_DAY, L_B, L_G, TDB0, TT_MINUS_TAI}; use crate::foundation::error::ConversionError; use crate::foundation::sealed::Sealed; -use crate::model::scale::{Scale, TAI, TCB, TCG, TDB, TT, UT1, UTC}; +use crate::model::scale::{Scale, BDT, ET, GPST, GST, QZSST, TAI, TCB, TCG, TDB, TT, UT1, UTC}; use affn::algebra::{AffineMap1, Space, SplitPoint1, SplitQuantity}; use qtty::unit::{Day, Second as SecondUnit}; use qtty::{Day as JdDay, Second}; @@ -102,7 +102,7 @@ macro_rules! identity_infallible { )+ }; } -identity_infallible!(TAI, TT, TDB, TCG, TCB, UTC, UT1); +identity_infallible!(TAI, TT, TDB, TCG, TCB, UTC, UT1, ET, GPST, GST, BDT, QZSST); /// UTC→UTC via context uses the identity mapping so [`ContextScaleConvert`] agrees with /// [`InfallibleScaleConvert`] (needed for [`crate::model::target::Unix`] as a @@ -368,6 +368,169 @@ ut1_through_tt!(TCG); ut1_through_tt!(TCB); ut1_through_tt!(UTC); +// ── ET (NAIF/SPICE compatibility) ──────────────────────────────────────── +// +// ET is implemented as a SPICE-compatibility marker that routes through TDB +// numerically. The split into a distinct scale exists so callers +// interchanging with NAIF/CSPICE can keep their types labelled "ET" without +// converting to "TDB" at the call site. + +impl InfallibleScaleConvert for ET { + #[inline] + fn convert(src_hi: Second, src_lo: Second) -> (Second, Second) { + (src_hi, src_lo) + } +} + +impl InfallibleScaleConvert for TDB { + #[inline] + fn convert(src_hi: Second, src_lo: Second) -> (Second, Second) { + (src_hi, src_lo) + } +} + +macro_rules! et_through_tdb { + ($scale:ty) => { + impl InfallibleScaleConvert<$scale> for ET { + #[inline] + fn convert(src_hi: Second, src_lo: Second) -> (Second, Second) { + >::convert(src_hi, src_lo) + } + } + impl InfallibleScaleConvert for $scale { + #[inline] + fn convert(src_hi: Second, src_lo: Second) -> (Second, Second) { + <$scale as InfallibleScaleConvert>::convert(src_hi, src_lo) + } + } + }; +} +et_through_tdb!(TT); +et_through_tdb!(TAI); +et_through_tdb!(TCG); +et_through_tdb!(TCB); +et_through_tdb!(UTC); + +impl ContextScaleConvert for ET { + #[inline] + fn convert_with( + src_hi: Second, + src_lo: Second, + ctx: &TimeContext, + ) -> Result<(Second, Second), ConversionError> { + let (tdb_hi, tdb_lo) = >::convert(src_hi, src_lo); + >::convert_with(tdb_hi, tdb_lo, ctx) + } +} + +impl ContextScaleConvert for UT1 { + #[inline] + fn convert_with( + src_hi: Second, + src_lo: Second, + ctx: &TimeContext, + ) -> Result<(Second, Second), ConversionError> { + let (tdb_hi, tdb_lo) = + >::convert_with(src_hi, src_lo, ctx)?; + Ok(>::convert(tdb_hi, tdb_lo)) + } +} + +// ── GNSS system times (fixed integer offsets from TAI) ─────────────────── +// +// Nominal offsets: +// GPST = TAI − 19 s (epoch 1980-01-06 UTC) +// GST = TAI − 19 s (epoch 1999-08-22 UTC) +// QZSST = TAI − 19 s (aligned with GPST) +// BDT = TAI − 33 s (epoch 2006-01-01 UTC; equivalently GPST − 14 s) +// +// These are nominal *system* times, not receiver-realized constellation +// times — broadcast inter-system offsets (GGTO, BGTO, …) are not modeled +// at the scale layer. + +/// Nominal `TAI − GPST` offset (19 s). +pub(crate) const TAI_MINUS_GPST: Second = Second::new(19.0); +/// Nominal `TAI − BDT` offset (33 s). +pub(crate) const TAI_MINUS_BDT: Second = Second::new(33.0); + +macro_rules! gnss_via_tai_offset { + ($scale:ty, $offset:expr) => { + impl InfallibleScaleConvert for $scale { + #[inline] + fn convert(src_hi: Second, src_lo: Second) -> (Second, Second) { + // = TAI − offset ⇒ TAI = + offset + add_constant(src_hi, src_lo, $offset) + } + } + impl InfallibleScaleConvert<$scale> for TAI { + #[inline] + fn convert(src_hi: Second, src_lo: Second) -> (Second, Second) { + add_constant(src_hi, src_lo, -$offset) + } + } + }; +} +gnss_via_tai_offset!(GPST, TAI_MINUS_GPST); +gnss_via_tai_offset!(GST, TAI_MINUS_GPST); +gnss_via_tai_offset!(QZSST, TAI_MINUS_GPST); +gnss_via_tai_offset!(BDT, TAI_MINUS_BDT); + +macro_rules! gnss_through_tai { + ($scale:ty, $other:ty) => { + impl InfallibleScaleConvert<$other> for $scale { + #[inline] + fn convert(src_hi: Second, src_lo: Second) -> (Second, Second) { + let (tai_hi, tai_lo) = + <$scale as InfallibleScaleConvert>::convert(src_hi, src_lo); + >::convert(tai_hi, tai_lo) + } + } + }; +} + +// Cross-GNSS conversions and GNSS↔{TT,TDB,TCG,TCB,UTC,ET}. +macro_rules! gnss_all_targets { + ($scale:ty) => { + gnss_through_tai!($scale, TT); + gnss_through_tai!($scale, TDB); + gnss_through_tai!($scale, TCG); + gnss_through_tai!($scale, TCB); + gnss_through_tai!($scale, UTC); + gnss_through_tai!($scale, ET); + // Reverse directions: + gnss_through_tai!(TT, $scale); + gnss_through_tai!(TDB, $scale); + gnss_through_tai!(TCG, $scale); + gnss_through_tai!(TCB, $scale); + gnss_through_tai!(UTC, $scale); + gnss_through_tai!(ET, $scale); + }; +} +gnss_all_targets!(GPST); +gnss_all_targets!(GST); +gnss_all_targets!(QZSST); +gnss_all_targets!(BDT); + +// Cross-GNSS conversions (each direction explicitly). +macro_rules! gnss_cross { + ($a:ty, $b:ty) => { + gnss_through_tai!($a, $b); + gnss_through_tai!($b, $a); + }; +} +gnss_cross!(GPST, GST); +gnss_cross!(GPST, QZSST); +gnss_cross!(GPST, BDT); +gnss_cross!(GST, QZSST); +gnss_cross!(GST, BDT); +gnss_cross!(QZSST, BDT); + +// UT1 ↔ GNSS via TT (re-use the existing ut1_through_tt pattern). +ut1_through_tt!(GPST); +ut1_through_tt!(GST); +ut1_through_tt!(QZSST); +ut1_through_tt!(BDT); + #[cfg(test)] mod tests { use super::*; diff --git a/tempoch-core/src/model/scale/mod.rs b/tempoch-core/src/model/scale/mod.rs index a820ec3..1153ff3 100644 --- a/tempoch-core/src/model/scale/mod.rs +++ b/tempoch-core/src/model/scale/mod.rs @@ -150,6 +150,63 @@ define_scale!( UT1 = "UT1" ); +define_scale!( + /// NAIF/SPICE Ephemeris Time — compatibility marker. + /// + /// `ET` exists so users interchanging values with NAIF/CSPICE can keep the + /// scale label distinct in their typed code. The current implementation + /// routes `ET ↔ {TT, TDB, …}` through the same Fairhead–Bretagnon model + /// used for TDB: numerically `Time` is identical to `Time` within + /// the documented model accuracy (~10 µs over 1600–2200 TT). + /// + /// Treat this as a SPICE-compatibility *label*, not a new physical + /// realization. A future high-precision SPICE-equivalent ET implementation + /// can replace the conversion without breaking callers that already use + /// `Time` versus `Time`. + ET = "ET" +); + +define_scale!( + /// GPS System Time. `GPST = TAI − 19 s` (nominal, exact integer offset). + /// + /// Epoch: 1980-01-06 00:00:00 UTC. Continuous SI-second clock with no + /// leap seconds. This marker represents the *nominal* GPS system time, + /// not the broadcast/realized GPST observed by a particular receiver + /// (which includes ionospheric/clock model corrections that are not part + /// of the scale itself). + /// + /// Week-number / seconds-of-week is a *format* concern (see + /// `format::markers`) and is intentionally separated from scale identity. + GPST = "GPST" +); + +define_scale!( + /// Galileo System Time. Nominally `GST = TAI − 19 s` (identical tick rate + /// and integer offset to [`GPST`] today; broadcast inter-system offset + /// GGTO is *not* modeled here). + /// + /// Epoch: 1999-08-22 00:00:00 UTC. See [`GPST`] notes on nominal vs. + /// broadcast realization. + GST = "GST" +); + +define_scale!( + /// BeiDou Navigation Satellite System Time. `BDT = TAI − 33 s` (nominal, + /// exact integer offset). Equivalently `BDT = GPST − 14 s`. + /// + /// Epoch: 2006-01-01 00:00:00 UTC. See [`GPST`] notes on nominal vs. + /// broadcast realization (BeiDou/GPS offset is broadcast separately and + /// not part of the scale). + BDT = "BDT" +); + +define_scale!( + /// Quasi-Zenith Satellite System Time. Nominally aligned with [`GPST`] + /// (`QZSST = TAI − 19 s`). The QZSS ICD defines QZSST as steered to GPST; + /// observed inter-system offsets are not part of the scale. + QZSST = "QZSST" +); + // ── ContinuousScale witness ────────────────────────────────────────────── /// Witness that a scale is continuous and supports direct arithmetic. @@ -166,7 +223,7 @@ macro_rules! coordinate { $(impl CoordinateScale for $scale {})+ }; } -coordinate!(TAI, TT, TDB, TCG, TCB, UT1, UTC); +coordinate!(TAI, TT, TDB, TCG, TCB, UT1, UTC, ET, GPST, GST, BDT, QZSST); /// Witness that a scale is both coordinate-bearing and physically continuous. /// @@ -179,4 +236,4 @@ macro_rules! continuous { $(impl ContinuousScale for $scale {})+ }; } -continuous!(TAI, TT, TDB, TCG, TCB, UT1); +continuous!(TAI, TT, TDB, TCG, TCB, UT1, ET, GPST, GST, BDT, QZSST); diff --git a/tempoch-core/src/model/target.rs b/tempoch-core/src/model/target.rs index 9847025..b820b10 100644 --- a/tempoch-core/src/model/target.rs +++ b/tempoch-core/src/model/target.rs @@ -13,7 +13,9 @@ use crate::format::TimeFormat; use crate::foundation::error::ConversionError; use crate::foundation::sealed::Sealed; use crate::model::scale::conversion::{ContextScaleConvert, InfallibleScaleConvert}; -use crate::model::scale::{CoordinateScale, Scale, TAI, TCB, TCG, TDB, TT, UT1, UTC}; +use crate::model::scale::{ + CoordinateScale, Scale, BDT, ET, GPST, GST, QZSST, TAI, TCB, TCG, TDB, TT, UT1, UTC, +}; use crate::model::time::Time; /// Unified conversion target for `Time::try_to::()`. @@ -144,12 +146,22 @@ default_context_scale_target!(TDB => UT1); default_context_scale_target!(TCG => UT1); default_context_scale_target!(TCB => UT1); default_context_scale_target!(UTC => UT1); +default_context_scale_target!(ET => UT1); +default_context_scale_target!(GPST => UT1); +default_context_scale_target!(GST => UT1); +default_context_scale_target!(QZSST => UT1); +default_context_scale_target!(BDT => UT1); default_context_scale_target!(UT1 => TT); default_context_scale_target!(UT1 => TAI); default_context_scale_target!(UT1 => TDB); default_context_scale_target!(UT1 => TCG); default_context_scale_target!(UT1 => TCB); default_context_scale_target!(UT1 => UTC); +default_context_scale_target!(UT1 => ET); +default_context_scale_target!(UT1 => GPST); +default_context_scale_target!(UT1 => GST); +default_context_scale_target!(UT1 => QZSST); +default_context_scale_target!(UT1 => BDT); impl, SrcF: TimeFormat> ConversionTarget for Unix { type Output = Time; diff --git a/tempoch-core/src/model/time.rs b/tempoch-core/src/model/time.rs index 3e0d12e..b460697 100644 --- a/tempoch-core/src/model/time.rs +++ b/tempoch-core/src/model/time.rs @@ -265,6 +265,122 @@ impl> Time { } } +impl Time { + /// Exact-precision duration from `other` to `self`. + /// + /// Unlike the [`Sub`] implementation that returns a `Quantity` + /// (and therefore goes through `f64`), this method projects the difference + /// into [`crate::ExactDuration`], which has 1 ns resolution. + /// + /// **Precision note:** `Time` stores instants as a compensated split-f64 + /// pair. Near typical astronomy epochs (e.g. J2000 ± 50 years) the ULP of + /// the high word is roughly 120–150 ns, so differences smaller than that + /// may not round-trip exactly. For sub-microsecond precision on two instants + /// that were originally constructed from the same `ExactDuration` arithmetic, + /// the compensation pair reduces the error significantly, but this is not a + /// guarantee of nanosecond parity for arbitrary instants. + /// + /// Returns [`crate::DurationError::Overflow`] only if the difference is + /// outside the i128-nanosecond range (≈ ±5.4 × 10²¹ yr), which is unreachable + /// for any physical astronomy use case. + #[inline] + pub fn diff_exact(self, other: Self) -> Result { + let delta: Second = self.instant - other.instant; + crate::ExactDuration::try_from_quantity(delta) + } + + /// Shift this instant by an [`crate::ExactDuration`], returning `Err` if the + /// duration's seconds component exceeds the `i64` range (≈ ±292 billion years). + /// + /// **Precision note:** The duration is split into a whole-second component and + /// a sub-second nanosecond remainder, each added to the compensated split-f64 + /// storage separately. The whole-second part is an integer `f64` (exact for + /// `|seconds| < 2^53`). The nanosecond remainder crosses the split-f64 storage + /// boundary and is therefore bounded by the documented split-f64 precision + /// limits (ULP ≈ 120–150 ns near J2000 ± 50 years), so shifts smaller than + /// that threshold may not alter the stored instant. + #[inline] + pub fn try_add_exact( + self, + delta: crate::ExactDuration, + ) -> Result { + let (whole_secs, sub_nanos) = delta.as_seconds_i64_nanos_checked()?; + let t = self.instant + Second::new(whole_secs as f64); + Ok(Self { + instant: t + Second::new(sub_nanos as f64 * 1e-9), + _fmt: PhantomData, + }) + } + + /// Shift this instant backward by an [`crate::ExactDuration`], returning `Err` + /// if the duration's seconds component exceeds the `i64` range. + /// + /// See [`Self::try_add_exact`] for precision notes. + #[inline] + pub fn try_sub_exact( + self, + delta: crate::ExactDuration, + ) -> Result { + let (whole_secs, sub_nanos) = delta.as_seconds_i64_nanos_checked()?; + let t = self.instant - Second::new(whole_secs as f64); + Ok(Self { + instant: t - Second::new(sub_nanos as f64 * 1e-9), + _fmt: PhantomData, + }) + } + + /// Shift this instant by an [`crate::ExactDuration`]. + /// + /// **Panics** if the duration's seconds component exceeds the `i64` range + /// (≈ ±292 billion years). Use [`try_add_exact`](Self::try_add_exact) for + /// the fallible variant that returns `Err` instead. + /// + /// See [`try_add_exact`](Self::try_add_exact) for precision notes. + #[inline] + pub fn add_exact(self, delta: crate::ExactDuration) -> Self { + self.try_add_exact(delta) + .expect("ExactDuration::add_exact: duration exceeds i64 seconds range") + } + + /// Shift this instant backward by an [`crate::ExactDuration`]. + /// + /// **Panics** if the duration's seconds component exceeds the `i64` range. + /// Use [`try_sub_exact`](Self::try_sub_exact) for the fallible variant. + /// + /// See [`try_add_exact`](Self::try_add_exact) for precision notes. + #[inline] + pub fn sub_exact(self, delta: crate::ExactDuration) -> Self { + self.try_sub_exact(delta) + .expect("ExactDuration::sub_exact: duration exceeds i64 seconds range") + } + + /// Round this instant to the nearest multiple of `quantum` measured from + /// `epoch`. Banker's rounding (half-to-even) at the quantum boundary. + /// Returns `self` unchanged on overflow. + pub fn round_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self { + match self.diff_exact(epoch) { + Ok(d) => epoch.add_exact(d.round_to(quantum)), + Err(_) => self, + } + } + + /// Floor this instant toward `epoch − ∞` at `quantum`. + pub fn floor_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self { + match self.diff_exact(epoch) { + Ok(d) => epoch.add_exact(d.floor_to(quantum)), + Err(_) => self, + } + } + + /// Ceil this instant toward `epoch + ∞` at `quantum`. + pub fn ceil_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self { + match self.diff_exact(epoch) { + Ok(d) => epoch.add_exact(d.ceil_to(quantum)), + Err(_) => self, + } + } +} + impl Time where F: FormatForScale, @@ -511,3 +627,91 @@ where *self = *self - rhs; } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::format::J2000s; + use crate::foundation::duration::ExactDuration; + use crate::model::scale::TAI; + + type TaiJ2000 = Time; + + fn j2000_tai() -> TaiJ2000 { + TaiJ2000::from_raw_j2000_seconds(Second::new(0.0)).unwrap() + } + + fn j2000_tai_plus_50yr() -> TaiJ2000 { + // 50 Julian years = 50 * 365.25 * 86400 = 1_577_836_800 s + TaiJ2000::from_raw_j2000_seconds(Second::new(1_577_836_800.0)).unwrap() + } + + #[test] + fn add_exact_1ns_at_j2000() { + let t = j2000_tai(); + let d = ExactDuration::from_nanos(1); + let shifted = t.add_exact(d); + let diff = shifted.diff_exact(t).unwrap(); + // Near J2000 hi ≈ 0; lo stores the 1 ns shift exactly. + assert_eq!(diff.as_nanos_i128(), 1, "1 ns shift at J2000 must be exact"); + } + + #[test] + fn add_sub_round_trip_1ns_at_j2000_plus_50yr() { + let t = j2000_tai_plus_50yr(); + // 1 ns: ULP of hi at 1.57e9 s is ~240 ns, so the lo word carries it. + for ns in [1_i128, 123, 999] { + let d = ExactDuration::from_nanos(ns); + let shifted = t.add_exact(d).sub_exact(d); + let back = shifted.diff_exact(t).unwrap(); + assert!( + back.as_nanos_i128().abs() < 100, + "add/sub round-trip drift at J2000+50yr for {ns} ns: {} ns", + back.as_nanos_i128() + ); + } + } + + #[test] + fn add_exact_1yr_plus_1ns_preserves_1ns() { + let t = j2000_tai(); + // 1 Julian year = 31_557_600 s + let one_year = ExactDuration::from_nanos(31_557_600 * 1_000_000_000); + let one_ns = ExactDuration::from_nanos(1); + let combined = (one_year + one_ns) + .checked_add(ExactDuration::ZERO) + .unwrap(); + let d_year = t.add_exact(one_year); + let d_combined = t.add_exact(combined); + let diff = d_combined.diff_exact(d_year).unwrap(); + // The difference should be 1 ns; allow up to 2 ns for sub-nanosecond f64 rounding. + assert!( + diff.as_nanos_i128().abs() <= 2, + "1 yr + 1 ns shift must preserve 1 ns component; diff = {} ns", + diff.as_nanos_i128() + ); + } + + #[test] + fn try_add_exact_overflow_returns_err() { + let t = j2000_tai(); + // ExactDuration::MAX has > i64::MAX seconds → try_add_exact must return Err. + let result = t.try_add_exact(ExactDuration::MAX); + assert!( + result.is_err(), + "expected Err for try_add_exact(MAX), got Ok" + ); + let result2 = t.try_sub_exact(ExactDuration::MAX); + assert!( + result2.is_err(), + "expected Err for try_sub_exact(MAX), got Ok" + ); + } + + #[test] + #[should_panic(expected = "ExactDuration::add_exact")] + fn add_exact_panics_on_overflow() { + let t = j2000_tai(); + let _ = t.add_exact(ExactDuration::MAX); + } +} diff --git a/tempoch-core/src/period/mod.rs b/tempoch-core/src/period/mod.rs index 02705ba..fcb52c0 100644 --- a/tempoch-core/src/period/mod.rs +++ b/tempoch-core/src/period/mod.rs @@ -13,7 +13,9 @@ use core::fmt; use crate::Time; mod error; +pub mod series; pub use error::{InvalidIntervalError, PeriodListError}; +pub use series::{TimeSeries, TimeSeriesError}; #[inline] fn partial_max(a: T, b: T) -> T { diff --git a/tempoch-core/src/period/series.rs b/tempoch-core/src/period/series.rs new file mode 100644 index 0000000..aa317b2 --- /dev/null +++ b/tempoch-core/src/period/series.rs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! [`TimeSeries`] — exact-step iterator over `Time`. +//! +//! Generates a uniform sequence of typed instants over a half-open range +//! `[start, end)` with an [`crate::ExactDuration`] step. The step is exact in +//! nanoseconds; the produced `Time` values inherit the split-f64 storage of +//! `Time` (see `foundation::duration` for the W1 caveat that exactness +//! lives in the duration container, not in instant storage). +//! +//! # Examples +//! +//! ``` +//! use tempoch_core::{ExactDuration, Time, TimeSeries, TT}; +//! use qtty::Second; +//! +//! let start = Time::::from_raw_j2000_seconds(Second::new(0.0)).unwrap(); +//! let end = Time::::from_raw_j2000_seconds(Second::new(10.0)).unwrap(); +//! let series = TimeSeries::new(start, end, ExactDuration::SECOND).unwrap(); +//! assert_eq!(series.count(), 10); +//! ``` + +use crate::format::TimeFormat; +use crate::foundation::duration::{DurationError, ExactDuration}; +use crate::model::scale::CoordinateScale; +use crate::model::time::Time; + +/// Error returned when a [`TimeSeries`] cannot be constructed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TimeSeriesError { + /// Step was zero — the iterator would not terminate. + ZeroStep, + /// `end < start` — the half-open range is empty in the forward direction; + /// callers wanting reverse iteration should use [`TimeSeries::new_with_step`] + /// with a negative [`ExactDuration`]. + EmptyForwardRange, + /// The end-start duration overflows the i128 nanosecond representation. + DurationOverflow, +} + +impl core::fmt::Display for TimeSeriesError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::ZeroStep => f.write_str("TimeSeries step must be non-zero"), + Self::EmptyForwardRange => { + f.write_str("TimeSeries::new requires end >= start; use new_with_step with a negative step for descending series") + } + Self::DurationOverflow => { + f.write_str("TimeSeries range exceeds i128 nanosecond capacity") + } + } + } +} + +impl std::error::Error for TimeSeriesError {} + +impl From for TimeSeriesError { + fn from(_: DurationError) -> Self { + Self::DurationOverflow + } +} + +/// Half-open iterator `[start, end)` stepping by an [`ExactDuration`]. +/// +/// Iteration is **deterministic by index**: the `n`th item is +/// `start.add_exact(step * n)` computed in i128 nanoseconds, NOT by repeated +/// addition. This avoids accumulating split-f64 drift over long ranges. +#[derive(Debug, Clone)] +pub struct TimeSeries { + start: Time, + #[allow(dead_code)] + /// Total nanoseconds covered by the half-open range; retained for debug + /// inspection and future range introspection helpers. + span_nanos: i128, + step_nanos: i128, + /// Items already produced. + cursor: u64, + /// Total number of items in this series (precomputed). + len: u64, +} + +impl TimeSeries { + /// Build a forward-stepping series `[start, end)` with positive step. + /// + /// Returns [`TimeSeriesError::EmptyForwardRange`] if `end < start`, + /// [`TimeSeriesError::ZeroStep`] if `step.is_zero()`. + pub fn new( + start: Time, + end: Time, + step: ExactDuration, + ) -> Result { + if step.is_zero() { + return Err(TimeSeriesError::ZeroStep); + } + let span = end.diff_exact(start)?; + let span_nanos = span.as_nanos_i128(); + let step_nanos = step.as_nanos_i128(); + if span_nanos == 0 { + // Empty but valid (zero-length half-open range). + return Ok(Self { + start, + span_nanos: 0, + step_nanos, + cursor: 0, + len: 0, + }); + } + // Forward iteration requires the signs of span and step to agree, and + // the magnitude of |span| / |step| to bound the count. + if span_nanos.signum() != step_nanos.signum() { + return Err(TimeSeriesError::EmptyForwardRange); + } + // Number of items: ceil(|span| / |step|) for a half-open range with + // strict containment of every step beyond start, BUT half-open + // semantics on `end` means we use floor and exclude any final point + // that would land exactly on `end`. Formally: + // n = ceil(span / step) if step doesn't divide span + // n = span / step otherwise (the last sample equals end, excluded) + let len = { + let span_abs = span_nanos.unsigned_abs(); + let step_abs = step_nanos.unsigned_abs(); + let q = span_abs / step_abs; + let r = span_abs % step_abs; + if r == 0 { + if q > u64::MAX as u128 { + return Err(TimeSeriesError::DurationOverflow); + } + q as u64 + } else { + if q >= u64::MAX as u128 { + return Err(TimeSeriesError::DurationOverflow); + } + (q + 1) as u64 + } + }; + Ok(Self { + start, + span_nanos, + step_nanos, + cursor: 0, + len, + }) + } + + /// Build a series allowing reverse iteration via a negative step. + /// Range semantics: items satisfy `step > 0 ⇒ start + k·step < end`, or + /// `step < 0 ⇒ start + k·step > end`. + pub fn new_with_step( + start: Time, + end: Time, + step: ExactDuration, + ) -> Result { + Self::new(start, end, step) + } + + /// Number of items remaining in the series. + #[inline] + pub fn remaining(&self) -> u64 { + self.len.saturating_sub(self.cursor) + } + + /// Total number of items in the series (independent of cursor). + #[inline] + pub fn len_total(&self) -> u64 { + self.len + } + + /// True iff this series has produced all items. + #[inline] + pub fn is_exhausted(&self) -> bool { + self.cursor >= self.len + } + + /// The `n`th item, computed from `start` (NOT by repeated addition). + /// Returns `None` if `n >= len_total()` or if the computed offset overflows + /// the `i64` seconds range (extremely large series only). + pub fn nth_item(&self, n: u64) -> Option> { + if n >= self.len { + return None; + } + let total_nanos = (n as i128).checked_mul(self.step_nanos)?; + self.start + .try_add_exact(ExactDuration::from_nanos(total_nanos)) + .ok() + } +} + +impl Iterator for TimeSeries { + type Item = Time; + + fn next(&mut self) -> Option { + if self.is_exhausted() { + return None; + } + let item = self.nth_item(self.cursor)?; + self.cursor += 1; + Some(item) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.remaining(); + let cap = remaining.min(usize::MAX as u64) as usize; + (cap, Some(cap)) + } + + fn count(self) -> usize { + self.remaining().min(usize::MAX as u64) as usize + } + + fn nth(&mut self, n: usize) -> Option { + self.cursor = self.cursor.saturating_add(n as u64); + self.next() + } +} + +impl ExactSizeIterator for TimeSeries {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Time, TT}; + use qtty::Second; + + fn t(s: f64) -> Time { + Time::::from_raw_j2000_seconds(Second::new(s)).unwrap() + } + + #[test] + fn ten_second_series() { + let s = TimeSeries::new(t(0.0), t(10.0), ExactDuration::SECOND).unwrap(); + assert_eq!(s.len_total(), 10); + assert_eq!(s.count(), 10); + } + + #[test] + fn zero_step_rejected() { + assert!(matches!( + TimeSeries::new(t(0.0), t(10.0), ExactDuration::ZERO), + Err(TimeSeriesError::ZeroStep) + )); + } + + #[test] + fn empty_forward_range_rejected() { + assert!(matches!( + TimeSeries::new(t(10.0), t(0.0), ExactDuration::SECOND), + Err(TimeSeriesError::EmptyForwardRange) + )); + } + + #[test] + fn empty_zero_span_returns_empty() { + let s = TimeSeries::new(t(5.0), t(5.0), ExactDuration::SECOND).unwrap(); + assert_eq!(s.len_total(), 0); + assert_eq!(s.count(), 0); + } + + #[test] + fn half_open_excludes_endpoint() { + let s = TimeSeries::new(t(0.0), t(3.0), ExactDuration::SECOND).unwrap(); + let items: Vec<_> = s.collect(); + assert_eq!(items.len(), 3); + // Last item should be at t=2 s, not t=3. + let last = items.last().unwrap(); + let secs = (last.raw_seconds_pair().0 + last.raw_seconds_pair().1).value(); + assert!((secs - 2.0).abs() < 1e-9); + } + + #[test] + fn non_dividing_step_yields_ceiling_count() { + // [0, 3.5) step 1 s → 4 samples at 0, 1, 2, 3 + let s = TimeSeries::new(t(0.0), t(3.5), ExactDuration::SECOND).unwrap(); + assert_eq!(s.len_total(), 4); + } + + #[test] + fn nth_item_is_deterministic() { + let s = TimeSeries::new(t(0.0), t(100.0), ExactDuration::SECOND).unwrap(); + let got = s.nth_item(50).unwrap(); + let secs = (got.raw_seconds_pair().0 + got.raw_seconds_pair().1).value(); + assert!((secs - 50.0).abs() < 1e-9); + assert!(s.nth_item(100).is_none()); + } + + #[test] + fn reverse_step_iterates_downward() { + let s = + TimeSeries::new_with_step(t(10.0), t(0.0), ExactDuration::from_nanos(-1_000_000_000)) + .unwrap(); + assert_eq!(s.len_total(), 10); + let items: Vec<_> = s.collect(); + let first = items.first().unwrap(); + let last = items.last().unwrap(); + let first_s = (first.raw_seconds_pair().0 + first.raw_seconds_pair().1).value(); + let last_s = (last.raw_seconds_pair().0 + last.raw_seconds_pair().1).value(); + assert!((first_s - 10.0).abs() < 1e-9); + assert!((last_s - 1.0).abs() < 1e-9); + } + + #[test] + fn skip_via_nth() { + let mut s = TimeSeries::new(t(0.0), t(10.0), ExactDuration::SECOND).unwrap(); + let third = s.nth(2).unwrap(); + let secs = (third.raw_seconds_pair().0 + third.raw_seconds_pair().1).value(); + assert!((secs - 2.0).abs() < 1e-9); + } + + /// Iterate 100 items and verify each matches `nth_item` exactly (no drift). + #[test] + fn no_drift_versus_nth_item() { + let series = TimeSeries::new(t(0.0), t(100.0), ExactDuration::SECOND).unwrap(); + let items: Vec<_> = series.collect(); + let fresh = TimeSeries::new(t(0.0), t(100.0), ExactDuration::SECOND).unwrap(); + for (i, item) in items.iter().enumerate() { + let direct = fresh.nth_item(i as u64).unwrap(); + let a = (item.raw_seconds_pair().0 + item.raw_seconds_pair().1).value(); + let b = (direct.raw_seconds_pair().0 + direct.raw_seconds_pair().1).value(); + assert_eq!( + a, b, + "iterator vs nth_item mismatch at index {i}: {a} vs {b}" + ); + } + } + + /// Out-of-bounds `nth_item` returns None. + #[test] + fn nth_item_out_of_bounds_is_none() { + let s = TimeSeries::new(t(0.0), t(10.0), ExactDuration::SECOND).unwrap(); + assert_eq!(s.len_total(), 10); + assert!(s.nth_item(10).is_none(), "expected None at len boundary"); + assert!(s.nth_item(100).is_none(), "expected None well past end"); + } +} diff --git a/tempoch-core/tests/proptests.rs b/tempoch-core/tests/proptests.rs new file mode 100644 index 0000000..6dd80d9 --- /dev/null +++ b/tempoch-core/tests/proptests.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Property tests for W1 ExactDuration and W3 scale-conversion invariants. +//! +//! These tests verify high-level invariants over generated inputs: +//! +//! * `ExactDuration` normalization, sign symmetry, ordering, and +//! `floor/ceil/round` invariants. +//! * `Time → Time → Time` round-trip preservation for every +//! continuous-scale pair (within documented tolerances). +//! * `Time + d - d == Time` (modulo ExactDuration precision). + +use proptest::prelude::*; +use qtty::Second; +use tempoch_core::{ExactDuration, Time, BDT, GPST, GST, QZSST, TAI, TCG, TT}; + +const J2000_SECONDS_RANGE: i64 = 3_155_760_000; // ~100 yr around J2000 + +prop_compose! { + fn arb_nanos()(n in any::()) -> i128 { n as i128 } +} + +prop_compose! { + fn arb_duration()(n in arb_nanos()) -> ExactDuration { + ExactDuration::from_nanos(n) + } +} + +prop_compose! { + fn arb_j2000_seconds()(s in (-J2000_SECONDS_RANGE)..J2000_SECONDS_RANGE) -> Second { + Second::new(s as f64) + } +} + +prop_compose! { + fn arb_tt()(s in arb_j2000_seconds()) -> Time { + Time::::from_raw_j2000_seconds(s).unwrap() + } +} + +prop_compose! { + fn arb_tai()(s in arb_j2000_seconds()) -> Time { + Time::::from_raw_j2000_seconds(s).unwrap() + } +} + +proptest! { + #[test] + fn duration_neg_round_trip(d in arb_duration()) { + if d != ExactDuration::MIN { + prop_assert_eq!(-(-d), d); + } + } + + #[test] + fn duration_add_sub_inverse(a in arb_duration(), b in arb_duration()) { + if let (Ok(sum), Ok(_)) = (a.checked_add(b), b.checked_neg()) { + if let Ok(back) = sum.checked_sub(b) { + prop_assert_eq!(back, a); + } + } + } + + #[test] + fn duration_ordering_matches_nanos(a in arb_duration(), b in arb_duration()) { + prop_assert_eq!(a.cmp(&b), a.as_nanos_i128().cmp(&b.as_nanos_i128())); + } + + #[test] + fn duration_floor_le_ceil(d in arb_duration(), q_nanos in 1_i64..1_000_000_000_000) { + let q = ExactDuration::from_nanos(q_nanos as i128); + let floor = d.floor_to(q); + let ceil = d.ceil_to(q); + prop_assert!(floor.as_nanos_i128() <= d.as_nanos_i128()); + prop_assert!(ceil.as_nanos_i128() >= d.as_nanos_i128()); + // Range bound: ceil - floor is 0 (already aligned) or q. + let span = ceil.as_nanos_i128() - floor.as_nanos_i128(); + prop_assert!(span == 0 || span == q_nanos as i128); + } + + #[test] + fn duration_round_lies_between_floor_and_ceil( + d in arb_duration(), + q_nanos in 1_i64..1_000_000_000_000, + ) { + let q = ExactDuration::from_nanos(q_nanos as i128); + let round = d.round_to(q); + let floor = d.floor_to(q); + let ceil = d.ceil_to(q); + prop_assert!(round == floor || round == ceil); + } + + #[test] + fn time_add_then_sub_preserves_instant( + t in arb_tt(), + d_ns in -1_000_000_000_000_i64..1_000_000_000_000_i64, + ) { + let d = ExactDuration::from_nanos(d_ns as i128); + let shifted = t.add_exact(d).sub_exact(d); + let back = shifted.diff_exact(t).unwrap(); + // Allow 100 ns drift from split-f64 storage round-trip. + prop_assert!(back.as_nanos_i128().abs() < 100, + "add/sub round-trip drift > 100 ns: {} ns", back.as_nanos_i128()); + } + + #[test] + fn tai_round_trip_to_gpst_within_tolerance(t in arb_tai()) { + let gpst = t.to::(); + let back = gpst.to::(); + let d = t.diff_exact(back).unwrap(); + prop_assert!(d.as_nanos_i128().abs() <= 1); + } + + #[test] + fn tai_round_trip_to_bdt_within_tolerance(t in arb_tai()) { + let bdt = t.to::(); + let back = bdt.to::(); + let d = t.diff_exact(back).unwrap(); + prop_assert!(d.as_nanos_i128().abs() <= 1); + } + + #[test] + fn cross_gnss_round_trip_within_tolerance(t in arb_tai()) { + for converted in [ + t.to::().to::().to::().to::().to::(), + t.to::().to::().to::(), + t.to::().to::().to::(), + ] { + let d = t.diff_exact(converted).unwrap(); + prop_assert!(d.as_nanos_i128().abs() <= 10, + "cross-GNSS round trip drift > 10 ns: {} ns", d.as_nanos_i128()); + } + } + + #[test] + fn tt_tcg_round_trip_within_continuous_tolerance(t in arb_tt()) { + let tcg = t.to::(); + let back = tcg.to::(); + let d = t.diff_exact(back).unwrap(); + // TT↔TCG is a tiny linear scaling; for a 100-yr range expect << 1 µs drift. + prop_assert!(d.as_nanos_i128().abs() < 1_000, + "TT↔TCG round trip drift > 1 µs: {} ns", d.as_nanos_i128()); + } +} diff --git a/tempoch-ffi/Cargo.toml b/tempoch-ffi/Cargo.toml index 8731965..de73a3c 100644 --- a/tempoch-ffi/Cargo.toml +++ b/tempoch-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tempoch-ffi" -version = "0.6.1" +version = "0.6.2" edition = "2021" authors = ["VPRamon "] description = "C FFI bindings for tempoch — astronomical time primitives." @@ -13,8 +13,8 @@ name = "tempoch_ffi" crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] -tempoch = { path = "../tempoch", version = "0.6.1" } -qtty = "0.8" +tempoch = { path = "../tempoch", version = "0.6.2" } +qtty = "0.8.4" qtty-ffi = "0.8" chrono = "0.4.44" diff --git a/tempoch-ffi/include/tempoch_ffi.h b/tempoch-ffi/include/tempoch_ffi.h index f0c621f..9c835bf 100644 --- a/tempoch-ffi/include/tempoch_ffi.h +++ b/tempoch-ffi/include/tempoch_ffi.h @@ -20,9 +20,9 @@ // Discriminant values are frozen; new variants may only be added at the end. // enum tempoch_status_t -#ifdef __cplusplus +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L : int32_t -#endif // __cplusplus +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L { // Operation completed successfully. TEMPOCH_STATUS_T_OK = 0, @@ -57,15 +57,19 @@ enum tempoch_status_t TEMPOCH_STATUS_T_INVALID_FORMAT_ID = 12, }; #ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum tempoch_status_t tempoch_status_t; +#else typedef int32_t tempoch_status_t; +#endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus // Format tags used by the split-instant C ABI. // enum tempoch_format_tag_t -#ifdef __cplusplus +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L : int32_t -#endif // __cplusplus +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L { // Julian Day. TEMPOCH_FORMAT_TAG_T_JD = 0, @@ -79,15 +83,19 @@ enum tempoch_format_tag_t TEMPOCH_FORMAT_TAG_T_GPS = 4, }; #ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum tempoch_format_tag_t tempoch_format_tag_t; +#else typedef int32_t tempoch_format_tag_t; +#endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus // Scale tags used by the split-instant C ABI. // enum tempoch_scale_tag_t -#ifdef __cplusplus +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L : int32_t -#endif // __cplusplus +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L { // Terrestrial Time. TEMPOCH_SCALE_TAG_T_TT = 0, @@ -105,7 +113,11 @@ enum tempoch_scale_tag_t TEMPOCH_SCALE_TAG_T_TCB = 6, }; #ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum tempoch_scale_tag_t tempoch_scale_tag_t; +#else typedef int32_t tempoch_scale_tag_t; +#endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus // Opaque FFI time-conversion context. @@ -185,7 +197,7 @@ extern "C" { // Returns the tempoch-ffi ABI version (major*10000 + minor*100 + patch). // -// Current ABI line: 0.6.1 -> 601 +// Current ABI line: 0.6.2 -> 602 uint32_t tempoch_ffi_version(void); // J2000.0 epoch as JD(TT) — 2 451 545.0. diff --git a/tempoch-ffi/src/eop.rs b/tempoch-ffi/src/eop.rs index 2e49a0b..71dfcde 100644 --- a/tempoch-ffi/src/eop.rs +++ b/tempoch-ffi/src/eop.rs @@ -77,12 +77,12 @@ pub unsafe extern "C" fn tempoch_eop_at(mjd_utc: f64, out: *mut TempochEopValues unsafe { *out = TempochEopValues { mjd_utc: v.mjd_utc.value(), - pm_xp_arcsec: v.pm_xp_arcsec.unwrap_or(f64::NAN), - pm_yp_arcsec: v.pm_yp_arcsec.unwrap_or(f64::NAN), + pm_xp_arcsec: v.pm_xp.map(|q| q.value()).unwrap_or(f64::NAN), + pm_yp_arcsec: v.pm_yp.map(|q| q.value()).unwrap_or(f64::NAN), ut1_minus_utc: v.ut1_minus_utc.value(), - lod_milliseconds: v.lod_milliseconds.unwrap_or(f64::NAN), - dx_milliarcsec: v.dx_milliarcsec.unwrap_or(f64::NAN), - dy_milliarcsec: v.dy_milliarcsec.unwrap_or(f64::NAN), + lod_milliseconds: v.lod.map(|q| q.value()).unwrap_or(f64::NAN), + dx_milliarcsec: v.dx.map(|q| q.value()).unwrap_or(f64::NAN), + dy_milliarcsec: v.dy.map(|q| q.value()).unwrap_or(f64::NAN), ut1_observed: v.ut1_observed as u8, }; } diff --git a/tempoch-ffi/src/lib.rs b/tempoch-ffi/src/lib.rs index 01a88e0..626a3e1 100644 --- a/tempoch-ffi/src/lib.rs +++ b/tempoch-ffi/src/lib.rs @@ -52,11 +52,11 @@ pub(crate) use catch_panic; /// Returns the tempoch-ffi ABI version (major*10000 + minor*100 + patch). /// -/// Current ABI line: 0.6.1 -> 601 +/// Current ABI line: 0.6.2 -> 602 #[allow(clippy::erasing_op, clippy::identity_op)] #[no_mangle] pub extern "C" fn tempoch_ffi_version() -> u32 { - 0 * 10000 + 6 * 100 + 1 // 0.6.1 + 0 * 10000 + 6 * 100 + 2 // 0.6.2 } #[cfg(test)] @@ -65,7 +65,7 @@ mod tests { #[test] fn version_returns_expected_value() { - assert_eq!(tempoch_ffi_version(), 601); + assert_eq!(tempoch_ffi_version(), 602); } // ── Layout tests ────────────────────────────────────────────────────── diff --git a/tempoch-time-data/Cargo.toml b/tempoch-time-data/Cargo.toml index 1666ad7..3768284 100644 --- a/tempoch-time-data/Cargo.toml +++ b/tempoch-time-data/Cargo.toml @@ -13,7 +13,7 @@ fetch = ["dep:serde_json", "dep:sha2", "dep:ureq"] [dependencies] chrono = "0.4.43" -qtty = "0.8.1" +qtty = "0.8.4" serde_json = { version = "1", optional = true } sha2 = { version = "0.10", optional = true } ureq = { version = "2.10", optional = true } diff --git a/tempoch-validation/Cargo.toml b/tempoch-validation/Cargo.toml new file mode 100644 index 0000000..e70700e --- /dev/null +++ b/tempoch-validation/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tempoch-validation" +version = "0.1.0" +edition = "2021" +authors = ["VPRamon "] +description = "Cross-validation harness for tempoch (SOFA/CSPICE/IERS/GNSS-ICD goldens)." +license = "AGPL-3.0-only" +publish = false + +[features] +default = [] +# Opt-in: regenerate committed CSVs from external toolchains (CSPICE/SOFA); +# normal `cargo test` consumes the checked-in CSVs and never touches these. +regenerate = [] + +[lib] +path = "src/lib.rs" + +[dependencies] +tempoch = { path = "../tempoch", version = "0.6.2" } +qtty = { version = "0.8" } diff --git a/tempoch-validation/data/gnss/README.md b/tempoch-validation/data/gnss/README.md new file mode 100644 index 0000000..7963128 --- /dev/null +++ b/tempoch-validation/data/gnss/README.md @@ -0,0 +1,29 @@ +# GNSS ICD reference points + +This directory contains check points derived from the published ICDs: + +- `gps_epoch.csv` — GPS week 0, second 0 = 1980-01-06 00:00:00 UTC. + Source: IS-GPS-200, Rev. R, §3.3.4 (System Time). +- `bdt_epoch.csv` — BDT week 0, second 0 = 2006-01-01 00:00:00 UTC. + Source: BeiDou ICD-OS Version 2.1, §5.2. +- `galileo_epoch.csv` — GST week 0, second 0 = 1999-08-22 00:00:00 UTC + (i.e. midnight between Saturday and Sunday). Source: OS-SIS-ICD Issue 2.1, + §5.1.2. +- `gps_week_rollover.csv` — week 1024 rollover (1999-08-22 → 1999-08-22 UTC) and + week 2048 rollover (2019-04-07 → 2019-04-07 UTC). Source: IS-GPS-200. + +CSV schema (all GNSS epoch files): + +``` +label,scale,utc_iso,tai_minus_utc_s,nominal_tai_minus_scale_s +``` + +- `label` — human-readable description of the check point. +- `scale` — `GPST`, `GST`, `BDT`, or `QZSST`. +- `utc_iso` — civil UTC label (ISO 8601 `YYYY-MM-DDTHH:MM:SSZ`). +- `tai_minus_utc_s` — leap seconds in effect at this UTC instant (per the + IERS UTC-TAI history). +- `nominal_tai_minus_scale_s` — nominal fixed offset (19 s for GPST/GST/QZSST, + 33 s for BDT). + +All values are exact integers per the ICDs at the respective epochs. diff --git a/tempoch-validation/data/gnss/epochs.csv b/tempoch-validation/data/gnss/epochs.csv new file mode 100644 index 0000000..26b8de7 --- /dev/null +++ b/tempoch-validation/data/gnss/epochs.csv @@ -0,0 +1,7 @@ +label,scale,utc_iso,tai_minus_utc_s,nominal_tai_minus_scale_s +gps_week_0_second_0,GPST,1980-01-06T00:00:00Z,19,19 +galileo_week_0_second_0,GST,1999-08-22T00:00:00Z,32,19 +beidou_week_0_second_0,BDT,2006-01-01T00:00:00Z,33,33 +qzss_aligned_with_gpst,QZSST,2010-09-11T00:00:00Z,34,19 +gps_week_1024_rollover,GPST,1999-08-22T00:00:00Z,32,19 +gps_week_2048_rollover,GPST,2019-04-07T00:00:00Z,37,19 diff --git a/tempoch-validation/data/gnss/epochs.csv.toml b/tempoch-validation/data/gnss/epochs.csv.toml new file mode 100644 index 0000000..9a085af --- /dev/null +++ b/tempoch-validation/data/gnss/epochs.csv.toml @@ -0,0 +1,18 @@ +# GNSS epochs CSV provenance + +source = "ICD-compiled reference points" +generated_by = "hand-curated from public ICDs (no third-party toolchain required)" +generation_command = "see data/gnss/README.md" +license = "ICDs are publicly published; this CSV records facts and is reusable under AGPL-3.0-only as part of tempoch-validation" +last_reviewed = "2026-05-27" + +[icds] +gps = "IS-GPS-200, Rev. R" +galileo = "Galileo OS-SIS-ICD Issue 2.1" +beidou = "BeiDou ICD-OS Version 2.1" +qzss = "QZSS IS-QZSS-PNT-005" + +[tolerance] +# Nominal integer-offset relationships are exact per the ICDs; allow 1 ns +# numerical noise from f64 round-trip through split storage on Time. +gnss_tai_offset_ns = 1 diff --git a/tempoch-validation/src/lib.rs b/tempoch-validation/src/lib.rs new file mode 100644 index 0000000..0d3166e --- /dev/null +++ b/tempoch-validation/src/lib.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! tempoch-validation — cross-validation harness. +//! +//! This crate is **dev/test-only** (`publish = false`). It holds committed +//! reference datasets and compares them against `tempoch-core` to catch +//! regressions. +//! +//! ## Currently committed datasets +//! +//! * GNSS ICD reference points (epochs, week-rollover, seconds-of-week edges) +//! under `data/gnss/`. Validated by `tests/gnss_icd.rs`. +//! +//! ## Planned future datasets (not yet committed) +//! +//! * Golden vectors from CSPICE/NAIF (ET/UTC) — will live under +//! `data/spice/`. Regeneration will require CSPICE and will be gated behind +//! a `regenerate` feature; normal `cargo test` will consume the +//! checked-in CSVs once they are added. +//! * Golden vectors from SOFA/ERFA (UTC/TAI/TT/UT1) — will live under +//! `data/sofa/`. +//! * IERS/USNO EOP and ΔT reference samples. +//! +//! `cargo test` requires no external tools and runs only on committed data. + +/// Tolerance budgets, documented per conversion class. +pub mod tolerance { + /// Two continuous SI-second scales (e.g. TAI↔TT): exact integer offset. + pub const CONTINUOUS_OFFSET_NS: i128 = 1; // 1 ns + /// TT↔TDB via Fairhead-Bretagnon: ~10 µs over 1600-2200 TT. + pub const TT_TDB_NS: i128 = 10_000; + /// UTC↔TAI: exact at integer-leap boundaries; allow 1 ns numerical noise. + pub const UTC_TAI_NS: i128 = 1; + /// UT1↔TT via bundled monthly ΔT: documented at <15 ms over observed + /// overlap. + pub const UT1_TT_MS: f64 = 15.0; + /// GNSS system-time integer offsets vs TAI: exact. + pub const GNSS_TAI_NS: i128 = 1; +} diff --git a/tempoch-validation/tests/gnss_icd.rs b/tempoch-validation/tests/gnss_icd.rs new file mode 100644 index 0000000..e57e894 --- /dev/null +++ b/tempoch-validation/tests/gnss_icd.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! GNSS ICD reference-point tests. +//! +//! Verifies that tempoch's GNSS scale markers reproduce the integer +//! TAI−scale offsets documented in each constellation's ICD at the +//! reference epochs given in `data/gnss/epochs.csv`. + +use std::fs; +use std::path::PathBuf; + +use qtty::Second; +use tempoch::{ConversionError, ExactDuration, Time, BDT, GPST, GST, QZSST, TAI, UTC}; +use tempoch_validation::tolerance::GNSS_TAI_NS; + +fn data_path() -> PathBuf { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("data/gnss/epochs.csv"); + p +} + +#[derive(Debug)] +struct Row { + label: String, + scale: String, + utc_iso: String, + nominal_tai_minus_scale_s: f64, +} + +fn parse_rows() -> Vec { + let text = fs::read_to_string(data_path()).expect("read gnss epochs.csv"); + let mut rows = Vec::new(); + for (i, line) in text.lines().enumerate() { + if i == 0 || line.trim().is_empty() { + continue; + } + let cols: Vec<&str> = line.split(',').collect(); + assert_eq!(cols.len(), 5, "bad csv row {i}: {line}"); + rows.push(Row { + label: cols[0].to_string(), + scale: cols[1].to_string(), + utc_iso: cols[2].to_string(), + nominal_tai_minus_scale_s: cols[4].parse().expect("offset"), + }); + } + rows +} + +/// Construct a `Time` at an arbitrary J2000-second offset; the absolute +/// epoch does not matter for verifying that the *offset* between TAI and a +/// GNSS scale matches the ICD-documented integer constant. The conversion +/// matrix is the same at every instant for a fixed-offset scale. +fn sample_tai() -> Time { + Time::::from_raw_j2000_seconds(Second::new(1_234_567.0)).unwrap() +} + +fn offset_ns(scale: &str) -> i128 { + let tai = sample_tai(); + let tai_secs = (tai.raw_seconds_pair().0 + tai.raw_seconds_pair().1).value(); + let scale_secs = match scale { + "GPST" => { + let s = tai.to::(); + (s.raw_seconds_pair().0 + s.raw_seconds_pair().1).value() + } + "GST" => { + let s = tai.to::(); + (s.raw_seconds_pair().0 + s.raw_seconds_pair().1).value() + } + "QZSST" => { + let s = tai.to::(); + (s.raw_seconds_pair().0 + s.raw_seconds_pair().1).value() + } + "BDT" => { + let s = tai.to::(); + (s.raw_seconds_pair().0 + s.raw_seconds_pair().1).value() + } + other => panic!("unknown scale {other}"), + }; + let delta_s = tai_secs - scale_secs; + ExactDuration::from_seconds_f64_lossy(delta_s) + .expect("finite delta") + .as_nanos_i128() +} + +/// Convert UTC `Time` to GNSS week components, dispatching on the scale label. +fn to_gnss_week(utc: Time, scale: &str) -> Result { + match scale { + "GPST" => utc.to::().to_gnss_week(), + "GST" => utc.to::().to_gnss_week(), + "QZSST" => utc.to::().to_gnss_week(), + "BDT" => utc.to::().to_gnss_week(), + other => panic!("unknown scale {other}"), + } +} + +#[test] +fn icd_integer_offsets_match() { + let rows = parse_rows(); + assert!(!rows.is_empty(), "must have ICD reference rows"); + for row in &rows { + let got_ns = offset_ns(&row.scale); + let expected_ns = (row.nominal_tai_minus_scale_s * 1e9) as i128; + let drift = (got_ns - expected_ns).abs(); + assert!( + drift <= GNSS_TAI_NS, + "{}: TAI - {} = {} ns, expected {} ns (drift {} ns)", + row.label, + row.scale, + got_ns, + expected_ns, + drift + ); + } +} + +/// For each row that marks a constellation epoch (week 0 / second 0), verify +/// that parsing the UTC ISO timestamp and converting to the appropriate GNSS +/// scale yields exactly (week=0, sow=0, ns=0). +#[test] +fn epoch_utc_parses_to_week_zero_second_zero() { + let rows = parse_rows(); + // Filter to epoch rows only (label contains "week_0_second_0"). + let epoch_rows: Vec<_> = rows + .iter() + .filter(|r| r.label.contains("week_0_second_0")) + .collect(); + assert!( + !epoch_rows.is_empty(), + "no epoch rows found in gnss epochs.csv" + ); + for row in epoch_rows { + let utc = Time::::parse_rfc3339(&row.utc_iso).unwrap_or_else(|e| { + panic!( + "failed to parse utc_iso '{}' for {}: {e:?}", + row.utc_iso, row.label + ) + }); + let gw = to_gnss_week(utc, &row.scale) + .unwrap_or_else(|e| panic!("to_gnss_week failed for {}: {e:?}", row.label)); + assert_eq!( + gw.week.value(), + 0, + "{}: expected week=0, got {}", + row.label, + gw.week.value() + ); + assert_eq!( + gw.seconds_of_week.value(), + 0, + "{}: expected sow=0, got {}", + row.label, + gw.seconds_of_week.value() + ); + assert_eq!( + gw.subsecond_nanos.value(), + 0, + "{}: expected ns=0, got {}", + row.label, + gw.subsecond_nanos.value() + ); + } +} + +/// For rollover rows, verify the UTC ISO timestamp yields the expected *full* +/// week number with no error. +/// +/// [`to_gnss_week`] returns the full week count since the constellation epoch +/// (no modulo applied). GPS week boundaries do **not** coincide with UTC +/// midnight because GPST = TAI − 19 s and TAI−UTC ≠ 19 s outside the GPS +/// epoch; `sow ≠ 0` at UTC midnight at rollover dates. +#[test] +fn rollover_utc_parses_to_expected_week() { + let rows = parse_rows(); + for row in &rows { + let expected_week: Option = if row.label == "gps_week_1024_rollover" { + Some(1024) // 1999-08-22T00:00:00 UTC → GPST full week 1024 + } else if row.label == "gps_week_2048_rollover" { + Some(2048) // 2019-04-07T00:00:00 UTC → GPST full week 2048 + } else { + None + }; + let Some(expected_week) = expected_week else { + continue; + }; + let utc = Time::::parse_rfc3339(&row.utc_iso).unwrap_or_else(|e| { + panic!( + "failed to parse utc_iso '{}' for {}: {e:?}", + row.utc_iso, row.label + ) + }); + let gw = to_gnss_week(utc, &row.scale) + .unwrap_or_else(|e| panic!("to_gnss_week failed for {}: {e:?}", row.label)); + assert_eq!( + gw.week.value(), + expected_week, + "{}: expected full week={expected_week}, got {}", + row.label, + gw.week.value() + ); + // Sanity: sow must be strictly less than one full week. + assert!( + gw.seconds_of_week.value() < 604_800, + "{}: sow {} out of range", + row.label, + gw.seconds_of_week.value() + ); + } +} + +#[test] +fn bdt_minus_gpst_is_minus_14_s_per_icd() { + let gpst = Time::::from_raw_j2000_seconds(Second::new(0.0)).unwrap(); + let bdt = gpst.to::(); + let delta_s = (gpst.raw_seconds_pair().0 + gpst.raw_seconds_pair().1).value() + - (bdt.raw_seconds_pair().0 + bdt.raw_seconds_pair().1).value(); + let drift_ns = ExactDuration::from_seconds_f64_lossy(delta_s) + .unwrap() + .as_nanos_i128() + - 14_000_000_000; + assert!( + drift_ns.abs() <= GNSS_TAI_NS, + "BDT - GPST should be -14 s; drift {} ns", + drift_ns + ); +} + +#[test] +fn gpst_round_trip_to_tai_is_exact_within_tolerance() { + let original = sample_tai(); + let round = original.to::().to::(); + let d = original.diff_exact(round).unwrap(); + assert!( + d.as_nanos_i128().abs() <= GNSS_TAI_NS, + "GPST round-trip drift > {} ns: {} ns", + GNSS_TAI_NS, + d.as_nanos_i128() + ); +} diff --git a/tempoch/Cargo.toml b/tempoch/Cargo.toml index 7151c29..ab10859 100644 --- a/tempoch/Cargo.toml +++ b/tempoch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tempoch" -version = "0.6.1" +version = "0.6.2" edition = "2021" authors = ["VPRamon "] description = "Astronomical time primitives: typed time scales, Julian dates, UTC conversion, and interval operations." @@ -20,11 +20,11 @@ serde = ["tempoch-core/serde"] runtime-data-fetch = ["tempoch-core/runtime-data-fetch"] [dependencies] -tempoch-core = { path = "../tempoch-core", version = "0.6.1" } +tempoch-core = { path = "../tempoch-core", version = "0.6.2" } [dev-dependencies] chrono = "0.4.44" -qtty = "0.8" +qtty = "0.8.4" serde_json = "1" [[example]] diff --git a/tempoch/src/lib.rs b/tempoch/src/lib.rs index 69e9d0e..0ec4190 100644 --- a/tempoch/src/lib.rs +++ b/tempoch/src/lib.rs @@ -9,24 +9,27 @@ //! - [`Scale`] markers such as [`TT`], [`TAI`], [`UTC`], and [`UT1`] //! - unified conversion targets via `time.to::()`, `try_to`, and //! `to_with` -//! - [`constats`] for epoch [`Time`] helpers plus [`qtty::Day`] / [`qtty::Second`] +//! - [`constats`] for epoch [`Time`] helpers plus `qtty::Day` / `qtty::Second` //! scratch constants pub use tempoch_core::{ - complement_within, constats, delta_t_seconds, delta_t_seconds_extrapolated, eop, - gps_epoch_jd_tai, gps_epoch_jd_utc, gps_epoch_tai, iau_time_epoch_t0_jd, j2000_jd_tt, - tdb_tt_model_high_accuracy_end_jd, tdb_tt_model_high_accuracy_start_jd, unix_epoch_jd, - unix_epoch_mjd, utc_defined_from_mjd, ContextConversionTarget, ContinuousScale, - ConversionError, ConversionTarget, CoordinateScale, FormatForScale, GpsTime, + assert_time_data_fresh, complement_within, constats, delta_t_seconds, + delta_t_seconds_extrapolated, eop, gps_epoch_jd_tai, gps_epoch_jd_utc, gps_epoch_tai, + iau_time_epoch_t0_jd, j2000_jd_tt, tdb_tt_model_high_accuracy_end_jd, + tdb_tt_model_high_accuracy_start_jd, time_data_provenance, unix_epoch_jd, unix_epoch_mjd, + utc_defined_from_mjd, ContextConversionTarget, ContinuousScale, ConversionError, + ConversionTarget, CoordinateScale, DataHorizons, DurationError, ExactDuration, FormatForScale, + FormatOptions, FormatPrecision, FreshnessError, GnssWeek, GnssWeekScale, GpsTime, InfallibleConversionTarget, InfallibleFormatForScale, Interval, InvalidIntervalError, - J2000Seconds, J2000s, JulianDate, ModifiedJulianDate, Period, PeriodListError, Scale, Time, - TimeContext, TimeDataError, TimeFormat, TimeInstant, Unix, UnixTime, - DELTA_T_PREDICTION_HORIZON_MJD, EOP_END_MJD, EOP_OBSERVED_END_MJD, EOP_START_MJD, GPS, - GPS_EPOCH_JD_TAI_DAY, GPS_EPOCH_JD_UTC_DAY, GPS_EPOCH_TAI_MINUS_UTC, GPS_EPOCH_TAI_SECONDS, + J2000Seconds, J2000s, JulianDate, ModifiedJulianDate, Period, PeriodListError, + ProvenanceSnapshot, Scale, SourceUrls, Time, TimeContext, TimeDataError, TimeFormat, + TimeInstant, TimeSeries, TimeSeriesError, Unix, UnixTime, BDT, DELTA_T_PREDICTION_HORIZON_MJD, + EOP_END_MJD, EOP_OBSERVED_END_MJD, EOP_START_MJD, ET, GPS, GPST, GPS_EPOCH_JD_TAI_DAY, + GPS_EPOCH_JD_UTC_DAY, GPS_EPOCH_TAI_MINUS_UTC, GPS_EPOCH_TAI_SECONDS, GST, IAU_TIME_EPOCH_T0_JD_DAY, J2000_JD_TT_DAY, JD, JULIAN_YEAR_DAYS, MJD, - MODERN_DELTA_T_OBSERVED_END_MJD, TAI, TCB, TCG, TDB, TDB_TT_MODEL_HIGH_ACCURACY_END_JD_DAY, - TDB_TT_MODEL_HIGH_ACCURACY_START_JD_DAY, TT, TT_MINUS_TAI, UNIX_EPOCH_JD_DAY, - UNIX_EPOCH_MJD_DAY, UT1, UTC, UTC_DEFINED_FROM_MJD_DAY, + MODERN_DELTA_T_OBSERVED_END_MJD, NANOS_PER_SECOND, QZSST, TAI, TCB, TCG, TDB, + TDB_TT_MODEL_HIGH_ACCURACY_END_JD_DAY, TDB_TT_MODEL_HIGH_ACCURACY_START_JD_DAY, TT, + TT_MINUS_TAI, UNIX_EPOCH_JD_DAY, UNIX_EPOCH_MJD_DAY, UT1, UTC, UTC_DEFINED_FROM_MJD_DAY, }; /// Historical name for [`Time`] after the format-parameter merge. diff --git a/tempoch/tests/scales_w3.rs b/tempoch/tests/scales_w3.rs new file mode 100644 index 0000000..ed04d9b --- /dev/null +++ b/tempoch/tests/scales_w3.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 Vallés Puig, Ramon + +//! Smoke tests for the new W3 scale markers (ET, GPST, GST, BDT, QZSST). +//! +//! These tests verify the fixed-offset relationships between GNSS system +//! times and TAI, and the ET-TDB identity, without depending on EOP/ΔT data. + +use qtty::Second; +use tempoch::{ExactDuration, Time, BDT, ET, GPST, GST, QZSST, TAI, TDB, TT}; + +#[test] +fn gpst_offset_from_tai_is_19s() { + let tai = Time::::from_raw_j2000_seconds(Second::new(1_000_000.0)).unwrap(); + let gpst = tai.to::(); + let d: ExactDuration = tai.diff_exact(gpst.to::()).unwrap(); + assert_eq!(d.as_nanos_i128(), 0); // round trip exact at integer-s offset + + // Direct check: GPST = TAI - 19 s + let tai_secs = tai.raw_seconds_pair(); + let gpst_secs = gpst.raw_seconds_pair(); + let total_tai = tai_secs.0 + tai_secs.1; + let total_gpst = gpst_secs.0 + gpst_secs.1; + assert!(((total_tai - total_gpst).value() - 19.0).abs() < 1e-9); +} + +#[test] +fn gst_and_qzsst_match_gpst_nominally() { + let tai = Time::::from_raw_j2000_seconds(Second::new(0.0)).unwrap(); + let gpst = tai.to::().to_j2000s(); + let gst = tai.to::().to_j2000s(); + let qzs = tai.to::().to_j2000s(); + assert_eq!(gpst.raw_seconds_pair(), gst.raw_seconds_pair()); + assert_eq!(gpst.raw_seconds_pair(), qzs.raw_seconds_pair()); +} + +#[test] +fn bdt_offset_from_gpst_is_14s() { + let gpst = Time::::from_raw_j2000_seconds(Second::new(0.0)).unwrap(); + let bdt = gpst.to::(); + let g_secs = gpst.raw_seconds_pair(); + let b_secs = bdt.raw_seconds_pair(); + let total_g = (g_secs.0 + g_secs.1).value(); + let total_b = (b_secs.0 + b_secs.1).value(); + assert!( + (total_g - total_b - 14.0).abs() < 1e-9, + "BDT must lag GPST by 14 s" + ); +} + +#[test] +fn bdt_offset_from_tai_is_33s() { + let tai = Time::::from_raw_j2000_seconds(Second::new(0.0)).unwrap(); + let bdt = tai.to::(); + let total_t = (tai.raw_seconds_pair().0 + tai.raw_seconds_pair().1).value(); + let total_b = (bdt.raw_seconds_pair().0 + bdt.raw_seconds_pair().1).value(); + assert!((total_t - total_b - 33.0).abs() < 1e-9); +} + +#[test] +fn gnss_round_trips_through_all_targets() { + let original = Time::::from_raw_j2000_seconds(Second::new(12_345_678.9)).unwrap(); + for label in ["GPST", "GST", "QZSST", "BDT"] { + let round_trip = match label { + "GPST" => original.to::().to::(), + "GST" => original.to::().to::(), + "QZSST" => original.to::().to::(), + "BDT" => original.to::().to::(), + _ => unreachable!(), + }; + let d = original.diff_exact(round_trip).unwrap(); + assert!( + d.as_nanos_i128().abs() < 1000, + "{label}: round-trip drift > 1 µs: {d}" + ); + } +} + +#[test] +fn et_is_numerically_identical_to_tdb() { + let tdb = Time::::from_raw_j2000_seconds(Second::new(123_456.789)).unwrap(); + let et = tdb.to::(); + let d = tdb.diff_exact(et.to::()).unwrap(); + assert_eq!( + d.as_nanos_i128(), + 0, + "ET ↔ TDB must be an identity at the storage layer" + ); +} + +#[test] +fn et_routes_through_tdb_for_other_scales() { + let et = Time::::from_raw_j2000_seconds(Second::new(0.0)).unwrap(); + let tt_via_et = et.to::(); + let tt_via_tdb = Time::::from_raw_j2000_seconds(Second::new(0.0)) + .unwrap() + .to::(); + let d = tt_via_et.diff_exact(tt_via_tdb).unwrap(); + assert_eq!(d.as_nanos_i128(), 0); +} + +#[test] +fn gnss_cross_conversion_uses_integer_offsets() { + // GPST -> BDT must lose 14 s exactly (nominal). + let g = Time::::from_raw_j2000_seconds(Second::new(500.0)).unwrap(); + let b = g.to::(); + let total_g = (g.raw_seconds_pair().0 + g.raw_seconds_pair().1).value(); + let total_b = (b.raw_seconds_pair().0 + b.raw_seconds_pair().1).value(); + assert!((total_g - total_b - 14.0).abs() < 1e-9); +} + +#[test] +fn diff_exact_returns_nanosecond_precision() { + let a = Time::::from_raw_j2000_seconds(Second::new(1_000_000.0)).unwrap(); + let b = Time::::from_raw_j2000_seconds(Second::new(1_000_001.000_000_001)).unwrap(); + let d = b.diff_exact(a).unwrap(); + // Expect ~1.000000001 s = 1_000_000_001 ns; allow small f64 noise. + let diff = (d.as_nanos_i128() - 1_000_000_001).abs(); + assert!( + diff < 100, + "diff_exact precision drift: {} ns from expected", + diff + ); +}