Skip to content

ivarvong/de440

Repository files navigation

DE440

CI Hex.pm License: MIT

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

What's in here

  • 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's testpo.440 to 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 an Atmosphere struct: 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.Nutation and DE440.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.

Install

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"}]
end

Then 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 s

Quickstart

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

Production use

  • 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/2 and 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. Avoid DE440.open_path/1 or DE440.open_app_priv/2 inside 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
end

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

What this is good for

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/2 solves 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/2 matches USNO to ≤ 2 s.
  • Sun-aided photography orientation — combine Sky.position with 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 checksGeodesy.karney/2 for millimeter-accurate baseline distances and azimuths on the WGS-84 ellipsoid.

Time zones

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, or time_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").

Public API

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

Body atoms

: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}.

Architecture notes

  • %DE440{} is pure data. Holds the 100 MB ephemeris binary in its :data field. 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/4 and friends) raise ArgumentError on 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.

Accuracy

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.

Verification

Five independent reference sources, in decreasing importance:

  1. JPL testpo.440 — gold standard for the Chebyshev evaluator.
  2. JPL HORIZONS — apparent alt/az and RA/Dec across 1550–2650; the binding accuracy check for the full reduction chain.
  3. ERFA / IAU SOFA (Python pyerfa) — refraction-coefficient validation for the Hohenkerk-Sinclair ray-trace.
  4. GeographicLib (Karney) — WGS-84 geodesic inverse-problem validation for Geodesy.karney/2 and Geodesy.vincenty/2.
  5. 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.

Tests

mix test                              # all tests (~13 s)
mix test --exclude requires_ephemeris # refraction + geodesy, no DL needed

Optional 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.exs

Quality gates

The CI pipeline runs all of:

mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix dialyzer
mix test

There'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.

Design notes

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.

License

MIT — see LICENSE.

About

Pure-Elixir reader for the JPL DE440 ephemeris — topocentric positions, skyline alignment, moon phase, solar events.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages