# 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>()`, `.to::<J2000s>()` for coordinate views
- `.try_to::<Unix>()` and `.to::<GPS>()` for transport encodings
- 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 10 ms of the bundled daily IERS-derived
path over the observed overlap through 2026-04-16, and within 0.2 s over the
compiled short-range prediction overlap through 2027-04-24. 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
`constats::TDB_TT_MODEL_HIGH_ACCURACY_START_JD` →
`constats::TDB_TT_MODEL_HIGH_ACCURACY_END_JD` interval (about 1600-01-01 to
2200-01-01 TT).
- Julian Day, Modified Julian Day, and SI-second views via `JD`, `MJD`, and
`J2000s` conversion targets on every built-in scale, including UTC's stored
instant axis.
- Unix/POSIX timestamps via `UnixTime::try_new(sec).and_then(|e| e.to_time_with(&ctx))` and `.try_to::<Unix>()`.
- GPS transport values via `GpsTime::try_new(sec).map(|e| e.to_time())` and `.to::<GPS>()`.
- Compiled time-data tables generated from official UTC-TAI and Delta T
sources.
- Optional `serde` support for `Time<S>` as `{"hi","lo"}` and
`Period<S>` / `Interval<T>` as `{start, end}` objects, plus explicit
`tempoch::tagged::{TaggedTime, TaggedPeriod}` wrappers when the payload must
carry the scale name.
- Automatic runtime freshness backed by a cached time-data bundle, while
keeping the same public API.
- Public typed epoch/offset constants under `tempoch::constats`, such as
`J2000_JD_TT`, `TT_MINUS_TAI`, and `DELTA_T_PREDICTION_HORIZON_MJD`.
- A utility `Interval<T>` type for half-open time ranges over `Time<A>`,
with intersection, normalization, validation, and complement helpers.
**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.
This is why `Time<S>` serializes as `{"hi","lo"}` with `serde`, and why the
crate reuses `affn::SplitQuantity` / `SplitPoint1` instead of a plain scalar
field.
### 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.
### Why UTC is treated specially
`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
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.4.3"
```
Enable `serde` if you want to serialize typed times and periods:
```toml
[dependencies]
tempoch = { version = "0.4.3", features = ["serde"] }
```
The `serde` feature composes with the ordinary runtime refresh behavior:
```toml
[dependencies]
tempoch = { version = "0.4.3", features = ["serde", "runtime-data-fetch"] }
```
## Serde
With the `serde` feature enabled:
- `Time<S>` serializes as `{"hi": ..., "lo": ...}`.
- `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 for interchange payloads.
```rust
use qtty::Second;
use tempoch::{
tagged::{TaggedPeriod, TaggedTime},
J2000Seconds, Period, TT,
};
let tt = J2000Seconds::<TT>::try_new(Second::new(42.5)).unwrap().to_time();
let period = Period::<TT>::new(
tt,
J2000Seconds::<TT>::try_new(Second::new(43.5)).unwrap().to_time(),
);
assert_eq!(serde_json::to_string(&tt).unwrap(), r#"{"hi":42.5,"lo":0.0}"#);
assert_eq!(
serde_json::to_string(&period).unwrap(),
r#"{"start":{"hi":42.5,"lo":0.0},"end":{"hi":43.5,"lo":0.0}}"#
);
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().to_time(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_001.0)).unwrap().to_time(),
);
let a = vec![
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.1)).unwrap().to_time(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.4)).unwrap().to_time(),
),
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.6)).unwrap().to_time(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.9)).unwrap().to_time(),
),
];
let b = vec![
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.2)).unwrap().to_time(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.3)).unwrap().to_time(),
),
Period::<TT>::new(
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.7)).unwrap().to_time(),
ModifiedJulianDate::<TT>::try_new(Day::new(61_000.8)).unwrap().to_time(),
),
];
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 = JulianDate::<TT>::try_new(Day::new(2_460_000.25))?.to_time();
let ut1: Time<UT1> = tt.to_with::<UT1>(&ctx)?;
let unix = UnixTime::try_new(Second::new(1_700_000_000.0))
.and_then(|e| e.to_time_with(&ctx))?;
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-core`.
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