cheatah
Module

space::time

cheatah-space v0.1.0-alpha — Biome Standard 0.6.5-alpha

Julian dates & epoch conversions — an extension for the cheatah standard time library. The pun (spacetime) is intended. Hand-authored, header-only C++20, zero dependencies.

Status: working — Julian/Modified-Julian/J2000 conversions and the CDF_EPOCH bridge, scalar and vectorized over ndarray, 100% covered and systested.

import space.time as st
import ndarray

let jd  = st.unix_to_jd(st.jd_unix_epoch())      # scalar
let jds = st.unix_to_jd(ndarray.array([0.0, 86400.0, 946728000.0]))   # whole array at once
let ms  = st.unix_to_cdf_epoch(0.0)              # 62167219200000.0 — feeds space.cdf

Templated with explicit concepts

Every conversion is one concept-constrained template (time/time.hpp) that accepts:

  • a sensible scalar Value (Numeric — a number of seconds/days/ms), or

  • a numeric ndarray (NumericArray — vectorized over the ndarray SIMD path), or

  • an ndarray of datetime structs (DatetimeArray) — lit up via the is_datetime_v trait hook once cheatah::datetime defines its struct.

The union is the TimeInput concept, so passing the wrong thing yields a crisp compiler error instead of a template spew. ndarray support is opt-in by include path (__has_include), so a scalar-only program never needs ndarray.

Functions

Reference epochs (functions, so callers never hard-code magic numbers): jd_unix_epoch, jd_j2000, cdf_epoch_unix_offset_ms.

Conversions (each O(n), allocation-free for scalars):

  • unix_to_jd / jd_to_unix — Unix seconds ⇄ Julian Date.

  • jd_to_mjd / mjd_to_jd, unix_to_mjd / mjd_to_unix — Modified Julian Date.

  • jd_to_j2000_seconds / jd_to_j2000_centuries — time since J2000.0.

  • unix_to_cdf_epoch / cdf_epoch_to_unix — the NASA CDF_EPOCH (ms since year 0) bridge.

Numerical note

A Julian Date stored as a double carries a ~2.44e6-day offset, leaving ~50 µs of resolution near the present epoch; round-tripping a small value through a JD therefore lands within ~µs, not bit-exactly (the tests assert this with an absolute tolerance). Known instants that land on exact half-days (Unix epoch, J2000) convert exactly.

Roadmap

  • CDF_TT2000 (ns since J2000 with leap seconds) — needs a leap-second table; lands with space.cdf. CDF_EPOCH16 (picosecond, two doubles).

  • GMST / sidereal time once a consumer needs it.

Tests

Two layers, both run by the qa_gate:

  • System tests — cheatah programs in ../../systests/ that import space.time end to end through purrc + the runtime.

  • Unit tests — GoogleTest in ../../tests/, held to 100% lines + functions clang source-based coverage over this header.

Every public function carries @complexity, @alloc, truthful @systest tags naming the systests that cover it, and an @par Example whose @code{.purr} block the gate compiles; @perf is reserved for the benchmarked modules (cdf/irbem).

Classes

Concepts

Functions

fn std::int64_t julian_day_number(int year, int month, int day) #

The Julian day number of a calendar date.

Reform-aware, not proleptic. Dates on or after the Gregorian reform use the Gregorian rule; earlier dates use the JULIAN rule, which is what they were actually recorded in. This differs deliberately from civil.hpp, which is proleptic Gregorian throughout because that is what the CDF format specifies — the two conventions disagree by ten days at the reform and by a growing amount before it, so they are separate functions rather than one with a flag.

Which convention a caller wants depends on what the date MEANS: a CDF timestamp is proleptic by definition, whereas a historical observation is in the calendar its observer used. Geomagnetic work reaches back to the 19th century and IGRF to 1900, both comfortably post-reform, so in practice the two agree everywhere this module is used — but agreeing by accident is not the same as agreeing by construction, and a silent ten-day error is exactly the kind that survives review.

