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):

FileRelative pathSourceApprox. size
de440s.bspephemeris/spk/de440s.bspNASA JPL / NAIF~31 MB
finals2000A.allephemeris/time/finals2000A.allIERS~3 MB
naif0012.tlsephemeris/time/naif0012.tlsNASA JPL / NAIF~5 KB
tab5.3a.txtephemeris/nutation/tab5.3a.txtIERS Conventions~200 KB
tab5.3b.txtephemeris/nutation/tab5.3b.txtIERS 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

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:

# 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.

KeySub-keyTypeDefaultValidationWhat it controls
modelsdelta_t:iers | :approximate:iersenumΔT source for UTC→TT. Use :approximate only for rough work.
modelsearth_orientation:gmst | :gast:gmstenumSidereal-time model; :gast adds the equation of equinoxes (~1″).
modelsearth:wgs84:wgs84enumReference ellipsoid (only WGS84 today).
modelsecliptic_frame:j2000 | :mean_of_date | :true_of_date:true_of_dateenumEcliptic reference plane; :true_of_date includes precession + nutation.
correctionsprecession_nutationbooleanfalsebooleanLegacy flag; nutation is always applied in :true_of_date/:mean_of_date. Kept for back-compat.
correctionsaberrationbooleanfalsebooleanAnnual aberration (~20.5″). Shifts ecliptic longitude ±5–25″ per body.
correctionslight_timebooleanfalsebooleanLight-time retardation (body seen where it was τ ago). Dominant term in station-time error (~3h for Neptune).
motionenabledbooleantruebooleanCompute longitude rate (deg/day) via central difference.
motiondt_minutes1..144030integer in rangeHalf-window for the finite-difference rate; smaller = noisier near a station.
geometryring_samples0 or 8..7224integer (0, or clamped to 8..72)3D ring-arc samples for frontend geometry; 0 disables.

Apparent geocentric requires both flags.aberration: trueandlight_time: true together enable the apparent-geocentric path (Intent.apparent_geocentric?/1). When set, each body gains apparent_geocentric_ecliptic_longitude / _latitude, and (with motion enabled) apparent_geocentric_ecliptic_lon_rate_deg_per_day. The default geocentric ecliptic_longitude and topocentric_ecliptic_longitude fields 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:

Practical rules:

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):

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.

FrameSwiss EphemerisSkyfield (DE440s)eph_core
apparent geocentric (almanac standard)10:54:37Z10:54:57Z~10:54Z (corrections: apparent)
geometric geocentric07:33:39Z06:45:25Z08:06:05Z
geometric topocentric05:56:25Z05:46:29Z06: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.

NotebookWhat it teaches
Getting Startedobserve/4 lab: sky table, frame choices, and city fan-out
Snapshot PipelineStage-by-stage walk of observe/4%Observation{}
Time & SiderealUTC → TAI → TT → UT1 → GMST/GAST → LMST/LAST
Kernels & ChebyshevDAF → ETS → Type 2 → Clenshaw; NAIF IDs; raw SPK queries
Celestial GeometryGeodetic/ECEF, ecliptic frames, projection, the celestial sphere
Apparent PlaceLight-time + annual aberration; geometric vs apparent; parallax
Time SeriesGrid compute + horizon geometry (charts / event searches)
AlmanacRise/transit/set fan-out (EphCore.Events.Almanac)
Skyfield SnapshotOne-shot EphCore vs Skyfield (Pythonx): timing + like-for-like diffs
Fixed StarsHipparcos catalog, proper motion, alt/az sky board (EphCore.Stars)
Retrograde & StationsRetrograde 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