Pure-Elixir reader for the JPL DE440 planetary ephemeris. Computes topocentric body positions, finds when a body passes behind a landmark or crosses a fixed direction, names the current Moon phase, and pins down sunrise / twilight / golden hour — all from a single in-process binary, no service or HTTP call.
Validated end-to-end:
- JPL HORIZONS — apparent alt/az and RA/Dec match to ~2″ across the full 1550–2650 ephemeris range.
- ERFA / IAU SOFA — Hohenkerk-Sinclair refraction agrees within 1″ across 10 atmospheres × 11 altitudes.
- GeographicLib (Karney) — WGS-84 geodesic distance and bearing to sub-millimeter / sub-microarcsec.
Predicts the AMNH 2026 Manhattanhenge minutes exactly (see the quickstart). Zero runtime dependencies — only stdlib (Erlang/OTP + Elixir core).
DE440— opens the JPL Linux binary, parses the header, evaluates Chebyshev coefficients via Clenshaw recurrence to give position (and optional velocity) for any of 11 bodies plus EMB, the Moon, and (where the file tabulates them) nutation, libration, mantle-velocity, and TT−TDB. Verified against JPL'stestpo.440to the round-off floor.DE440.Sky— apparent topocentric altitude/azimuth/distance and apparent geocentric RA/Dec for any body. Full reduction chain: light-time, annual aberration, precession (IAU 2006), nutation (IAU 1980, 4-term truncation), GMST → GAST, WGS-84 observer geometry, refraction. Cross-checked against JPL HORIZONS to ~2″ across 1550–2650 (see "Accuracy" below).DE440.Refraction— Saemundsson 1986 closed-form (default, fast) and Hohenkerk-Sinclair 1985 ray-trace (opt-in via anAtmospherestruct: P, T, RH, λ). HS matches ERFA/IAU SOFA to ≤ 1″ and is smooth through the visible horizon — needed for Manhattanhenge-grade timing, surveying, and marine sextant work.DE440.Skyline— given a fixed direction or a landmark, finds when a body's descending path passes that direction. Also finds rising/setting azimuth crossings for street-grid alignment problems. Coarse grid scan + refinement, warm-start path for stepped queries.DE440.MoonPhase— phase angle, illuminated fraction, ecliptic elongation, named phase (by elongation, not by phase angle).DE440.SolarEvents— sunrise/sunset, civil/nautical/astronomical twilight, solar noon, golden hour, blue hour. Cross-checked against USNO and Skyfield to ≤2 seconds.DE440.Geodesy— bearing and distance between two lat/lon points on the WGS-84 ellipsoid by four methods: equirectangular flat-earth, spherical haversine, Vincenty 1975 inverse, and Karney 2013 inverse. Validated against Charles Karney's reference GeographicLib to sub-millimeter / sub-microarcsec.DE440.NutationandDE440.Time— supporting math: Espenak-Meeus Delta-T (pre-1972, ≤1 s back to 1500), leap seconds (1972+), TDB periodic series, and the IAU 1980 nutation expansion.
Requires Elixir 1.17 or newer. CI covers Elixir 1.17 through the latest 1.20 release candidate on OTP 27/28.
def deps do
[{:de440, "~> 0.1"}]
endThen download the ~100 MB ephemeris file from JPL once during build or
deploy. From an app that depends on :de440, use the library function
(it works after mix deps.get and verifies byte size + SHA256):
mix run -e 'DE440.EphemerisFile.fetch!()'From a git checkout of this repo the bundled Mix task is equivalent and slightly tidier:
mix ephemeris.fetch
mix run scripts/demo.exs
mix test # 118 tests + 18 properties + 12 doctests, ~13 sEvery verb is verb(eph, struct). Build the struct via the matching
builder; pass it to the verb.
{:ok, eph} = DE440.open_path("priv/linux_p1550p2650.440")
obs = DE440.Observer.new(40.7128, -74.0060)
# Where is the moon at a given moment?
aim = DE440.Sky.aim(:moon, obs, at: ~U[2026-05-04 21:00:00Z])
{:ok, p} = DE440.Sky.position(eph, aim)
p.altitude_deg # -65.44 (apparent — refraction-corrected)
p.true_altitude_deg # -65.44 (geometric; equal to apparent below the horizon)
p.azimuth_deg # 67.19
# Many positions in one call (uses internal block cache):
times = for h <- 0..23, do: DateTime.new!(~D[2026-05-04], Time.new!(h, 0, 0))
{:ok, ps} = DE440.Sky.positions(eph, DE440.Sky.aim(:moon, obs, at: times))
# When does the moon pass behind a landmark?
liberty = DE440.Landmark.new(40.6892, -74.0445, 93.0, name: "Statue of Liberty")
search = DE440.Skyline.search(:moon, obs, liberty,
from: ~U[2026-05-04 23:00:00Z], search_hours: 30 * 24)
{:ok, pass} = DE440.Skyline.passage(eph, search)
# Or a fixed (azimuth, altitude) target:
{:ok, pass} =
DE440.Skyline.passage(
eph,
DE440.Skyline.search(:moon, obs, {222.0, 6.0},
from: ~U[2026-05-04 23:00:00Z])
)
# When does the setting Sun cross the Manhattan grid bearing?
# Tyson & Faherty publicly cite 299.1° (their own grid measurement);
# AMNH's 2026 published minutes fit a refined 299.16° — round-to-
# nearest-minute lands on every one of the four events exactly.
# Pass an `:atmosphere` to use the Hohenkerk-Sinclair ray-trace so the
# apparent altitude stays accurate down to the horizon.
nyc_evening = DE440.Refraction.Atmosphere.standard(
pressure_mbar: 1015.0, temperature_c: 20.0,
relative_humidity: 0.60)
amnh = DE440.Observer.new(40.75348980, -73.98089025, 0.0,
name: "42nd & 5th", atmosphere: nyc_evening)
cs = DE440.Skyline.crossing_search(:sun, amnh, 299.16,
from: ~U[2026-05-29 00:00:00Z], search_hours: 1, direction: :setting)
{:ok, crossing} = DE440.Skyline.azimuth_crossing(eph, cs)
# crossing.utc = 2026-05-29T00:13:41Z → 8:14 PM ET, AMNH's "May 28 Half Sun"
# Solar events for a calendar date:
q = DE440.SolarEvents.query(obs, ~D[2026-05-04], time_zone: "America/New_York")
{:ok, day} = DE440.SolarEvents.day(eph, q)
day.sunrise # %DateTime{} in UTC
day.transit # %DateTime{} solar noon
day.golden_hour_morning # %{start: dt, end: dt}
# Moon phase:
{:ok, mp} = DE440.MoonPhase.at(eph, ~U[2026-05-01 17:23:00Z])
mp.illuminated_fraction # 0.998
mp.name # :full
mp.waxing # true (elongation 179.99° — approaching opposition)- Fetch the ephemeris during build/deploy, not during request handling.
Use
DE440.EphemerisFile.fetch!(output: PATH)if your release expects priv files under a subdirectory. - Open the file once at application start with
DE440.open_app_priv/2and share the resulting%DE440{}. It is immutable pure data; the ~100 MB binary is held off-heap and shared by reference between BEAM processes. - Keep
%DE440{}in your own app state. AvoidDE440.open_path/1orDE440.open_app_priv/2inside per-request code. - Prefer batch APIs (
Sky.positions/2,Skyline.passages/2,Skyline.azimuth_crossings/2,SolarEvents.day/2) for sweeps; they reuse an internal parsed-block cache.
In a Phoenix app, a small application-owned module works well:
defmodule MyApp.Ephemeris do
use GenServer
@key {__MODULE__, :de440}
def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def get, do: :persistent_term.get(@key)
@impl true
def init(_opts) do
{:ok, eph} = DE440.open_app_priv(:my_app)
:persistent_term.put(@key, eph)
{:ok, eph}
end
endAdd MyApp.Ephemeris to your supervision tree, then use it from request
code without reopening the file:
eph = MyApp.Ephemeris.get()
obs = DE440.Observer.new(40.7128, -74.0060)
aim = DE440.Sky.aim(:sun, obs, at: DateTime.utc_now())
{:ok, position} = DE440.Sky.position(eph, aim)The examples/phoenix_api/ directory has a clone-able demo app that
wraps these calls behind an HTTP API, including the Manhattanhenge
azimuth-crossing endpoint as its marquee route.
The combination "sub-arcsecond apparent positions" + "ray-traced horizon refraction" + "millimeter-grade geodesy" makes a handful of apps practical that are usually built on a stack of half-broken phone APIs:
- Street-grid sun alignments — Manhattanhenge, summer-solstice
Stonehenge-style civic events, urban planning.
Skyline's azimuth-crossing search lands on AMNH's published minute. - Landmark occultations — when does the Moon disappear behind a
named building from a chosen rooftop?
Skyline.passage/2solves this directly given the building's coordinates and height. - Outdoor production planning — sunrise / golden hour / blue
hour / civil twilight for any date and location, with proper
ellipsoid + refraction.
SolarEvents.day/2matches USNO to ≤ 2 s. - Sun-aided photography orientation — combine
Sky.positionwith a photo's EXIF GPS + GPS-time + Sun pixel to recover camera true heading to ~0.02° (≈ 100× better than the phone magnetometer). - Sextant-grade celestial navigation — apparent altitudes corrected via Hohenkerk-Sinclair are within ~1″ of professional sight-reduction output; usable for Sun, Moon, and bright planets.
- Surveying / sight-line checks —
Geodesy.karney/2for millimeter-accurate baseline distances and azimuths on the WGS-84 ellipsoid.
The library accepts any TZ-aware DateTime as input
(Sky.aim/3's :at, Skyline.search/4's :from,
Skyline.crossing_search/4's :from,
SolarEvents.query/3's :time_zone). Internally we shift to UTC via
DateTime.shift_zone!/2, which consults Elixir's configured time-zone
database.
-
For UTC-only inputs (e.g.
~U[…]sigil literals, ortime_zone: "Etc/UTC"), no extra setup is needed. -
For named IANA zones (
"America/New_York", etc.), add an IANA TZ database to your app:# mix.exs defp deps do [{:tz, "~> 0.28"}] # or {:tzdata, "~> 1.1"} end
# config/config.exs config :elixir, :time_zone_database, Tz.TimeZoneDatabase
Outputs are always UTC DateTimes (the canonical "this happened at this
instant" form). For local-time display, call
DateTime.shift_zone!(day.sunrise, "America/New_York").
Every verb is verb(eph, struct). Builders construct the struct.
# Loading
DE440.load/1, open/1, open_path/1, open_app_priv/2
DE440.EphemerisFile.fetch/1, fetch!/1, verify/1, verify!/1, sha256/1, sha256!/1
# Low-level body positions (km, SSB-relative or geocentric for the Moon)
DE440.state/4, state!/4, earth/2, earth!/2, read_block/2, read_block!/2
# Inputs and locations
DE440.Observer.new/4
DE440.Landmark.new/4
DE440.Landmark.view_from/2 → %Landmark.View{} # pure geometry
# Builders
DE440.Sky.aim/3 → %Sky.Aim{}
DE440.Skyline.search/4 → %Skyline.Search{}
DE440.Skyline.crossing_search/4 → %Skyline.CrossingSearch{}
DE440.SolarEvents.query/3 → %SolarEvents.Query{}
# Verbs (all 2-arity, struct in second position)
DE440.Sky.position/2 → {:ok, %Sky.Position{}}
DE440.Sky.positions/2 → {:ok, [%Sky.Position{}]}
DE440.Skyline.passage/2 → {:ok, %Skyline.Passage{}}
DE440.Skyline.passages/2 → {:ok, [%Skyline.Passage{}]}
DE440.Skyline.azimuth_crossing/2 → {:ok, %Skyline.Crossing{}}
DE440.Skyline.azimuth_crossings/2 → {:ok, [%Skyline.Crossing{}]}
DE440.SolarEvents.day/2 → {:ok, %SolarEvents.Day{}}
DE440.MoonPhase.at/2 → {:ok, %MoonPhase{}}
# Apparent geocentric coordinates (no observer needed)
DE440.Sky.equatorial/3 → {:ok, %Sky.Equatorial{}}
# Bearing + distance between two surface points
DE440.Geodesy.flat_bearing/2 → float() (degrees east of N)
DE440.Geodesy.spherical_bearing/2 → float()
DE440.Geodesy.spherical_distance_km/2 → float() (km)
DE440.Geodesy.vincenty/2 → %Geodesy.Result{}
DE440.Geodesy.karney/2 → %Geodesy.Result{}
# Time-scale helpers
DE440.Time.{datetime_to_jd_utc, jd_utc_to_datetime,
jd_utc_to_jd_tt, jd_tt_to_jd_tdb, jd_utc_to_jd_tdb,
delta_t}/1
DE440.Refraction.{arcmin, apparent_altitude}/1 # Saemundsson (default)
DE440.Refraction.{hohenkerk_arcmin,
hohenkerk_apparent_altitude}/2 # HS, takes %Atmosphere{}
DE440.Refraction.Atmosphere.standard/1 # P/T/RH/λ struct
DE440.Nutation.at/1 → {dpsi, deps, eps_mean} (rad)
Altitude convention: altitude_deg is the apparent (refraction-
corrected) altitude — what you see through the atmosphere.
true_altitude_deg is the geometric altitude before refraction. Same
convention in Sky.Position, Skyline.Passage, and Skyline.Crossing.
Landmark.View only exposes true_altitude_deg (there is no canonical
apparent altitude for a fixed point).
:mercury, :venus, :emb, :mars, :jupiter, :saturn, :uranus,
:neptune, :pluto, :moon (geocentric), :sun, :nutations,
:libration, :mantle_vel, :tt_tdb.
There is no :earth body — use DE440.earth/2, which derives Earth from
EMB and the geocentric Moon: EMB - Moon × (1/(1 + EMRAT)).
The 1550–2650 file (linux_p1550p2650.440) does not tabulate
:mantle_vel or :tt_tdb; calling state/4 on those returns
{:error, :body_not_present}.
%DE440{}is pure data. Holds the 100 MB ephemeris binary in its:datafield. No file handle, no process, no ETS, no:persistent_term. Pass freely between processes — BEAM shares large binaries off-heap by reference, so no copying.- Errors are tagged. Public functions return
{:ok, _} | {:error, atom()}.!-suffixed variants raise on the same conditions for callers who prefer that style. Builders (crossing_search/4and friends) raiseArgumentErroron invalid options — fail-fast at construction, not deep in the search. - Cache is internal. Batch entry points (
Sky.positions/2,Skyline.passages/2,Skyline.azimuth_crossings/2,SolarEvents.day/2) thread a one-block cache through their loops, skipping the per-call coefficient parse when consecutive timestamps land in the same 32-day block. Empirical ~10–30× speedup over scalar mapping for hourly sweeps.
End-to-end alt/az and apparent geocentric RA/Dec, validated against JPL HORIZONS over the full 1550–2650 ephemeris range:
| metric | max residual | rms |
|---|---|---|
| topocentric cross-track | ~2.2″ | ~0.8″ |
| topocentric altitude | ~2.2″ | ~0.7″ |
| geocentric RA·cos(δ) | ~2.8″ | ~0.7″ |
| geocentric Dec | ~1.1″ | ~0.2″ |
Reduction chain in Sky: light-time, annual aberration, IAU 2006
precession, IAU 1980 nutation (4-term Meeus truncation), Greenwich
Apparent Sidereal Time, WGS-84 observer ECEF, Saemundsson refraction.
Time scale: leap seconds from 1972-01-01 through 2017-01-01; pre-1972
falls back to the Espenak-Meeus Delta-T polynomial (≤1 s back to year
1500). UT1 ≈ UTC throughout — sub-second ΔUT1 is the residual ceiling.
Deliberately not applied: polar motion (<0.5″), diurnal aberration (<0.32″), and the full IAU 2000B nutation series (would tighten Δψ from ~0.5″ to <1 mas). Adding any of those would require an IERS bulletin or a much larger nutation table.
Geodesy is validated against Charles Karney's GeographicLib to sub-
millimeter distance and sub-microarcsecond bearing (1e-3 m, 1e-7°).
Atmospheric refraction is applied automatically. Saemundsson 1986 is
the default (fast closed-form, residual ≤ 0.1′ above 5°, kinks at the
horizon, assumes 1010 mbar / 10 °C). For horizon-grade accuracy pass
an :atmosphere to Observer.new/4 — that switches Sky.position
to the Hohenkerk-Sinclair ray-trace, which matches ERFA/IAU SOFA to
≤ 1″ across pressure 800–1050 mbar, T −20…+40 °C, RH 0–100 %, and
wavelength 0.4–10 μm.
Five independent reference sources, in decreasing importance:
- JPL
testpo.440— gold standard for the Chebyshev evaluator. - JPL HORIZONS — apparent alt/az and RA/Dec across 1550–2650; the binding accuracy check for the full reduction chain.
- ERFA / IAU SOFA (Python
pyerfa) — refraction-coefficient validation for the Hohenkerk-Sinclair ray-trace. - GeographicLib (Karney) — WGS-84 geodesic inverse-problem
validation for
Geodesy.karney/2andGeodesy.vincenty/2. - Skyfield (Python) and astronomy-engine (JavaScript) —
end-to-end alt/az + solar-event sanity checks under
DE440_EXTERNAL_VERIFY=1.
The reference CSVs under test/external/ are generated by the
fetch_*.py scripts and checked in — see them as the "answer key"
that the library is held against.
mix test # all tests (~13 s)
mix test --exclude requires_ephemeris # refraction + geodesy, no DL neededOptional cross-language verification uses Python Skyfield and JS astronomy-engine. These are test/CI dependencies only; the library itself ships with zero runtime dependencies.
python3 -m pip install -r test/external/requirements.txt
npm install --prefix test/external
mix ephemeris.fetch
DE440_EXTERNAL_VERIFY=1 mix test test/external_verification_test.exsThe CI pipeline runs all of:
mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix dialyzer
mix testThere's also a separate mix run bench/run.exs for ad-hoc
performance regressions.
The examples/phoenix_api/ app is a clone-only HTTP smoke-test example.
It is deliberately excluded from the Hex package so Phoenix is not a
dependency of the library.
notes.md captures the astronomy gotchas — passive vs.
active rotations, why phase names go by ecliptic elongation rather than
phase angle, how time scales overlap, and which corrections are in vs.
deliberately omitted. Read it if you're curious why the code looks the
way it does.
MIT — see LICENSE.