Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
# the test kernels live in git-lfs (~0.5 GB); download them once per content
# change and share via the actions cache so matrix jobs never re-download
lfs-kernels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Compute LFS content key
run: git lfs ls-files -l -I "test/inputs/**" | cut -d' ' -f1 | sort > .lfs-ids

- name: Cache LFS objects
id: lfs-cache
uses: actions/cache@v4
with:
path: .git/lfs
key: lfs-inputs-${{ hashFiles('.lfs-ids') }}

- name: Download kernels on cache miss
if: steps.lfs-cache.outputs.cache-hit != 'true'
run: git lfs pull -I "test/inputs/**"

test:
needs: lfs-kernels
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4

- name: Compute LFS content key
run: git lfs ls-files -l -I "test/inputs/**" | cut -d' ' -f1 | sort > .lfs-ids

- name: Restore LFS objects
uses: actions/cache@v4
with:
path: .git/lfs
key: lfs-inputs-${{ hashFiles('.lfs-ids') }}

- name: Materialize kernels
run: git lfs pull -I "test/inputs/**"

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest

- name: Run tests
run: pytest -q

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Build wheel and sdist
run: |
python -m pip install --upgrade pip build
python -m build

- name: Check the wheel installs and imports
run: |
pip install dist/*.whl
python -c "from jpl_time import Time; print(Time('2022-001T00:00:00').to_utc())"
jpl_time -t 2022-001T00:00:00 -o isoc
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@
*.pyc
*.html
*.DS_Store
__pycache__/
*.egg-info/
build/
dist/
docs/_build/
.pytest_cache/
venv/
jpl_time_venv/
530 changes: 161 additions & 369 deletions README.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions docs/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#

# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build

# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

.PHONY: help Makefile

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
50 changes: 50 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
API Reference
=============

Time
----

.. autoclass:: jpl_time.Time
:members:
:undoc-members:

Duration
--------

.. autoclass:: jpl_time.Duration
:members:
:undoc-members:

EpochRelativeTime
-----------------

.. autoclass:: jpl_time.EpochRelativeTime
:members:
:undoc-members:

Kernel and configuration functions
----------------------------------

.. autofunction:: jpl_time.load_chronos_config

.. autofunction:: jpl_time.download_latest_kernels

.. autofunction:: jpl_time.check_for_latest_kernels

CLI conversion helpers
----------------------

.. autofunction:: jpl_time.create_jpl_time_object

.. autofunction:: jpl_time.output_jpl_time_in_format

.. autofunction:: jpl_time.convert_jpl_time

.. autofunction:: jpl_time.convert_to_all_formats

Exceptions
----------

.. automodule:: jpl_time._exceptions
:members:
:undoc-members:
32 changes: 32 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Sphinx configuration for the jpl_time documentation.
# Build with: make html (from the docs directory, with sphinx installed)

import os
import sys

sys.path.insert(0, os.path.abspath('..'))

project = 'jpl_time'
copyright = '2026, Flora Ridenhour'
author = 'Flora Ridenhour'
release = '2.0.0'

extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.viewcode',
]

templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']

# use the readthedocs theme when available, otherwise the builtin default
try:
import sphinx_rtd_theme # noqa: F401
html_theme = 'sphinx_rtd_theme'
except ImportError:
html_theme = 'alabaster'

html_static_path = ['_static']

# document members in source order to match the class layout
autodoc_member_order = 'bysource'
137 changes: 137 additions & 0 deletions docs/developer_guide.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
Developer Guide
===============

Architecture
------------

The public API is defined in ``jpl_time/__init__.py`` and re-exported by the
legacy module path ``jpl_time.jpl_time`` (kept so that
``from jpl_time.jpl_time import Time`` continues to work). The implementation
lives in focused internal modules:

``_leapseconds.py``
The pure-Python time core: the leap second table, the DELTET formula
relating TAI/TDT/TDB, the UTC string parser (a ``str2et`` equivalent) and
the UTC formatter (an ``et2utc`` equivalent). No SPICE.

``_duration.py``
The ``Duration`` class. Stores a float number of Earth seconds. No SPICE.

``_time.py``
The ``Time`` class. Stores ET (TDB) seconds past J2000. Uses
``_leapseconds`` for everything UTC/TAI-related and ``_spice`` for
kernel-dependent conversions.

``_lmst.py``
LMST string handling plus the linear (kernel-free) LMST model.

``_spice.py``
Every spiceypy call in the library: kernel management and the SCLK, OWLT,
LTST and kernel-LMST conversions. Whenever the loaded kernels change, the
active leap second table is re-synced (see below).

``_epoch_relative.py``, ``_config.py``, ``_cli.py``, ``_exceptions.py``
``EpochRelativeTime``, CHRONOS config loading / NAIF kernel downloads, the
command line interface, and the exception hierarchy.

``jpl_time_utilities/``
Lower-level helpers kept at their historical import paths: shared
constants and regexes (``time_globals``), CHRONOS setup parsing
(``chronos_input_parsing``) and NAIF kernel fetching
(``fetch_latest_kernels``).

How the SPICE-free time core works
----------------------------------

All times are stored as ET (TDB) seconds past J2000, exactly as before. The
conversion chain for a UTC string is::

UTC calendar components -> naive UTC seconds -> (+ leap seconds) TAI -> (DELTET) ET

* **Leap seconds**: ``_leapseconds`` embeds the DELTA_AT table from the NAIF
leapseconds kernel. Before the first table entry (1972) SPICE assumes one
fewer leap second, and jpl_time reproduces that. Times inside a leap second
format as ``23:59:60.xxx``.
* **DELTET**: ET - TAI = ``DELTA_T_A + K*sin(E)`` where ``E = M + EB*sin(M)``
and ``M = M0 + M1*ET``. The TAI->ET direction inverts the relation by
fixed-point iteration, matching CSPICE UNITIM to double precision (~3e-14 s).
* **Formatting** rounds in integer units of 10^-precision seconds on the TAI
timeline, so rounding carries correctly across minutes/days/years and into
leap seconds. Outputs match ``spice.et2utc`` exactly, except >100 years in
the future at precision 6, where SPICE's own double-precision jitter can
differ by 1 microsecond.
* **Parsing** supports the common ``str2et`` grammar (ISO day-of-year and
calendar forms, US-style dates, month names, AM/PM, Julian dates, TDB/TDT
scales, leap seconds). For exotic formats outside that grammar, the ``Time``
constructor falls back to ``spice.str2et`` when a leapseconds kernel is
loaded in SPICE.

Leap second kernel syncing
--------------------------

``Time.load_kernel``/``load_kernels``/``reload_kernels``/``unload_*`` all go
through ``_spice``, which after any change re-scans the loaded text kernels:
the most recently loaded leapseconds kernel is parsed into the active
pure-Python table, and if none is loaded the built-in default table is
restored. This keeps the pure-Python core and SPICE consistent no matter which
LSK is in use.

LMST models
-----------

``to_lmst``/``from_lmst`` first try the LMST SCLK kernel through SPICE. If the
kernel conversion fails and a linear epoch has been configured with
``Time.set_lmst_epoch``, the linear model is used: LMST seconds =
``(et - epoch_et) / rate`` with the kernel rate (88775.244 s/sol) by default.
Linear LMST is quantized to the kernel tick resolution (1e-5 mars seconds) so
its output strings match the kernel path exactly.

If a mission ever publishes an LMST kernel with multiple coefficient records
(a rate correction), the linear model will diverge from the kernel after the
correction; the kernel always takes precedence when loaded.

Adding a mission
----------------

Nothing mission-specific lives in the library: configure the NAIF ids
(``set_spacecraft_id_and_lmst_id``) and load the mission kernels. To support
``jpl_time -f <mission>`` downloads, add the mission to ``SUPPORTED_MISSIONS``
in ``jpl_time_utilities/fetch_latest_kernels.py`` (it must have a CHRONOS
setup directory on the NAIF server).

Testing
-------

.. code-block:: bash

pip install -e .[dev]
pytest

The suite has three layers:

* **Unit tests** (``test_duration.py``, ``test_time_*.py``, ``test_lmst.py``,
``test_epoch_relative.py``, ``test_cli.py``, ``test_kernels.py``): most run
with no kernels loaded; kernel-dependent tests use the mission fixtures in
``conftest.py`` and skip automatically when ``test/inputs`` kernels are not
available.
* **SPICE conversions** (``test_spice_conversions.py``): SCLK/OWLT/LTST/GST
against the M2020/MSL/InSight/Psyche kernel sets, including regression cases
from operational incidents.
* **Regression baselines** (``test_regression_baseline.py``): replays
``test/regression_data/*.json``, generated from jpl_time 1.4.5 by
``test/generate_regression_baseline.py``. These pin the compatibility
contract — the SPICE-free implementation must reproduce the old SPICE-based
outputs exactly. Regenerate the baselines only when intentionally changing
behavior (e.g. a new leap second kernel).

State isolation: the ``no_kernels`` fixture guarantees a clean SPICE-free
state, and an autouse fixture restores the class-level defaults (comparison
precision, output precision) after every test.

Compatibility rules
-------------------

The 1.x public interface is frozen: do not remove or reorder existing
parameters, change return types, or alter output string formats. New
parameters must have defaults. ``jpl_time.jpl_time`` must keep re-exporting
the full public API.
23 changes: 23 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
jpl_time
========

jpl_time provides ``Time`` and ``Duration`` classes for spacecraft time
conversions and math. Basic operations (UTC parsing and formatting, TAI/GPS/ET
conversions, time zones, time and duration math) work out of the box with a
built-in leap second table; mission-specific conversions (SCLK, LMST from
kernels, LTST, one-way light time) use SPICE kernels.

.. toctree::
:maxdepth: 2
:caption: Contents:

user_guide
developer_guide
api

Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
35 changes: 35 additions & 0 deletions docs/make.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@ECHO OFF

pushd %~dp0

REM Command file for Sphinx documentation

if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build

if "%1" == "" goto help

%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)

%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end

:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%

:end
popd
Loading
Loading