EphCore
Pure Elixir ephemeris computation library. Provides high-precision astronomical calculations for celestial body positions, time conversions, and coordinate transformations.
Installation
Add eph_core to your dependencies in mix.exs:
def deps do
[
{:eph_core, "~> 0.1.0"}
# Or from GitHub:
# {:eph_core, github: "jakedjohnson/eph_core"}
]
end
Data setup
EphCore does not commit or package third-party ephemeris kernels, Earth
orientation tables, or star catalogs. The application will not start until
the baseline files below are present. Nothing under priv/ephemeris/ or
priv/stars/ is included in the Hex package.
From a fresh clone or a consuming Mix app, at the project root:
mix deps.get
mix eph.download_kernels
mix test
mix eph.download_kernels writes under $PWD/priv. Runtime uses that
directory when the baseline files are there; otherwise it uses the compiled
:eph_core priv directory. Set :kernel_base_dir when the process working
directory is not the app root. Hipparcos stars are optional and are not
required to boot or to run the core tests.
Baseline ephemeris data
mix eph.download_kernels
This downloads into $PWD/priv/ephemeris/ by default (~32 MB total):
| File | Relative path | Source | Approx. size |
|---|---|---|---|
de440s.bsp | ephemeris/spk/de440s.bsp | NASA JPL / NAIF | ~31 MB |
finals2000A.all | ephemeris/time/finals2000A.all | IERS | ~3 MB |
naif0012.tls | ephemeris/time/naif0012.tls | NASA JPL / NAIF | ~5 KB |
tab5.3a.txt | ephemeris/nutation/tab5.3a.txt | IERS Conventions | ~200 KB |
tab5.3b.txt | ephemeris/nutation/tab5.3b.txt | IERS Conventions | ~200 KB |
Asteroid SPK kernels
Generate a small-body SPK via the JPL Horizons API:
mix eph.generate_kernel 2000001
Output defaults to priv/ephemeris/spk/asteroids/2000001.bsp. See
mix help eph.generate_kernel for date range and output options.
Optional: Hipparcos fixed stars
Fixed-star features (EphCore.Stars.*) require the Hipparcos main catalog:
mix eph.setup_stars
This places hip_main.dat (~51 MB) at priv/stars/hip_main.dat. If the
download fails, fetch the file manually from the
VizieR I/239 catalog (ESA
Hipparcos) and copy it to that path. Core ephemeris features work without it.
Custom data directory
:kernel_base_dir is not required for a Mix app started from its project
root after mix eph.download_kernels. Set it for releases, containers, Livebook
sessions whose working directory is not the repo, or any process whose cwd is
not the app root:
config :eph_core,
kernel_base_dir: "/var/eph_core/data"
Both ephemeris/ and stars/ are resolved under this base (for example
/var/eph_core/data/ephemeris/spk/de440s.bsp and
/var/eph_core/data/stars/hip_main.dat). The Mix download tasks honor the
same config when it is already set.
Third-party attribution
- NASA JPL / NAIF — planetary SPK kernels (
de440s.bsp), leap-second kernel (naif0012.tls). See NAIF. - IERS — Earth orientation parameters (
finals2000A.all) and IAU 2000A nutation tables. See IERS and IERS Conventions. - ESA Hipparcos — star catalog (
hip_main.dat, VizieR catalog I/239). See Hipparcos at ESA.
Usage
# Compute sky positions for celestial bodies
datetime = ~U[2026-02-02 12:00:00Z]
location = %{lat: 44.9778, lon: -93.2650, height: 250}
bodies = [:sun, :moon, :mars]
{:ok, observation} = EphCore.observe(datetime, location, bodies)
# Access results
observation.bodies[:sun]
# => %EphCore.SnapshotPipeline.SkyPosition{
# altitude_deg: ...,
# azimuth_deg: ...,
# topocentric_range_km: ...,
# ...
# }
Supported Targets
EphCore.observe/4 accepts the solar-system targets that are present in the
loaded SPK kernels:
- Sun, Moon, and planets:
:sun,:moon,:mercury,:venus,:mars,:jupiter,:saturn,:uranus,:neptune,:pluto - Named asteroid kernels generated by
mix eph.generate_kernel::ceres,:pallas,:juno,:vesta,:chiron
# Requires priv/ephemeris/spk/asteroids/2000001.bsp
{:ok, observation} = EphCore.observe(datetime, location, [:ceres])
observation.bodies[:ceres].altitude_deg
Fixed stars use the Hipparcos catalog API rather than observe/4:
{:ok, regulus} = EphCore.Stars.Catalog.lookup(49_669)
position =
EphCore.Stars.Position.compute(regulus, jd_tt,
observer: %{lat_deg: 44.9778, lon_deg: -93.2650, height_m: 250},
lst_deg: 120.0
)
position.altitude_deg
Future releases should consolidate these target types behind EphCore.observe/4
so callers can pass one mixed target list for the observed celestial sphere. See
#1 for the planned API
direction.
observe/4 — Options Reference
EphCore.SnapshotPipeline.observe(datetime, location, bodies, opts) accepts four
top-level option keys: :models, :corrections, :motion, and :geometry. Each is a
map; any omitted sub-key falls back to the default. Invalid values fail fast with an
{:error, changeset} from EphCore.SnapshotPipeline.Intent.
| Key | Sub-key | Type | Default | Validation | What it controls |
|---|---|---|---|---|---|
models | delta_t | :iers | :approximate | :iers | enum | ΔT source for UTC→TT. Use :approximate only for rough work. |
models | earth_orientation | :gmst | :gast | :gmst | enum | Sidereal-time model; :gast adds the equation of equinoxes (~1″). |
models | earth | :wgs84 | :wgs84 | enum | Reference ellipsoid (only WGS84 today). |
models | ecliptic_frame | :j2000 | :mean_of_date | :true_of_date | :true_of_date | enum | Ecliptic reference plane; :true_of_date includes precession + nutation. |
corrections | precession_nutation | boolean | false | boolean | Legacy flag; nutation is always applied in :true_of_date/:mean_of_date. Kept for back-compat. |
corrections | aberration | boolean | false | boolean | Annual aberration (~20.5″). Shifts ecliptic longitude ±5–25″ per body. |
corrections | light_time | boolean | false | boolean | Light-time retardation (body seen where it was τ ago). Dominant term in station-time error (~3h for Neptune). |
motion | enabled | boolean | true | boolean | Compute longitude rate (deg/day) via central difference. |
motion | dt_minutes | 1..1440 | 30 | integer in range | Half-window for the finite-difference rate; smaller = noisier near a station. |
geometry | ring_samples | 0 or 8..72 | 24 | integer (0, or clamped to 8..72) | 3D ring-arc samples for frontend geometry; 0 disables. |
Apparent geocentric requires both flags.
aberration: trueandlight_time: truetogether enable the apparent-geocentric path (Intent.apparent_geocentric?/1). When set, each body gainsapparent_geocentric_ecliptic_longitude/_latitude, and (with motion enabled)apparent_geocentric_ecliptic_lon_rate_deg_per_day. The default geocentricecliptic_longitudeandtopocentric_ecliptic_longitudefields are always retained.
# Default (geometric) call
EphCore.SnapshotPipeline.observe(datetime, location, bodies)
# Full apparent-geocentric call
EphCore.SnapshotPipeline.observe(datetime, location, bodies,
corrections: %{aberration: true, light_time: true},
motion: %{enabled: true, dt_minutes: 30}
)
Frame Semantics — When to Use What
There are two independent axes. Callers combine them to select a frame:
- Geocentric vs topocentric — is the body projected from Earth's center or from the observer's surface location? Topocentric adds diurnal parallax (largest for the Moon, ~57′; sub-arcsecond for outer planets).
- Geometric vs apparent — geometric is the instantaneous true position; apparent applies
light-time retardation (body at retarded epoch
jd_tt − τ) and annual aberration (~20.5″).
Practical rules:
- Topocentric geometric (default; no
corrections): projected from the observer's location, no light-time or aberration. Use when the observer's vantage point matters and almanac-style timing is not required. - Apparent geocentric (
corrections: %{aberration: true, light_time: true}, no observer subtraction): matches published almanac positions and event instants at the geocenter. Use for timing longitude-rate zero-crossings (retrograde stations) or longitude-threshold crossings, where a near-zero rate amplifies a small position difference into a multi-hour timing error. - Alt/az and horizon events (rise/set/transit): use the
EphCore.Events.Almanac/EphCore.Corrections.ApparentPlacepath, which is separate from this snapshot pipeline.
All positions are true-of-date (precession + IAU 2000A nutation) by default. Nutation is
always applied for :true_of_date; the precession_nutation correction flag is legacy.
Compute-Cost Guidance
Relative to the default geometric snapshot (1 SPK evaluation per body per timestamp):
light_time: true— +2 SPK re-evaluations per body (2 light-time iterations; range barely changes after the first). For a 10-body snapshot: ~+20 SPK calls, negligible wall time.aberration: true— +2 SPK evaluations for Earth velocity (central difference, shared across all bodies and cached per frame), plus one vector op per body. Negligible.motion: true(default) — +2 SPK evaluations per body att ± dt_minutes. With apparent enabled, each of those±tevaluations also runs the apparent-place pass, so motion + apparent ≈ 3× the SPK calls vs geometric motion alone. Station bisection calls the probe ~50–60 times, so benchmark before shipping a hot path.
EphCore.Corrections.ApparentPlace.frame/2 caches per-timestamp shared work and accepts a
reused :nutation / :earth_velocity — reuse it across bodies at the same instant.
Worked Example — Neptune Retrograde Station (2026-07-07)
Near a station the longitude rate ≈ 0, so a fixed position offset of ε degrees shifts the zero-crossing time by ε / (dω/dt) — which is hours for an outer planet. That is why the choice of frame moves the station time by ~4.9h even though the positions differ by arcseconds.
| Frame | Swiss Ephemeris | Skyfield (DE440s) | eph_core |
|---|---|---|---|
| apparent geocentric (almanac standard) | 10:54:37Z | 10:54:57Z | ~10:54Z (corrections: apparent) |
| geometric geocentric | 07:33:39Z | 06:45:25Z | 08:06:05Z |
| geometric topocentric | 05:56:25Z | 05:46:29Z | 06:01:45Z (default) |
# Default (geometric topocentric) — ~4h53m early vs almanacs
{:ok, snap} = EphCore.SnapshotPipeline.observe(dt, minneapolis, [:neptune],
motion: %{enabled: true, dt_minutes: 30})
snap.solar_system_positions[:neptune].ecliptic_longitude
# geometric longitude; drives the ~06:01:45Z station in the default path
# Apparent geocentric — matches published almanacs (~10:54Z)
{:ok, snap} = EphCore.SnapshotPipeline.observe(dt, minneapolis, [:neptune],
corrections: %{aberration: true, light_time: true},
motion: %{enabled: true, dt_minutes: 30})
snap.solar_system_positions[:neptune].apparent_geocentric_ecliptic_longitude
snap.motion[:neptune].apparent_geocentric_ecliptic_lon_rate_deg_per_day
Rule of thumb: event-timing detectors (stations, ingresses) should consume the apparent-geocentric rate, while "where is it in the sky from here" answers stay topocentric geometric.
Documentation
Livebook tours
Open any .livemd in Livebook (Desktop is fine). Each notebook
puts Mix.install in Livebook’s setup cell (top of the file, before sections),
uses the local eph_core path as a normal runtime dependency, and loads de440s.bsp only. Run mix eph.download_kernels in the
repo before evaluating a setup cell — EphCore will not start without baseline files.
After editing a notebook on disk, re-evaluate changed cells in Livebook (or close and re-open the session) to pick up updates.
| Notebook | What it teaches |
|---|---|
| Getting Started | observe/4 lab: sky table, frame choices, and city fan-out |
| Snapshot Pipeline | Stage-by-stage walk of observe/4 → %Observation{} |
| Time & Sidereal | UTC → TAI → TT → UT1 → GMST/GAST → LMST/LAST |
| Kernels & Chebyshev | DAF → ETS → Type 2 → Clenshaw; NAIF IDs; raw SPK queries |
| Celestial Geometry | Geodetic/ECEF, ecliptic frames, projection, the celestial sphere |
| Apparent Place | Light-time + annual aberration; geometric vs apparent; parallax |
| Time Series | Grid compute + horizon geometry (charts / event searches) |
| Almanac | Rise/transit/set fan-out (EphCore.Events.Almanac) |
| Skyfield Snapshot | One-shot EphCore vs Skyfield (Pythonx): timing + like-for-like diffs |
| Fixed Stars | Hipparcos catalog, proper motion, alt/az sky board (EphCore.Stars) |
| Retrograde & Stations | Retrograde loops, station finding, topocentric vs geocentric rate |
Contributing
Sanity-check tools for validating against external sources (Skyfield, IERS) live in dev/tasks/. They are compiled only in the :dev and :test environments and are not part of the published package.
When changing public APIs or the modules behind them, update the relevant
Livebook tours in notebooks/ in the same change. The tours should call
EphCore's public functions wherever possible rather than reimplementing the
library logic inside notebook cells; this keeps notebook breakage visible when
the dependency API changes.
To run sanity checks:
# Requires python3 + skyfield installed
mix eph.sky_position_sanity_check
mix eph.time_conversion_sanity_check
License
MIT