Skip to content

omnilab/horizons

Omnilab Horizons: the ephemeris of a body of the solar system - a planet, a moon, the Sun, an asteroid, a comet, a spacecraft - seen from a place on the Earth over a period, as series: right ascension and declination, azimuth and elevation, airmass, apparent magnitude, illuminated fraction, angular diameter, distances to the observer and to the Sun and their rates, solar elongation and phase angle, through NASA/JPL's Horizons API, no key needed.

Floor
Provider · Omnilab
Version
1.x
Conformance suite
not wired
Verified for real
Its README does not say
Licence
MIT
composer require omnilab/horizons:1.x-dev

NASA/JPL Horizons, through its API (https://ssd.jpl.nasa.gov/api/horizons.api), for omnisense/omnilab: the ephemeris of a planet, moon, asteroid, comet or spacecraft seen from a place on the Earth over a period - right ascension and declination, azimuth and elevation, magnitude, illumination, distances, elongation - as series, each variable naming its body (499:elevation). No key.

Installation

composer require omnilab/horizons
use Omnilab\Horizons\HorizonsSourceFactory;

$horizons = (new HorizonsSourceFactory($httpClient))->create([
    'mailto' => 'support@glitchr.io',  // recommended: JPL asks a User-Agent that names the caller
    'throttle' => 0.2,                 // optional: seconds between two calls
]);

With the Symfony bundle, the factory is registered as horizons when the bundle knows it; otherwise register HorizonsSourceFactory as a service (it is autoconfigured as a SourceFactoryInterface).

Credentials

None: the Horizons API takes no key. JPL asks that its APIs be called one request at a time, with a User-Agent that names the application: give mailto.

Options

The factory's options, with the defaults it applies (HorizonsSourceFactory):

Option Type Default Required Effect
mailto ?string null no a contact address, in the User-Agent: JPL's fair use asks an application-specific one
user_agent ?string null no the whole User-Agent, in place of the one made from mailto
throttle float 0.2 no seconds left between two calls: JPL asks one request at a time
base_uri string https://ssd.jpl.nasa.gov/api/ no the API the source calls

Capabilities

capabilities() answers series, forecast, historical; an operation it does not answer throws NotSupportedException before any call.

Capability Supported
entity no a body's record is omnilab/jpl's (the Small-Body Database)
search no
series yes the variables <body>:<quantity> (499:elevation, jpl.sbdb:99942:distance) seen from a Point on the Earth, one call per body; steps of minutes to years
products no
occurrences no
datasets no
jobs no
open no
structure no
cross_references no
forecast yes an ephemeris answers for the future
historical yes and for the past (within each body's ephemeris)
area no a box is refused before any call: an observer stands at a point
station no a station is refused before any call
cloud_cover no

Which API

The Horizons API (https://ssd.jpl.nasa.gov/api/horizons.api, API version 1.2 in its answers, documented as 1.3): every Horizons setting is a parameter of the URL, the answer a JSON object whose result is Horizons' text - here an observer table in CSV between $$SOE and $$EOE - and whose error, when there is one, says what Horizons refused (with a 200). Asked each time: EPHEM_TYPE=OBSERVER, CENTER=coord@399 with COORD_TYPE=GEODETIC and SITE_COORD (east longitude, latitude, altitude in km), OBJ_DATA=NO, ANG_FORMAT=DEG (angles in degrees, not sexagesimal), CSV_FORMAT=YES, TIME_DIGITS=SECONDS, EXTRA_PREC=YES, and the quantity codes the variables need.

Series, and the body in the variable

series() is the contract's: one variable at a place (the observer, a Point on the Earth) over a period. What an ephemeris adds is the body, which SeriesQuery has no field for, so each variable names it before its quantity: <body>:<quantity>, split at the last colon - 499:ra, 301:elevation, jpl.sbdb:99942:distance, jpl.sbdb:C/2020 F3:magnitude. Several bodies may be asked at once; one call is made per body, whatever its quantities, and the series come back in the order asked.

Body Horizons' command
a number: 10, 301, 499, -74 as it is: a major body's or a spacecraft's id
jpl.sbdb:433 (an Identifier of the jpl.sbdb scheme too) 433; (a numbered small body)
jpl.sbdb:20000433 (an SPK-ID, seven digits or more) DES=20000433;
jpl.sbdb:2004 MN4 DES=2004 MN4;
jpl.sbdb:1P, jpl.sbdb:1P/Halley, jpl.sbdb:C/2020 F3 DES=1P; CAP;: the comet's latest apparition
jpl.sbdb:Eros NAME=Eros;
anything else (Mars Barycenter, DES=2004 MN4;) as it is, Horizons' own syntax

An identifier of another scheme (wikidata:Q111) is refused before any call. A plain name is Horizons' text search over the major bodies, which matches parts of names: on 2026-10-15 Eros gave Kerberos (904), Pluto's moon - name a small body in the jpl.sbdb scheme (jpl.sbdb:Eros is NAME=Eros;, 433 Eros). The series' label says which body Horizons answered for: Mars (499): right ascension (ICRF, astrometric).

Quantity Horizons (code: column) Unit
ra, dec 1: R.A., DEC (ICRF, astrometric) deg
azimuth, elevation 4: Azimuth, Elevation (apparent, airless) deg
airmass, extinction 8: a-mass, mag_ex -, mag
magnitude 9: APmag (a comet's T-mag, its total magnitude) mag
nuclear_magnitude 9: N-mag (a comet's) mag
surface_brightness 9: S-brt mag/arcsec²
illumination 10: Illu% %
angular_diameter 13: Ang-diam arcsec
heliocentric_distance, heliocentric_range_rate 19: r, rdot au, km/s
distance, range_rate 20: delta, deldot au, km/s
elongation 23: S-O-T deg
phase_angle 24: S-T-O deg

A value Horizons gives as n.a. (the airmass of a body below the horizon, a comet's nuclear magnitude) is null; a column the body has not (a comet's surface brightness) gives null points.

The period: both ends included, an end at midnight standing for its whole day; no start is today (00:00 UTC, or in the query's time zone), no end a day after the start. The step: PT10M is 10 m, PT1H 1 h, P1D 1 d, P1W 7 d, P1M 1 mo, P1Y 1 y; any other (seconds) is refused before any call. The times: Horizons' UT (UTC since 1962), given in the query's time zone when it names one. No aggregation: an ephemeris' points are instants (a query with Series::MAX... is refused). The place: a Point, its elevation in metres (0 when unknown); a BBox and a station are refused. forecast is null: an ephemeris is computed, neither observed nor a forecast in the weather's sense; the capabilities say forecast and historical, since it answers for the future and the past alike.

Errors

Horizons says Here
"No matches found" (a small-body search) a series without points
several bodies match ("Moon": 3 Earth-Moon Barycenter, 301 Moon) ProviderException listing five of them: name one by its id
error: a table past 90,024 lines, a start after the end, an unknown step, a time out of the body's ephemeris ("No ephemeris for target "Mars" after A.D. 2599-DEC-31") ProviderException with Horizons' text
429, 5xx RateLimitedException, UnavailableException

Refused before any call (NotSupportedException): a box or a station, an aggregation, a variable without its body or a quantity not served, a body of another scheme (wikidata:Q111), a step in seconds. A 404 or 410 is a ProviderException too ("the API answered nothing"); any other 4xx a ProviderException with what Horizons said; an answer that is not JSON a ProviderException, as every source's (HttpSource).

What Horizons does not give here

The vector and orbital-element tables, SPK files and close-approach tables (other EPHEM_TYPEs), an observer elsewhere than on the Earth, the object data (OBJ_DATA: radius, mass, rotation of a major body - free text), the quantities not listed above (the constellation, the local apparent sidereal time, the Moon's and the planets' sub-observer points, the light time...), dates B.C., the TT and TDB time scales, the refraction model, and a list of times (TLIST).

Example

<?php

require __DIR__.'/vendor/autoload.php';

use Omnilab\Horizons\HorizonsSourceFactory;
use Omnilab\Model\Point;
use Omnilab\Source\SeriesQuery;
use Symfony\Component\HttpClient\HttpClient;

$horizons = (new HorizonsSourceFactory(HttpClient::create()))->create(['mailto' => 'you@example.org']);

// Mars and the Moon from Strasbourg, every three hours on 2026-10-28 (UTC), given in Paris time: one call per body.
[$mars, $marsMag, $moon] = $horizons->series(new SeriesQuery(['499:elevation', '499:magnitude', '301:illumination'], new Point(48.5734, 7.7521, 140), new DateTimeImmutable('2026-10-28'), new DateTimeImmutable('2026-10-28'), 'PT3H', timezone: 'Europe/Paris'));
echo $mars->label, ' (', $mars->unit, "):\n ";
foreach ($mars as [$time, $value]) {
    echo ' ', $time->format('H'), 'h ', round($value, 1);
}
echo "\n", $marsMag->label, ': ', $marsMag->min(), ' ', $marsMag->unit, "\n", $moon->label, ': ', $moon->min(), ' to ', $moon->max(), ' ', $moon->unit, "\n";
Mars (499): elevation above the horizon (apparent, airless) (deg):
  01h 9.4 04h 38.8 07h 58.4 10h 45.1 13h 16.2 16h -10.6 19h -24.1 22h -15.2
Mars (499): apparent visual magnitude (a comet's total): 0.837 mag
Moon (301): illuminated fraction: 89.69355 to 95.2556 %

(as answered on 2026-10-27: two calls, one per body; the period's dates are UTC midnights, the times given in Paris time, an hour ahead)

# config/packages/omnilab.yaml
omnilab:
    sources:
        horizons: { factory: horizons, options: { mailto: '%env(OMNILAB_MAILTO)%' } }

Verified, and not

Against the real service on 2026-10-15: Mars from Strasbourg hourly on 2026-10-16 (24 points, its elevation up to 60.10°, APmag 1.039, 1.5576 au), each of the seventeen quantities; the Moon from Sydney and from New York in their time zones; Apophis by jpl.sbdb:99942 daily around 13 April 2029 and every ten minutes that evening (0.0002243 au from Strasbourg at 21:40 UTC); comet NEOWISE (C/2020 F3) in July 2020, its total magnitude 7.354 to 7.726; 1P/Halley's distance from the Sun in 2061 (0.835 and 0.597 au, monthly); "Moon" (two bodies), a name no small body has, a table of 525,600 lines, a period before 1600 for Mars. The fixtures in Tests/Fixtures/ are its answers. Each call took 0.7 to 1.3 s.

Again on 2026-10-27: Mars and the Moon from Strasbourg every three hours on 2026-10-28, in Paris time (the example above).

Not verified: a 429 or a 503 from Horizons (none met: the mapping is the core's); a spacecraft; the 90,024-line limit met exactly; a mirror (base_uri).

Responsibility

This package is an independent, unofficial client: it is not affiliated with, endorsed by or sponsored by NASA, JPL or Caltech. JPL asks that its APIs be called one request at a time with a User-Agent that names the application, and Horizons be cited (Giorgini, J.D., and JPL Solar System Dynamics Group, NASA/JPL Horizons On-Line Ephemeris System), as the README's Terms of use says. Whoever runs the package is responsible for complying with them and for any use made of what it returns: see the README's Responsibility section.

Propose a change docs/index.md