Integer arithmetic throughout (Fliegel & Van Flandern 1968 for the Gregorian branch), so there is no floating-point intermediate and no rounding, valid into negative years under astronomical numbering: JDN 0 is -4712-01-01 in the Julian calendar.

Parameters
year

the year, astronomical numbering.

month

1-12.

day

1-based.

Returns

the Julian day number — a count of days where the DAY BEGINS AT NOON. A caller wanting a Julian DATE for a sidereal-time series must subtract 0.5 and add the UT fraction; getting that half-day wrong shifts GMST by twelve hours, which rotates GSM by 180 degrees and yields a field that looks plausible and is inverted.

Complexity

O(1).

Allocation

none.

fn CalendarDate calendar_date(std::int64_t jdn) #

The calendar date of a Julian day number — the exact inverse of julian_day_number, including across the reform.

Parameters
jdn

the Julian day number.

Returns

the calendar date, in whichever calendar was in force at that JDN.

Complexity

O(1).

Allocation

none.

fn int day_of_year(int year, int month, int day) #

The day of year of a calendar date, leap-year correct.

Parameters
year

the year.

month

1-12.

day

1-based.

Returns

the day of year, 1 = January 1.

Complexity

O(1).

Allocation

none.

fn int days_in_year(int year) #

How many days a year has.

Parameters
year

the year.

Returns

366 in a leap year, 365 otherwise — computed from the calendar rather than from the leap rule directly, so the two can never disagree.

Complexity

O(1).

Allocation

none.

fn CalendarDate date_from_day_of_year(int year, int doy) #

The calendar date of a given day of year — the inverse of day_of_year, and the piece IRBEM's DOY_AND_UT2DATE_AND_TIME needs.

Parameters
year

the year, astronomical numbering.

doy

the day of year, 1 = January 1. Values outside [1, days_in_year(year)] are not rejected; they roll into the neighbouring year, which is what makes a UT offset that crosses midnight on December 31 come out right instead of producing a "day 367".

Returns

the calendar date.

Complexity

O(1).

Allocation

none.

fn double decimal_year(int year, int month, int day, int hour, int minute, int second) #

Date and time to decimal year — IRBEM's DATE_AND_TIME2DECY.

yyyy.0 is January 1 at 00:00 UT and the year's own length is the denominator, so the fraction is the elapsed portion of that year: a mid-year instant is .5 in a leap year and in a common year alike. This is the form IGRF coefficient interpolation consumes.

Everything above the final division is integer, so the only rounding in the whole routine is the one unavoidable division — and when the fraction is exactly representable the result is exact. decimal_year(2000, 7, 2, 0, 0, 0) is 2000.5 bit-for-bit, because 183 of 366 days is one half.

Parameters
year

the year, astronomical numbering.

month

the month, 1 = January … 12 = December.

day

the day of the month, 1-based.

hour

the UT hour of day, 0…23.

minute

the UT minute, 0…59.

second

the UT second, 0…59.

Returns

the decimal year.

Complexity

O(1).

Allocation

none.

fn DateTime date_and_time_from_decimal_year(double decy) #

Decimal year back to a broken-down instant — IRBEM's DECY2DATE_AND_TIME, and the inverse of decimal_year.

The fraction is converted to an integer count of microseconds of year and then snapped to the millisecond before anything is split off it — see detail::decimal_year_resolution_us for the derivation. That snap is the whole point of this routine: a decimal year near 2000 resolves to only about 7 microseconds, so a whole hour recovered from one lands a few microseconds short and a plain truncation reports the previous second. Measured against the shipped IRBEM library, that defect fires on roughly one round trip in six across a sweep of the year 2000; this routine returns the instant it was given, exactly, for every input decimal_year can produce.

Parameters
decy

the decimal year, yyyy.0 being January 1 at 00:00 UT.

Returns

the instant, with month, day, day of year, h:m:s and seconds-of-day all filled in.

Complexity

O(1).

Allocation

none.

fn DateTime date_and_time_from_doy_and_ut(int year, int doy, double ut_seconds) #

