Ephemeris9 MIN READ07 / 31

NASA adapter module design

Status: Implemented · Part of: NASA adapter HLD · Updated: 2026-10-24

This is the module-level design of the @astrology/nasa-adapter workspace package. It maps the HLD onto the @astrology/adapter-contract, the same way the Swiss implementation does.

Package shape

  • TypeScript ESM package, private, Node >=22.12.0, with the same layout as the Swiss package.
  • Dependencies: @astrology/adapter-contract and @astrology/logger via workspace:*. It has no HTTP library: the global fetch is used, and tests inject their own.
  • The public entry point exports only HorizonsAdapter and HorizonsAdapterOptions.
  • Import and construction perform no I/O. There is no server, listener or startup script.
Option Default Meaning
userAgent required Descriptive agent including a contact address; blank fails with INVALID_REQUEST
baseUrl https://ssd.jpl.nasa.gov/api/horizons.api Endpoint override for tests
timeoutMs 15000 Per HTTP attempt
deadlineMs 60000 Whole operation, including queueing, retries and every body
maxConcurrency 2 Concurrent Horizons calls per instance; extra bodies queue
maxAttempts 3 Attempts for 429 and 5xx only
retryBaseMs 1000 Backoff base, doubled per retry with full jitter
breakerThreshold / breakerOpenMs 5 / 60000 Consecutive transport failures that open the breaker, and how long it stays open
allowTopocentric false Whether observer coordinates may be sent to JPL
fetch globalThis.fetch Injection point for offline tests

Contract mapping

Method Behaviour
capabilities() Static snapshot with no network; healthy is false while the breaker is open
positions(request) Shared validateRequest, local refusals, one Horizons call per body, one declaration
houses() Always rejects UNSUPPORTED_FEATURE; nothing is sent
Capability Value
provider nasa-horizons
features / zodiacs ['positions'] / ['tropical']
bodies Sun through Pluto; Rahu and Ketu are excluded because Horizons has no node targets
houseSystems / ayanamsas / files empty
engineVersion Pinned API version 1.2 (the version the live API reports)
bindingVersion / precisionArcsec null
ephemeris DE441
coverage TT 2378497.5–2597650.3591467496, the same window as Swiss
fallbackPolicy explicit

These refusals happen before any network call:

Request Error code
Sidereal zodiac UNSUPPORTED_FEATURE; the normalizer applies the ayanamsa downstream
Rahu or Ketu UNSUPPORTED_BODY
Observer present while allowTopocentric is false UNSUPPORTED_FEATURE; coordinates never leave the process
Instant within a day of the coverage edges OUT_OF_EPHEMERIS_RANGE
Breaker open PROVIDER_UNAVAILABLE

Query template

One GET per body. Only COMMAND, CENTER, COORD_TYPE, SITE_COORD and TLIST vary. Horizons values are wrapped in single quotes; format=json is an API parameter and must stay unquoted.

Parameter Value Why
MAKE_EPHEM / OBJ_DATA YES / NO Table only, no physical-data block
EPHEM_TYPE OBSERVER Apparent, observer-centred quantities
CENTER 500@399 geocentric; coord@399 topocentric Earth centre unless explicitly allowed
COORD_TYPE / SITE_COORD GEODETIC / lon,lat,elev_km Topocentric only; east-positive degrees, kilometres
QUANTITIES 20,30,31 Range and range rate, TDB−UT, ecliptic-of-date longitude and latitude
ANG_FORMAT / EXTRA_PREC DEG / YES Decimal degrees at full printed precision
APPARENT AIRLESS No refraction
CSV_FORMAT / CAL_FORMAT YES / JD Comma-separated rows whose first column is the UT Julian day
TIME_TYPE / TLIST_TYPE UT / JD Explicit time scale; never a server default
TLIST t or t−h, t, t+h Speeds add two neighbouring instants to the same call

Quantity 31 is the apparent position (light-time, light deflection and aberration applied) in the ecliptic of date, which matches the contract’s apparent-ecliptic-of-date frame.

Body sun moon mercury venus mars jupiter … pluto
COMMAND (body centre) 10 301 199 299 499 599 699 799 899 999
Geocentric speed step h (days) 0.1 0.01 0.02 0.05 0.05 0.25

Speed step

Each step balances two errors:

  • truncation error grows with the step and with how fast the body’s speed changes;
  • the 1e-7° printed rounding is divided by 2h, so it shrinks as the step grows.

So the Moon and Mercury take short steps, and slow outer planets take long ones. Topocentric requests use 0.002 days for every body. Diurnal parallax adds a one-day oscillation, about 1° for the Moon, which would otherwise dominate the truncation error.

Response verification and parsing

Checks run in this order, and the first failure rejects the whole operation:

  1. The HTTP response decodes as JSON. Non-JSON on success is PROVIDER_RESPONSE.
  2. signature.source is NASA/JPL Horizons API and signature.version is 1.2.
  3. A top-level error saying there is no ephemeris for the target is OUT_OF_EPHEMERIS_RANGE. Any other Horizons refusal, or any other 4xx, is INTERNAL: it means the pinned template is wrong.
  4. There is exactly one $$SOE/$$EOE pair. The header two lines above $$SOE contains Date_________JDUT, delta, deldot, TDB-UT, ObsEcLon and ObsEcLat.
  5. The row count equals the instant count, and each row’s JD echoes the requested instant.
  6. Every cell read is a plain finite number. Empty cells and n.a. fail with the column name.

