tempoch 0.8.0

Astronomical time primitives: typed time scales, Julian dates, UTC conversion, and interval operations.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
# tempoch

[![Crates.io](https://img.shields.io/crates/v/tempoch.svg)](https://crates.io/crates/tempoch)
[![Docs](https://docs.rs/tempoch/badge.svg)](https://docs.rs/tempoch)
[![Code Quality](https://github.com/Siderust/tempoch/actions/workflows/ci.yml/badge.svg?branch=main)](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:

| Generated table | Purpose | Official reference | Canonical upstream |
|---|---|---|---|
| `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