Year, day of year and UT seconds to a broken-down instant — IRBEM's DOY_AND_UT2DATE_AND_TIME.

Parameters
year

the year, astronomical numbering.

doy

the day of year, 1 = January 1.

ut_seconds

UT time of day in seconds since midnight. Values outside [0, 86400) are carried into the day count rather than rejected, so a caller stepping a trajectory by adding seconds gets the right date across midnight — including backwards, where a negative offset walks into the previous day.

Returns

the instant. day_of_year in the result is recomputed from the resulting date, so it reflects any such carry rather than echoing doy back.

Complexity

O(1).

Allocation

none.

fn bool is_leap_year(int year) noexcept #

Whether year is a leap year under proleptic Gregorian rules.

Parameters
year

astronomical year number.

Returns

true when year has 366 days.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

io.print(st.is_leap_year(2000))   # True  — divisible by 400
io.print(st.is_leap_year(1900))   # False — divisible by 100 but not 400
fn int days_in_month(int year, int month) noexcept #

The number of days in a given month.

Parameters
year

astronomical year number (decides February's length).

month

1–12; anything outside that range yields 0.

Returns

days in the month, or 0 when month is out of range.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

io.print(st.days_in_month(2024, 2))   # 29
io.print(st.days_in_month(2026, 2))   # 28
fn bool is_valid_civil(const CivilDate &date) noexcept #

Whether date names a day that exists.

Parameters
date

the date to check.

Returns

true when the month is 1–12 and the day is within that month's length.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

io.print(st.is_valid_civil(st.civil_date(2024, 2, 29)))   # True  — 2024 is a leap year
io.print(st.is_valid_civil(st.civil_date(2026, 2, 29)))   # False — 2026 is not
fn CivilDate civil_date(int year, int month, int day) noexcept #

Build a CivilDate from its three components.

Exists because a .purr caller cannot write a C++ aggregate initializer; from C++ prefer CivilDate{y, m, d} directly. No validation is performed — see is_valid_civil().

Parameters
year

astronomical year number.

month

1–12 for a valid date.

day

1–31 for a valid date.

Returns

the assembled date.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

let d = st.civil_date(2026, 8, 28)
io.print(d.year, d.month, d.day)   # 2026 8 28
fn long long days_from_civil(int year, int month, int day) noexcept #

Days since the Unix epoch (1970-01-01) for a proleptic Gregorian date.

Howard Hinnant's days_from_civil, which is exact for every year representable in int and is the inverse of civil_from_days(). Shifting the year to start in March is what makes the leap day fall at the end of the year, so no special case for February is needed.

Parameters
year

astronomical year number.

month

1–12.

day

1–31.

Returns

days before (negative) or after (positive) 1970-01-01.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

io.print(st.days_from_civil(1970, 1, 1))   # 0
io.print(st.days_from_civil(2000, 1, 1))   # 10957 — the J2000 day
fn CivilDate civil_from_days(long long days) noexcept #

The proleptic Gregorian date for a count of days since the Unix epoch.

The exact inverse of days_from_civil() over the whole representable range.

Parameters
days

days before (negative) or after (positive) 1970-01-01.

Returns

the calendar date.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

let d = st.civil_from_days(10957)
io.print(d.year, d.month, d.day)   # 2000 1 1
fn long long nasa_julian_day_at_noon(int year, int month, int day) noexcept #

The Julian Day number at noon, computed exactly the way NASA's CDF library computes it.

A literal transcription of the _JulianDay formula in NASA's CDF distribution, truncating integer division and all. It is here so our CDF epoch conversions can be shown to agree with NASA's rather than merely be believed to: the unit tests assert this equals days_from_civil(...) + 2440588 for every day in the TT2000 range, which retires the whole class of off-by-one calendar bugs in one loop.

Warning

It is EXACT for every year >= 0 and WRONG for negative years — off by one day at year -1, by two by year -401, and drifting further back. The formula needs floor division and C truncates toward zero, which only differs once the numerator goes negative. That costs CDF nothing, and the bound is the reason: CDF_EPOCH counts from year 0 and CDF_TIME_TT2000 reaches back only to 1707, so no CDF-representable instant falls in the broken region. Verified day by day in tests/space_civil_test.cpp, both that they agree for year >= 0 and that they diverge below it — the divergence is pinned deliberately, so a future "fix" to this transcription fails the build.

Prefer days_from_civil() for new code. This exists for provenance, not for use.

Parameters
year

astronomical year number.

month

1–12.

day

1–31.

Returns

the Julian Day number of the noon that begins this date's Julian day.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

# J2000: 2000-01-01 has Julian Day 2451545 at noon.
io.print(st.nasa_julian_day_at_noon(2000, 1, 1))   # 2451545
fn double jd_unix_epoch() #

Julian Date of the Unix epoch, 1970-01-01T00:00:00Z.

Returns

2440587.5.

Complexity

O(1).

Allocation

none.

System testsystests/test_jd.purr
Example
import io
import space.time as st

# The Unix epoch's Julian Date is the anchor of every unix<->jd conversion.
io.print(st.jd_unix_epoch())                       # 2440587.5
io.print(st.unix_to_jd(0.0) == st.jd_unix_epoch()) # true
fn double jd_j2000() #

Julian Date of the J2000.0 epoch, 2000-01-01T12:00:00 TT.

Returns

2451545.0.

Complexity

O(1).

Allocation

none.

System testsystests/test_jd.purr
Example
import io
import space.time as st

# J2000.0 — the reference epoch of modern astronomical series.
io.print(st.jd_j2000())                            # 2451545.0
io.print(st.jd_to_j2000_seconds(st.jd_j2000()))    # 0.0 — J2000 is its own origin
fn double cdf_epoch_unix_offset_ms() #

CDF_EPOCH of the Unix epoch: milliseconds from 0000-01-01T00:00:00.000 to 1970-01-01 — the constant that bridges Unix time and NASA CDF's millisecond epoch.

Returns

62167219200000.0.

Complexity

O(1).

Allocation

none.

System testsystests/test_cdf_epoch.purr
Example
import io
import space.time as st

# The Unix epoch expressed as a CDF_EPOCH (ms since year 0).
io.print(st.cdf_epoch_unix_offset_ms())                             # 62167219200000.0
io.print(st.unix_to_cdf_epoch(0.0) == st.cdf_epoch_unix_offset_ms())  # true
fn auto unix_to_jd(TimeInput auto &&seconds) #

Unix epoch seconds → Julian Date.

Parameters
seconds

scalar or numeric ndarray of seconds since 1970-01-01T00:00:00Z.

Returns

the corresponding Julian Date(s).

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_jd.purr systests/test_vectorized.purr
Example
import io
import ndarray
import space.time as st

io.print(st.unix_to_jd(946728000.0))               # 2451545.0 — J2000, exactly
let jds = st.unix_to_jd(ndarray.array([0.0, 86400.0]))   # a whole array, vectorized
io.print(jds[0], jds[1])                           # 2440587.5 2440588.5
fn auto jd_to_unix(TimeInput auto &&jd) #

Julian Date → Unix epoch seconds.

Parameters
jd

scalar or numeric ndarray of Julian Dates.

Returns

seconds since 1970-01-01T00:00:00Z.

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_jd.purr systests/test_vectorized.purr
Example
import io
import space.time as st

io.print(st.jd_to_unix(2451545.0))                 # 946728000.0 — J2000 in Unix seconds
io.print(st.jd_to_unix(st.unix_to_jd(86400.0)))    # 86400.0 — the round trip
fn auto jd_to_mjd(TimeInput auto &&jd) #

Julian Date → Modified Julian Date (MJD = JD − 2400000.5).

Parameters
jd

scalar or numeric ndarray of Julian Dates.

Returns

the Modified Julian Date(s).

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_mjd.purr
Example
import io
import space.time as st

io.print(st.jd_to_mjd(st.jd_j2000()))              # 51544.5 — J2000 as an MJD
fn auto mjd_to_jd(TimeInput auto &&mjd) #

Modified Julian Date → Julian Date.

Parameters
mjd

scalar or numeric ndarray of Modified Julian Dates.

Returns

the Julian Date(s).

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_mjd.purr
Example
import io
import space.time as st

io.print(st.mjd_to_jd(51544.5))                    # 2451545.0 — back to a full JD
fn auto unix_to_mjd(TimeInput auto &&seconds) #

Unix epoch seconds → Modified Julian Date.

Parameters
seconds

scalar or numeric ndarray of seconds since the Unix epoch.

Returns

the Modified Julian Date(s).

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_mjd.purr systests/test_vectorized.purr
Example
import io
import space.time as st

io.print(st.unix_to_mjd(0.0))                      # 40587.0 — the Unix epoch's MJD
fn auto mjd_to_unix(TimeInput auto &&mjd) #

Modified Julian Date → Unix epoch seconds.

Parameters
mjd

scalar or numeric ndarray of Modified Julian Dates.

Returns

seconds since the Unix epoch.

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_mjd.purr
Example
import io
import space.time as st

io.print(st.mjd_to_unix(40587.0))                  # 0.0 — the Unix epoch again
fn auto jd_to_j2000_seconds(TimeInput auto &&jd) #

Seconds elapsed since the J2000.0 epoch for a given Julian Date.

Parameters
jd

scalar or numeric ndarray of Julian Dates.

Returns

seconds since 2000-01-01T12:00:00.

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_j2000.purr
Example
import io
import space.time as st

io.print(st.jd_to_j2000_seconds(2451546.0))        # 86400.0 — one day after J2000
fn auto jd_to_j2000_centuries(TimeInput auto &&jd) #

Julian centuries (36525 days) elapsed since J2000.0 — the time argument for most astronomical polynomial series (mean sidereal time, precession, …).

Parameters
jd

scalar or numeric ndarray of Julian Dates.

Returns

Julian centuries since J2000.0.

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_j2000.purr
Example
import io
import space.time as st

# The `T` fed to precession / sidereal-time polynomial series.
io.print(st.jd_to_j2000_centuries(2451545.0 + 36525.0))   # 1.0 — one Julian century
fn auto unix_to_cdf_epoch(TimeInput auto &&seconds) #

Unix epoch seconds → NASA CDF_EPOCH (milliseconds since year 0).

Feeds space.cdf epoch variables directly. CDF_TT2000 (ns since J2000 with leap seconds) needs a leap-second table and lands with space.cdf — see the cdf roadmap.

Parameters
seconds

scalar or numeric ndarray of seconds since the Unix epoch.

Returns

the CDF_EPOCH value(s) in milliseconds.

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_cdf_epoch.purr systests/test_vectorized.purr
Example
import io
import space.time as st

# J2000 as a CDF_EPOCH — ready to write into a CDF epoch variable.
io.print(st.unix_to_cdf_epoch(946728000.0))        # 63113947200000.0
fn auto cdf_epoch_to_unix(TimeInput auto &&ms) #

NASA CDF_EPOCH (milliseconds since year 0) → Unix epoch seconds.

Parameters
ms

scalar or numeric ndarray of CDF_EPOCH values in milliseconds.

Returns

seconds since the Unix epoch.

Complexity

O(n).

Allocation

one result array for an ndarray; none for a scalar.

System testsystests/test_cdf_epoch.purr
Example
import io
import space.time as st

# An epoch read from a CDF file, back onto the Unix scale.
io.print(st.cdf_epoch_to_unix(63113947200000.0))   # 946728000.0 — J2000

Constants & variables

var std::int64_t gregorian_reform_jdn #

The Julian day number at which the Gregorian calendar takes over: 1582-10-15, the day Pope Gregory XIII's reform declared should follow 1582-10-04.

var bool is_datetime_v #

Trait hook for cheatah's forthcoming datetime struct.

It defaults to false; when cheatah::datetime defines its broken-down datetime type, this is specialized to true for it (here or there), which lights up DatetimeScalar / DatetimeArray and the datetime overloads — without this header taking a dependency on that type today.