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-contractand@astrology/loggerviaworkspace:*. It has no HTTP library: the globalfetchis used, and tests inject their own. - The public entry point exports only
HorizonsAdapterandHorizonsAdapterOptions. - 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:
- The HTTP response decodes as JSON. Non-JSON on success is
PROVIDER_RESPONSE. signature.sourceisNASA/JPL Horizons APIandsignature.versionis1.2.- A top-level
errorsaying there is no ephemeris for the target isOUT_OF_EPHEMERIS_RANGE. Any other Horizons refusal, or any other 4xx, isINTERNAL: it means the pinned template is wrong. - There is exactly one
$$SOE/$$EOEpair. The header two lines above$$SOEcontainsDate_________JDUT,delta,deldot,TDB-UT,ObsEcLonandObsEcLat. - The row count equals the instant count, and each row’s JD echoes the requested instant.
- 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-Afteracts as a floor. Retries stop atmaxAttempts, and none is scheduled past the deadline. - Timeouts, other 4xx, and verification failures never retry.
- Only
PROVIDER_UNAVAILABLEandPROVIDER_TIMEOUTcount 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.