# tempoch
[](https://crates.io/crates/tempoch)
[](https://docs.rs/tempoch)
[](https://github.com/Siderust/tempoch/actions/workflows/ci.yml)
Typed astronomical time primitives for Rust.
`tempoch` is built around a few deliberate modeling choices:
- A `Time<S>` value is an instant on a scale-specific axis, not a bare `f64`.
- Time instants are modeled with affine semantics: `time_a - time_b` yields a
duration, while shifting an instant is `time + seconds`.
- The canonical internal carrier is a split `(hi, lo)` pair of J2000-based
seconds so epoch-sized values can retain sub-second precision through
conversions and arithmetic.
- `JD`, `MJD`, `J2000s`, `Unix`, and `GPS` are views or transport
encodings, not alternate storage backends.
- `UTC` keeps its civil meaning, but internally it is stored on a continuous
instant axis and interpreted through the active UTC-TAI data tables.
`tempoch` provides:
- `Time<S>` instants parameterized by a physical or civil scale (`TT`,
`TAI`, `UTC`, `UT1`, `TDB`, `TCG`, `TCB`).
- Unified target-based conversions:
- `.to::<TT>()`, `.to::<UTC>()`, `.to::<TDB>()` for infallible scale routes
- `.to_with::<UT1>(&ctx)` for context-backed UT1 routes, or `.try_to::<UT1>()` shorthand (snapshots active data at call time)
- `.to::<JD>()` / `.to::<MJD>()` for infallible coordinates on continuous scales; UTC uses `.to_with::<MJD>(&ctx)` (or `JD`) followed by `try_raw_with(&ctx)`
- `.try_to::<Unix>()` and `.to::<GPS>()` for transport encodings
- `JulianDate<S>`, `ModifiedJulianDate<S>`, `UnixTime`, and `GpsTime` implement `Into<Time<_>>` (same instant as `.to_j2000s()`), so `Period::<S>::new(...)` / `try_new(...)` accept encoded endpoints directly; `J2000Seconds<S>` is already `Time<S>`.
- UTC conversion through `chrono`, leap-second aware over the official history
(1961-01-01 onward). Requests for dates before the UTC standard was defined
return `ConversionError::UtcBeforeDefinition` by default; opt in to the
approximate segment back-extrapolation by building your context with
`TimeContext::new().allow_pre_definition_utc()`.
- Automatic `ΔT = TT - UT1` handling for `UT1` conversions via an explicit
`TimeContext`. For the currently compiled bundle fetched 2026-04-18, the
default monthly-ΔT path stays within 15 ms of the bundled daily IERS-derived
path over the observed overlap, and within 0.2 s over the compiled
short-range prediction overlap. Opt into
`TimeContext::with_builtin_eop()` when you want the highest-fidelity bundled
UT1 path; raw EOP values are available under `tempoch::eop` and bracketed by
the public `EOP_START_MJD` / `EOP_OBSERVED_END_MJD` / `EOP_END_MJD`
constants.
- TT↔TDB conversion via the built-in seven-term Fairhead–Bretagnon
approximation from USNO Circular 179. The crate documents about 10 µs
accuracy only inside the public interval bracketed by
[`tdb_tt_model_high_accuracy_start_jd`] / [`tdb_tt_model_high_accuracy_end_jd`]
in [`tempoch::constats`] (day literals also exposed as
`TDB_TT_MODEL_HIGH_ACCURACY_*_JD_DAY`; roughly 1600-01-01 to 2200-01-01 TT).
- Julian Day and Modified Julian Day coordinates via `JD` and `MJD` on each
scale's time axis. For UTC these are civil fractional-day coordinates using
86,400 nominal seconds per day (POSIX style); for TAI, TT, and other
continuous scales they remain coordinates on that scale's continuous axis.
UTC conversion requires `TimeContext` because it uses UTC-TAI data, and a
leap-second instant returns `ConversionError::InvalidLeapSecond` since it
cannot be uniquely represented by a POSIX-style day count. The internal
compensated J2000-second instant storage is unchanged.
- Unix/POSIX timestamps via `UnixTime::try_new(sec).map(Into::into)` and `.try_to::<Unix>()`.
- GPS transport values via `GpsTime::try_new(sec).map(Into::into)` and `.to::<GPS>()`.
- Compiled time-data tables generated from official UTC-TAI and Delta T
sources.
- Optional `serde` support for `Time<S, F>` as the format's scalar quantity and
`Period<S>` / `Interval<T>` as `{start, end}` objects, plus explicit
`tempoch::tagged::{TaggedTime, TaggedPeriod}` wrappers when the payload must
carry the scale name and compensated split seconds. UTC JD/MJD scalars use
civil coordinates and report conversion failures as Serde errors. Each
Serde operation captures a fresh default `TimeContext`; the time-data
snapshot is not stored in the payload. For reproducible conversion with a
fixed context, serialize `time.try_raw_with(&ctx)?` and decode the quantity
with `Time::try_new_with(raw, &ctx)`.
- Automatic runtime freshness backed by a cached time-data bundle, while
keeping the same public API.
- Public typed epochs under `tempoch::constats`: [`Time`] helpers such as
[`j2000_jd_tt`], [`qtty::Day`] literals such as [`J2000_JD_TT_DAY`], offsets like
[`TT_MINUS_TAI`], and the ΔT horizon marker [`DELTA_T_PREDICTION_HORIZON_MJD`]
(`qtty::Day`).
- A utility `Interval<T>` type for half-open time ranges over `Time<A>`,
with intersection, normalization, validation, and complement helpers.
## Compiled Tables And Official References
The generated tables under `tempoch-time-data/src/generated/` are tied to explicit
authoritative sources:
| `UTC_TAI_SEGMENTS` | UTC-to-TAI history, including pre-1972 rate segments and post-1972 leap-second steps | IERS Bulletin C / `UTC-TAI.history` | `https://hpiers.obspm.fr/eoppc/bul/bulc/UTC-TAI.history` |
| `MODERN_DELTA_T_POINTS` | Compiled modern `ΔT = TT - UT1` series | USNO observed monthly `deltat.data` plus USNO predicted `deltat.preds` | `https://maia.usno.navy.mil/ser7/deltat.data` and `https://maia.usno.navy.mil/ser7/deltat.preds` |
| `EOP_POINTS` | Daily Earth-orientation parameters used by the bundled UT1/EOP path | IERS combined Bulletin A + C04 file `finals2000A.all` | `https://datacenter.iers.org/data/9/finals2000A.all` |
`MODERN_DELTA_T_POINTS` is a derived bundle in this crate: the observed USNO
monthly series is concatenated with C0-adjusted USNO predictions, and the
observed/predicted boundary is exposed through
`MODERN_DELTA_T_OBSERVED_END_MJD`.
**Storage model:** `Time<S>` stores a compensated `(hi, lo)` pair of seconds
since J2000 TT on the target axis. Tags such as `JD`, `MJD`, `Unix`, and
`GPS` are conversion targets, not storage types.
## Design Decisions
### `Time<S>` is an affine point, not a scalar
`tempoch` treats an instant as a point on a time axis. That is why the API is
deliberately shaped around:
- `time_a - time_b -> duration`
- `time + duration -> time`
- `time - duration -> time`
and does not model "adding two instants" as a meaningful operation.
Internally, this is represented with `affn::SplitPoint1`, which keeps the
affine semantics aligned with the rest of the geometry model instead of
re-introducing scalar-like mistakes through ad hoc time arithmetic.
### Why time uses split storage
Astronomical epochs are large in absolute value, but many important corrections
are tiny:
- leap-second boundaries
- TT-TAI offsets
- TT-TDB corrections
- UT1 adjustments
- sub-second transport roundtrips
Using one `f64` for "seconds since epoch" would lose low-order precision once a
small correction is combined with a value on the order of `1e9` seconds.
`tempoch` therefore stores every `Time<S>` as a normalized compensated pair
`(hi, lo)` whose sum is the represented instant. The high part carries the
epoch-sized component; the low part preserves the small remainder.
The crate reuses `affn::SplitQuantity` / `SplitPoint1` to preserve this pair.
Scalar Serde encodings project it into the selected format's `f64` coordinate;
`tagged::TaggedTime<S>` preserves both components in its payload.
### Why J2000 seconds are canonical
The crate exposes Julian Day, Modified Julian Day, Unix, and GPS helpers, but
those are public coordinate or transport views. The storage choice remains
canonical J2000-based seconds because it gives one precise internal axis for
arithmetic and scale conversion, while still allowing JD/MJD-style APIs at the
boundary.
### Format vs Scale
Two orthogonal phantom-type axes drive all typed time values in `tempoch`:
- **Scale** — the physical time axis: `TT`, `TAI`, `UTC`, `UT1`, `TDB`,
`TCG`, `TCB`. A scale tells you *which* reference frame's clock is ticking.
- **Format** — how an instant is numerically encoded: `JD` (Julian Day),
`MJD` (Modified Julian Day), `J2000s` (SI seconds since J2000), `Unix`
(POSIX seconds), `GPS` (days since GPS epoch). A format tells you *how* you
read the number off the wire.
The two are always kept separate:
- [`Time<S>`] defaults to tag [`J2000s`] and stores the compensated J2000-second
pair on scale `S`; [`reinterpret`] selects another [`TimeFormat`] view without
changing storage.
- [`EncodedTime<S, F>`] (and its aliases `JulianDate<S>`, `ModifiedJulianDate<S>`,
etc.) pair a scale with a format for I/O.
- Crate epochs are expressed either as [`Time<S, F>`] via helpers in
[`tempoch::constats`] (for example [`j2000_jd_tt`]) or as canonical plain
[`qtty::Day`] / [`qtty::Second`] facts such as [`J2000_JD_TT_DAY`],
[`UNIX_EPOCH_JD_DAY`], and [`GPS_EPOCH_TAI_MINUS_UTC`].
- Generic unit facts such as Julian years and Julian centuries live in `qtty`
(`qtty::time::JULIAN_YEAR`, `qtty::time::JULIAN_CENTURY`). Derived epoch
coordinates such as Unix MJD or GPS epoch JD(TAI) are available through typed
helpers instead of public scalar constants.
The type parameter order is always **Scale first, Format second** — matching
the reading direction "JD on TT" → [`EncodedTime<TT, JD>`] / [`JulianDate<TT>`].
`UTC` is a civil scale with leap-second labeling, so it cannot be treated as a
simple uniform scalar everywhere. `tempoch` keeps the internal instant storage
continuous, then maps that stored instant to civil UTC through the active
UTC-TAI table. That separation lets the crate support both:
- precise instant arithmetic and transport encodings
- leap-second-aware civil conversions
UTC JD/MJD values now follow that same civil mapping. Construct with
`Time::<UTC, MJD>::try_new_with(day, &ctx)` and encode with
`time.to_with::<MJD>(&ctx)?.try_raw_with(&ctx)?` (substitute `JD` as needed).
The context controls time-data selection and the pre-1961 UTC policy. Previous
code such as `Time::<UTC, MJD>::new(value)`, `time.to::<MJD>().raw()`, and
`ModifiedJulianDate::<UTC>::new(value)` must migrate to these fallible APIs.
UTC JD/MJD `.raw()` and `.new()` are intentionally unavailable; other
continuous scales retain their infallible operations.
Migration example for a numeric UTC MJD field:
```rust,ignore
// Before: interpreted the number on the continuous stored axis.
let utc = ModifiedJulianDate::<UTC>::new(mjd);
let mjd_back = utc.to::<MJD>().raw();
// After: decode and encode the civil UTC day count with one context snapshot.
let ctx = TimeContext::new();
let utc = Time::<UTC, MJD>::try_new_with(Day::new(mjd), &ctx)?;
let mjd_back = utc.to_with::<MJD>(&ctx)?.try_raw_with(&ctx)?;
```
The compiled modern ΔT series runs through MJD 63871 (`2033-10-01`). Beyond
that date the built-in bundle stops and UT1 conversions fail with
`ConversionError::Ut1HorizonExceeded` unless an active runtime bundle extends
the horizon. Use the exported `DELTA_T_PREDICTION_HORIZON_MJD` typed
`qtty::Day` constant to reference the compiled boundary programmatically.
## Installation
```toml
[dependencies]
tempoch = "0.8"
```
Enable `serde` if you want to serialize typed times and periods:
```toml
[dependencies]
tempoch = { version = "0.8", features = ["serde"] }
```
The `serde` feature composes with the ordinary runtime refresh behavior:
```toml
[dependencies]
tempoch = { version = "0.8", features = ["serde", "runtime-data-fetch"] }
```
### `no_std`
`tempoch` is written against `core` / `alloc`. Default builds enable `std`.
```toml
# no_std + alloc (including bare-metal targets)
tempoch = { version = "0.8", default-features = false, features = ["alloc"] }
```
Bundled IERS tables come from `siderust-archive` 0.1.5+, which is itself
`no_std` + `alloc` when its `std` feature is off. Runtime download
(`runtime-data-fetch`) still requires `std`.
## Serde
With the `serde` feature enabled:
- `Time<S, F>` serializes as the format's scalar quantity; `Time<S>` uses
J2000 seconds.
- `Period<S>` serializes as `{"start": ..., "end": ...}`.
- The scale remains type-level and is not embedded in the payload.
- `tagged::TaggedTime<S>` and `tagged::TaggedPeriod<S>` serialize with an
explicit `"scale"` field and compensated split seconds for interchange payloads.
- UTC JD/MJD Serde captures fresh default time data per operation and returns
Serde errors for leap-second encoding, pre-definition UTC, and unusable
coordinates. Serialize an explicitly converted quantity when a fixed
`TimeContext` is required.
```rust
use qtty::Second;
use tempoch::{
tagged::{TaggedPeriod, TaggedTime},
J2000Seconds, Period, TT,
};
let tt = J2000Seconds::<TT>::try_new(Second::new(42.5)).unwrap();
let period = Period::<TT>::new(
tt,
J2000Seconds::<TT>::try_new(Second::new(43.5)).unwrap(),
);
assert_eq!(serde_json::to_string(&tt).unwrap(), "42.5");
assert_eq!(
serde_json::to_string(&period).unwrap(),
r#"{"start":42.5,"end":43.5}"#
);
assert_eq!(
serde_json::to_string(&TaggedTime(tt)).unwrap(),
r#"{"scale":"TT","hi":42.5,"lo":0.0}"#
);
assert_eq!(
serde_json::to_string(&TaggedPeriod(period)).unwrap(),
r#"{"scale":"TT","start":{"scale":"TT","hi":42.5,"lo":0.0},"end":{"scale":"TT","hi":43.5,"lo":0.0}}"#
);
```
## Quick Start
```rust
use chrono::Utc;
use tempoch::{JD, MJD, Time, TT, UTC};
let utc_now = Time::<UTC>::from_chrono(Utc::now());
let tt_now: Time<TT> = utc_now.to::<TT>();
println!("UTC : {}", utc_now.to_chrono().unwrap());
println!("TT in JD : {:.9}", tt_now.to::<JD>());
println!("TT in MJD : {:.9}", tt_now.to::<MJD>());
```
## Period Operations
```rust
use qtty::Day;
use tempoch::{ModifiedJulianDate, Period, TT};
let day = Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.0)).unwrap(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_001.0)).unwrap(),
);
let a = vec![
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.1)).unwrap(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.4)).unwrap(),
),
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.6)).unwrap(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.9)).unwrap(),
),
];
let b = vec![
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.2)).unwrap(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.3)).unwrap(),
),
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.7)).unwrap(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.8)).unwrap(),
),
];
let overlap = Period::intersect_many(&a, &b);
let gaps = day.complement(&a);
assert_eq!(overlap.len(), 2);
assert_eq!(gaps.len(), 3);
```
## Examples
- `cargo run --example 01_quickstart`
- `cargo run --example 02_scales`
- `cargo run --example 03_formats`
- `cargo run --example 04_periods`
- `cargo run --example 05_serde --features serde`
- `cargo run -p tempoch --example 06_runtime_tables`
- `cargo run --example 07_conversions`
## Runtime Time Data
`tempoch` automatically prefers a cached runtime bundle for fresher UTC-TAI
history, modern Delta T, and daily IERS EOP while keeping the public API
unchanged. `TimeContext` and `Time::to_with` consult a cached bundle in
`~/.tempoch/data`, refreshing it once on first use when the cache is missing,
invalid, or older than 24 hours. Data-dependent shorthand methods (e.g.
`try_to::<UT1>()`, `try_to::<Unix>()`, `try_to_chrono()`) snapshot a fresh
`TimeContext` internally. For reproducible pipelines, use the `_with` variants
with an explicit context.
Set `TEMPOCH_DATA_DIR` to override the cache location.
For a runnable example that uses the ordinary API with runtime refresh, run:
```bash
cargo run -p tempoch --example 06_runtime_tables
```
```rust,no_run
use qtty::{Day, Second};
use tempoch::{JD, JulianDate, Time, TimeContext, UnixTime, Unix, TT, UT1, UTC};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ctx = TimeContext::with_builtin_eop();
let tt: Time<TT> = JulianDate::<TT>::try_new(Day::new(2_460_000.25))?.into();
let ut1: Time<UT1> = tt.to_with::<UT1>(&ctx)?;
let unix = UnixTime::try_new(Second::new(1_700_000_000.0)).map(Into::into)?;
let back = unix.try_to::<Unix>()?;
println!("UT1 JD : {:.9}", ut1.to::<JD>());
println!("Unix roundtrip: {:.3}", back);
Ok(())
}
```
## Time Data Updates
The compile-time path still uses checked-in generated tables in `tempoch-time-data`.
The dedicated Rust CLI `tempoch-time-data-updater` regenerates those committed
files from the official UTC-TAI, Delta T, and IERS finals2000A.all sources.
Its fetch/parse/build pipeline now reuses the same shared support crate that
powers runtime refresh. The updater intentionally keeps only render/write
orchestration; parser and bundle-building logic is centralized in
`tempoch-time-data` to avoid runtime/compile-time drift. To refresh locally:
```bash
cargo run -p tempoch-time-data-updater
cargo test --all-features
```
To verify manually that the committed generated files are still in sync with
upstream:
```bash
cargo run -p tempoch-time-data-updater -- --check
```
A scheduled GitHub Actions workflow runs the refresh automatically every
Monday at 05:23 UTC and pushes the resulting commit directly to `main` when
the generated tables or their source hashes change. GitHub cron schedules are
defined in UTC.
## Tests and Coverage
```bash
cargo test --all-targets
cargo test --doc
cargo +nightly llvm-cov --workspace --all-features --doctests --summary-only
```
Coverage is gated in CI at **>= 90% line coverage**.
## License
AGPL-3.0-only