Columns are found by header name, never by position. The topocentric Moon rows, for example, carry a * daylight marker in an unnamed column.

Position field Source
longitude / latitude ObsEcLon / ObsEcLat at t; longitude wrapped into [0, 360)
distanceAu delta at t
speedLongitude (lon(t+h) − lon(t−h)) / 2h, unwrapped across the 0°/360° seam
speedLatitude (lat(t+h) − lat(t−h)) / 2h
speedDistanceAu deldot at t, km/s × 86400 / 149597870.7
returnedFlags null

All three speeds are null unless includeSpeed is true.

Time and declaration

The UTC instant becomes a UT Julian day for TLIST. Horizons returns TDB-UT for it.

Declaration field Value
provider / engineVersion / bindingVersion nasa-horizons / 1.2 / null
ephemeris DE441 plus each body’s distinct source: id, e.g. DE441;jup365_merged;mar099
files / tidalAcceleration… / requestedFlags / precisionArcsec empty / null / null / null
julianDayUt Requested UT Julian day
deltaTSeconds TDB-UT at t; bodies that disagree by more than 1 ms are rejected as PROVIDER_RESPONSE
julianDayTt julianDayUt + deltaTSeconds / 86400
frame / origin / zodiac apparent-ecliptic-of-date / geocentric or topocentric / tropical
ayanamsa, ayanamsaDegrees, ayanamsaAppliedBy null
degraded false

Two approximations are declared here rather than hidden. Horizons UT means UTC after 1962 (UT1−UTC is under 0.9 s). TDB stands in for TT (they differ by under 2 ms).

Throttle, retry and breaker

  • A semaphore caps in-flight calls. Queued bodies wait until the deadline, then fail with PROVIDER_TIMEOUT.
  • Each attempt is aborted at min(timeoutMs, time left before the deadline).
  • HTTP 429 and 5xx retry with full-jitter exponential backoff. Retry-After acts as a floor. Retries stop at maxAttempts, and none is scheduled past the deadline.
  • Timeouts, other 4xx, and verification failures never retry.
  • Only PROVIDER_UNAVAILABLE and PROVIDER_TIMEOUT count toward the breaker. When the open period ends, the breaker admits one trial call: success closes it, failure reopens it.
  • When one body fails, its sibling calls are aborted.
  • Every request sends the configured User-Agent. Logs record codes and counts only, never coordinates or response text.
Failure Code
Attempt, queue or deadline timeout PROVIDER_TIMEOUT
Network error, 429/5xx after retries, breaker open PROVIDER_UNAVAILABLE
Unexpected signature, markers, columns, rows, cells or ΔT PROVIDER_RESPONSE
Horizons has no ephemeris for the target OUT_OF_EPHEMERIS_RANGE
Other Horizons refusal or 4xx INTERNAL

PROVIDER_RESPONSE was added to the shared contract’s ERROR_CODES for this adapter. It covers any remote provider that answers with a shape, version or value its adapter does not recognise.

Module layout

File Responsibility
src/index.ts Public exports
src/nasa/horizons-adapter.ts EphemerisAdapter implementation, refusals, assembly and declaration
src/nasa/query-builder.ts Pinned template, per-body COMMAND, CENTER and TLIST
src/nasa/target-map.ts Canonical body ↔ Horizons target id and speed steps
src/nasa/response.ts Envelope verification and header-located CSV parsing
src/nasa/http-client.ts Semaphore, timeouts, backoff, breaker and transport error codes

Verification

pnpm test:nasa-adapter, which pnpm test:adapters also runs, is fully offline. The tests inject a fetch that replays real Horizons responses from test/fixtures/horizons-responses.json. pnpm --filter @astrology/nasa-adapter record:nasa-adapter re-records them. It drives the real adapter through a recording fetch for the cases in test/fixtures/cases.mjs, so fixtures always match the URLs the adapter builds.

The tests cover:

  • the public surface;
  • refusals that make no network call;
  • the per-body and topocentric speed steps;
  • the longitude seam;
  • every verification failure above;
  • the span and template refusals;
  • 429 retry;
  • 5xx exhaustion with the breaker opening, then a successful half-open trial;
  • timeout;
  • the concurrency cap.

At J2000, all ten bodies agree with the independent Swiss swetest values to within 1″ in longitude and latitude, 1e-6 relative in distance, and 1e-4 °/day in angular speed. Horizons’ deldot matches its own differenced delta to under 1e-6 AU/day.

Finding for the drift validator: distance speed differs between the providers by up to ~6e-5 AU/day (Neptune, Pluto) because Swiss differentiates its apparent distance differently. The test allows 1e-4 AU/day there. Drift thresholds for speedDistanceAu should be set per body, not copied from the angle thresholds.

Limitations

  • Fixtures cover J2000 and one 1990 topocentric case. Live behaviour changes are caught only by re-recording, or by the drift validator’s runs.
  • The coverage window is the Swiss window on purpose. A wider window is useful only once the router declares fallback outside the Swiss files.
  • capabilities() reports breaker state, not a live probe of Horizons.