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.cdfTemplated 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), ora numeric
ndarray(NumericArray— vectorized over the ndarray SIMD path), oran ndarray of datetime structs (
DatetimeArray) — lit up via theis_datetime_vtrait hook oncecheatah::datetimedefines 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.timeend 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
CalendarDate— A calendar date, with no time of day.CivilDate— A calendar date in the proleptic Gregorian calendar, astronomical year numbering.DateTime— A calendar date with a UT time of day — what IRBEM's date routines hand back.
Concepts
DatetimeArray— An ndarray of datetime structs (see is_datetime_v) — lit up when that struct lands.DatetimeScalar— A single broken-down datetime value (see is_datetime_v).Numeric— A scalar that makes sense as a continuous time/epoch quantity — any arithmetic type.NumericArray— An ndarray with a numeric (arithmetic) entry — the vectorized continuous-time input.TimeInput— The full accepted domain: a sensible scalar, a numeric ndarray, or a datetime-struct ndarray.
Functions
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.
year | the year, astronomical numbering. |
month | 1-12. |
day | 1-based. |
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.
O(1).
none.
The calendar date of a Julian day number — the exact inverse of julian_day_number, including across the reform.
jdn | the Julian day number. |
the calendar date, in whichever calendar was in force at that JDN.
O(1).
none.
The day of year of a calendar date, leap-year correct.
year | the year. |
month | 1-12. |
day | 1-based. |
the day of year, 1 = January 1.
O(1).
none.
How many days a year has.
year | the year. |
366 in a leap year, 365 otherwise — computed from the calendar rather than from the leap rule directly, so the two can never disagree.
O(1).
none.
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.
year | the year, astronomical numbering. |
doy | the day of year, 1 = January 1. Values outside |
the calendar date.
O(1).
none.
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.
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. |
the decimal year.
O(1).
none.
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.
decy | the decimal year, |
the instant, with month, day, day of year, h:m:s and seconds-of-day all filled in.
O(1).
none.
Year, day of year and UT seconds to a broken-down instant — IRBEM's DOY_AND_UT2DATE_AND_TIME.
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 |
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.
O(1).
none.
Whether year is a leap year under proleptic Gregorian rules.
year | astronomical year number. |
true when year has 366 days.
O(1).
none.
systests/test_civil.purrimport 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 400The number of days in a given month.
year | astronomical year number (decides February's length). |
month | 1–12; anything outside that range yields 0. |
days in the month, or 0 when month is out of range.
O(1).
none.
systests/test_civil.purrimport io
import space.time as st
io.print(st.days_in_month(2024, 2)) # 29
io.print(st.days_in_month(2026, 2)) # 28Whether date names a day that exists.
date | the date to check. |
true when the month is 1–12 and the day is within that month's length.
O(1).
none.
systests/test_civil.purrimport 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 notBuild 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().
year | astronomical year number. |
month | 1–12 for a valid date. |
day | 1–31 for a valid date. |
the assembled date.
O(1).
none.
systests/test_civil.purrimport io
import space.time as st
let d = st.civil_date(2026, 8, 28)
io.print(d.year, d.month, d.day) # 2026 8 28Days 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.
year | astronomical year number. |
month | 1–12. |
day | 1–31. |
days before (negative) or after (positive) 1970-01-01.
O(1).
none.
systests/test_civil.purrimport 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 dayThe proleptic Gregorian date for a count of days since the Unix epoch.
The exact inverse of days_from_civil() over the whole representable range.
days | days before (negative) or after (positive) 1970-01-01. |
the calendar date.
O(1).
none.
systests/test_civil.purrimport io
import space.time as st
let d = st.civil_from_days(10957)
io.print(d.year, d.month, d.day) # 2000 1 1The 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.
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.
year | astronomical year number. |
month | 1–12. |
day | 1–31. |
the Julian Day number of the noon that begins this date's Julian day.
O(1).
none.
systests/test_civil.purrimport 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)) # 2451545Julian Date of the Unix epoch, 1970-01-01T00:00:00Z.
2440587.5.
O(1).
none.
systests/test_jd.purrimport 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()) # trueJulian Date of the J2000.0 epoch, 2000-01-01T12:00:00 TT.
2451545.0.
O(1).
none.
systests/test_jd.purrimport 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 originCDF_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.
62167219200000.0.
O(1).
none.
systests/test_cdf_epoch.purrimport 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()) # trueUnix epoch seconds → Julian Date.
seconds | scalar or numeric ndarray of seconds since 1970-01-01T00:00:00Z. |
the corresponding Julian Date(s).
O(n).
one result array for an ndarray; none for a scalar.
systests/test_jd.purr systests/test_vectorized.purrimport 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.5Julian Date → Unix epoch seconds.
jd | scalar or numeric ndarray of Julian Dates. |
seconds since 1970-01-01T00:00:00Z.
O(n).
one result array for an ndarray; none for a scalar.
systests/test_jd.purr systests/test_vectorized.purrimport 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 tripJulian Date → Modified Julian Date (MJD = JD − 2400000.5).
jd | scalar or numeric ndarray of Julian Dates. |
the Modified Julian Date(s).
O(n).
one result array for an ndarray; none for a scalar.
systests/test_mjd.purrimport io
import space.time as st
io.print(st.jd_to_mjd(st.jd_j2000())) # 51544.5 — J2000 as an MJDModified Julian Date → Julian Date.
mjd | scalar or numeric ndarray of Modified Julian Dates. |
the Julian Date(s).
O(n).
one result array for an ndarray; none for a scalar.
systests/test_mjd.purrimport io
import space.time as st
io.print(st.mjd_to_jd(51544.5)) # 2451545.0 — back to a full JDUnix epoch seconds → Modified Julian Date.
seconds | scalar or numeric ndarray of seconds since the Unix epoch. |
the Modified Julian Date(s).
O(n).
one result array for an ndarray; none for a scalar.
systests/test_mjd.purr systests/test_vectorized.purrimport io
import space.time as st
io.print(st.unix_to_mjd(0.0)) # 40587.0 — the Unix epoch's MJDModified Julian Date → Unix epoch seconds.
mjd | scalar or numeric ndarray of Modified Julian Dates. |
seconds since the Unix epoch.
O(n).
one result array for an ndarray; none for a scalar.
systests/test_mjd.purrimport io
import space.time as st
io.print(st.mjd_to_unix(40587.0)) # 0.0 — the Unix epoch againSeconds elapsed since the J2000.0 epoch for a given Julian Date.
jd | scalar or numeric ndarray of Julian Dates. |
seconds since 2000-01-01T12:00:00.
O(n).
one result array for an ndarray; none for a scalar.
systests/test_j2000.purrimport io
import space.time as st
io.print(st.jd_to_j2000_seconds(2451546.0)) # 86400.0 — one day after J2000Julian centuries (36525 days) elapsed since J2000.0 — the time argument for most astronomical polynomial series (mean sidereal time, precession, …).
jd | scalar or numeric ndarray of Julian Dates. |
Julian centuries since J2000.0.
O(n).
one result array for an ndarray; none for a scalar.
systests/test_j2000.purrimport 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 centuryUnix 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.
seconds | scalar or numeric ndarray of seconds since the Unix epoch. |
the CDF_EPOCH value(s) in milliseconds.
O(n).
one result array for an ndarray; none for a scalar.
systests/test_cdf_epoch.purr systests/test_vectorized.purrimport 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.0NASA CDF_EPOCH (milliseconds since year 0) → Unix epoch seconds.
ms | scalar or numeric ndarray of CDF_EPOCH values in milliseconds. |
seconds since the Unix epoch.
O(n).
one result array for an ndarray; none for a scalar.
systests/test_cdf_epoch.purrimport 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 — J2000Constants & variables
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.
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.
