Downloadable bodies
Body has room for the Sun, the Moon, the eight planets and the seven small bodies this engine
carries a table for. It has no room for the rest of the solar system, and there is a lot of rest:
1,563,747 asteroids, 895,910 of them numbered, 4,076 comets and 458 planetary satellites with an
ephemeris at the JPL, counted on 14 September 2026. An enum with a million and a half cases is a
file PHP has to compile whole to use one of them, and it would be stale again the day after it was
written.
So a satellite, an asteroid or a comet the engine does not already know is not a case of an enum. It is a small object built from a number or a designation, and its data, when it is wanted, is fetched from JPL Horizons and written to disk by a class that has no opinion about which bodies deserve that.
Three types behind one door
Satellites fit in an enum after all, because four hundred and fifty eight is a number an enum can
hold, and a misspelled Satellite::Io fails when it is read rather than when it is requested.
Asteroids and comets do not, so they are built by number or by designation instead:
use Astronomy\Asteroid; use Astronomy\Comet; use Astronomy\Satellite; $eris = Asteroid::number(136199); $provisional = Asteroid::designation('2024 YR4'); $halley = Comet::designation('1P/Halley'); $io = Satellite::Io; echo $eris->name(), ' -> ', $eris->horizonsId(), ' -> ', $eris->file(2451545.0), "\n"; echo $provisional->name(), ' -> ', $provisional->horizonsId(), ' -> ', $provisional->file(2451545.0), "\n"; echo $halley->name(), ' -> ', $halley->horizonsId(), ' -> ', $halley->file(2451545.0), "\n"; echo $io->name(), ' -> ', $io->horizonsId(), ' -> ', $io->horizonsCenter(), ' -> ', $io->planet()->name(), "\n";
(136199) -> 136199; -> downloads/asteroids/136199.bin 2024 YR4 -> DES=2024 YR4; -> downloads/asteroids/2024_YR4.bin 1P -> DES=1P;CAP;NOFRAG; -> downloads/comets/1P/2000.bin Io -> 501 -> 500@5 -> Jupiter
Not by name, on purpose, and it is the one door this class deliberately does not open. Turning
"Eris" into 136199 means asking the JPL, and there is no network here; a name also misleads in a
way a number does not, because there is an asteroid 1181 Lilith, a 763 Cupido and a 5731 Zeus that
have nothing to do with the bodies a chart calls by those names and would answer a query for them
perfectly naturally. Asteroid::designation() and Comet::designation() validate the shape they
are given, because a designation ends up both in a file name and in a query sent to Horizons, and
../.. is a path if nothing checks first.
All three answer to one interface, and that is what stops the class that downloads and the class that reads from ever disagreeing about where a body's data lives:
interface DownloadableBody
{
public function horizonsId(): string;
public function file(float $jdTT): string;
public function name(): string;
public function group(): DownloadableGroup;
}
use Astronomy\DownloadableBody; var_dump($eris instanceof DownloadableBody, $halley instanceof DownloadableBody, $io instanceof DownloadableBody);
bool(true) bool(true) bool(true)
horizonsId() is what gets asked of Horizons in COMMAND, and it already carries what makes the
question unambiguous: Asteroid appends a semicolon so 136199 searches the small bodies rather
than being read as the barycentre of Mercury, and Comet appends CAP;NOFRAG;, which is not
decoration either. Without CAP a periodic comet answers with the list of its apparitions instead
of a position; without NOFRAG, 73P-B returns twenty-six matches, the fragment and every one of
its own subfragments, and no ephemeris at all. file() is the second half of the same promise:
given an instant, it says exactly which file on disk holds that body's data, so a comet's own
identifier and its file name can never quietly drift apart from each other.
A satellite's horizonsCenter() is worth a second look, because it is the same trap Body already
carries seen from the other side: it asks Horizons for positions relative to the barycentre of
its planet's system, 500@5 for Jupiter's moons, not the centre of the planet itself. The engine
places Jupiter at that barycentre too, because that is what VSOP87 and the correction towards
Horizons represent, so a satellite's final position is built by adding what was downloaded to that
same point. Downloading from the planet's centre instead would leave out how far the planet itself
sits from its own barycentre, which for Pluto and Charon is about two thousand kilometres.
Nothing downloads itself
Downloadables::path() is where the engine looks for a body's file, and it throws when the file
is not there rather than reaching for the network to fill the gap:
use Astronomy\Asteroid; use Astronomy\Ephemeris; use Astronomy\MissingData; use Astronomy\Time; $provisional = Asteroid::designation('2024 YR4'); [$jdTT] = Time::fromClock(new DateTimeImmutable('1985-06-10 00:00:00', new DateTimeZone('UTC'))); try { Ephemeris::position($provisional, $jdTT); } catch (MissingData $missing) { echo get_class($missing), "\n"; echo $missing->body->name(), "\n"; echo $missing->dataFile, "\n"; echo $missing->getMessage(), "\n"; }
Astronomy\MissingData 2024 YR4 downloads/asteroids/2024_YR4.bin The data for 2024 YR4 is missing: downloads/asteroids/2024_YR4.bin is not in [...]/resources/astro. Whoever wants it fetches it with Downloader, and decides when: this package does not go to the network on its own.
MissingData carries the body and the file rather than being a plain runtime exception, because
what to do next is not this package's decision. A web request catching it might answer that the
body is not served yet; a background job might catch it, download the file right there and retry;
a script sweeping decades of transits might download everything it needs up front and never see
the exception at all. There used to be an allow-list of which bodies could be downloaded, and it
was removed: a library that reads a configuration to decide what an application is allowed to ask
for is a library with opinions about a program it has never seen. This one fetches what it is
told to fetch, and nothing on its own.
Downloader::download() is the other half:
use Astronomy\Downloader; Downloader::download($missing->body, $jdTT); // or, for a whole span: Downloader::downloadBetween($missing->body, $fromTT, $toTT);
That one is not run as part of this chapter, on purpose: it really does reach the JPL, and a documentation example is not the place for a live network call.
Two processes downloading the same file at the same time do not download it twice: the second one waits on a lock and finds the first one's work already done when it wakes up. The file is written to a temporary name and renamed into place at the end, which on the same disk is atomic, so a reader never sees a half-written file, only the complete one or none at all.
A step chosen by measuring, not assumed
Every downloadable body picks its own sampling step, and the choice is not made once per group. It is measured for that specific body, because a smooth orbit and a close approach do not behave the same way under the same interpolation:
Measured against Horizons on 14 September 2026, with the same nine-point interpolation the engine reads the file back with: Juno needs a point every ten days to stay within tolerance (with twenty, the interpolation error rises from 1.6e-9 AU to 6.6e-8); Io needs one every 2.7 hours (5.3 hours pushes the error from 1.3e-8 AU to 4.5e-6); Phobos needs one every 32 minutes (7e-10 AU against 4.9e-7 at 64 minutes); and Phaethon, sweeping close to its own perihelion, needs one every eight hours. A whole download of Eris from 1600 to 2400 costs 3.2 seconds in a single request, because asteroids are stored whole rather than split by year; Io over the same span, sampled hourly, would cost seven and a half minutes, which is why satellites and comets are split by year instead, a year at a time costing under two seconds.
The tolerance behind all of that is fixed and small: 0.01 arcseconds at the closest distance from
which the body can ever be seen, with a floor of 0.05 astronomical units so that a body passing
very near the Earth does not force an unreasonably fine step everywhere else in its file. measure()
downloads at a fine step, checks how far off the interpolation would land if every other point were
thrown away, and doubles the step as long as that error stays under tolerance; if not even the
starting step is fine enough, it refines and downloads again. What that error is measured against
is exactly the points that get dropped, with the same nine-point window and the same weights
DownloadedPositions::j2000() uses to read the file back, so what gets measured is what the engine
will actually see.
A close approach breaks any step chosen for the rest of the orbit: Apophis, with its 2029 pass inside the requested span, is off by 1.2e-4 astronomical units at one point a day, which is why the tolerance is checked at the body's own closest possible distance and not at some average one.
What the JPL says about its own uncertainty
Once a file is written, one more question gets asked of Horizons, and only for asteroids: how
confident is the JPL itself in that orbit. Satellites and comets answer n.a. to that question,
the same as the planets, because their ephemerides publish no covariance to ask for. An asteroid's
answer goes inside the file, next to the interpolation error already measured, and is what
vendor/bin/astronomy asteroids prints one line of after every download, taken straight from the
binary itself rather than retyped for this page:
fwrite(STDOUT, sprintf(
"%s · %s · one point every %d minutes · interpolation error %.4f arcsec at its closest · the JPL declares %s\n",
$body->name(),
substr($path, strlen(DataFolder::folder()) + 1),
$meta['stepMinutes'],
0.01 * $meta['errorAu'] / $meta['toleranceAu'],
$uncertainty === null ? 'no uncertainty for it' : sprintf('%.3f arcsec of uncertainty', $uncertainty)
));
And past sixty arcseconds, one arcminute, the command says so on its own line: an arcminute is the unit a chart is actually read in, so beyond that a body's position on a wheel is a guess dressed as a number rather than a fact. A body discovered only in 2024, asked about over the whole 1600 to 2400 span this engine otherwise covers, declares an uncertainty in the hundreds of millions of arcseconds, which is another way of saying nothing at all is known about where it sat four centuries before anybody found it, and the download still succeeds: the uncertainty is reported, not enforced.
Files, years, and a folder deep enough to hide from the cache
A file's name carries the years it covers, satellites/501/1980-1989.bin, and the chunks are
aligned to the calendar rather than to whoever happens to ask first: two requests inside the same
decade share the one file instead of each downloading its own slightly-offset copy. useYearsPerFile()
is the one thing that gets configured here, and it is deliberately not policy: whether a body may
be downloaded and whether something missing should be fetched right now are decisions for the
application, and this class holds neither opinion, only the shape of a file name.
That shape puts everything downloaded three and four folders deep,
downloads/<group>/<key>/<span>.bin, and the depth is not an accident either. Ephemeris's own
cache-invalidating fingerprint walks the first two levels of the data folder to notice when a
series changes; if a download landed inside that walk, fetching one asteroid would throw away the
cached results of every chart in the application, none of which read that asteroid at all, and the
fingerprint would have to walk however many thousand files somebody had downloaded on every single
request. Putting downloads two levels deeper keeps the two concerns from ever touching.
The rest of the engine takes it like a Body
Once a file exists, a DownloadableBody is accepted almost everywhere a Body is: all five of
Ephemeris's frames, Crossings's ingresses and exact longitude searches, Retrogrades's
stations, Phenomena's phase and magnitude, NodesAndApsides's orbit and ExtremeDistances, and
anything built on Horizon's direction-of-time interface, which is how RiseSet, Occultations
and Eclipses already accepted one without any change at all. What none of those can know about an
arbitrary downloaded body, they say so rather than inventing a number:
use Astronomy\NodesAndApsides; use Astronomy\Satellite; try { NodesAndApsides::of(Satellite::Io, $jdTT); } catch (\LogicException $e) { echo $e->getMessage(), "\n"; }
The nodes and apsides of Io are those of its orbit around Jupiter, and this computes orbits around the Sun.
- No radius means no visible disc, not an invented one.
Phenomena's apparent diameter usesBody::equatorialRadiusKm(), which a downloaded body has no equivalent of, so its diameter comes back zero, the same value a body with no radius written down already returns, rather than a made up size. - No brightness model means a null magnitude, not a borrowed one.
Magnitudesis fitted body by body against Horizons' own published values, and none of those fits is good for just any asteroid, the same reason Uranus and Neptune already return null outside the range of phase angles they were fitted with. - No mass in the DE440 header means a bare solar GM, which is what Horizons itself uses. A downloaded body contributes nothing to the sum that finds the barycentric Sun, the same case as Chiron and the four asteroids this engine already carries a table for: they weigh too little for the JPL to count either, and its own "Keplerian GM" for an asteroid is the Sun's alone.
- No known period means the widest search window, not a guessed one.
Crossingscannot ask a downloaded body how long it takes to go once round the zodiac, so it is searched with the widest window and the tightest retrograde margin the engine has, on the reasoning that paying too much in a sweep costs a little time and paying too little costs a missed crossing. - A satellite has no orbit around the Sun to give back. It orbits its planet, and asking
NodesAndApsidesfor the Sun-relative one throws with the message shown above rather than silently answering a question that was never asked.
The network side of all this, Horizons, is worth knowing about even though it is rarely called
directly: it asks for rows in chunks of fifty thousand, because Horizons has been known to cut a
response short at ten thousand rows without saying so, and on another day answered with 87,673 in
a single reply with no explanation for either. Each chunk starts at the row after the last one
that actually arrived, so a truncated response leaves a gap to fill in rather than a hole quietly
left behind. And when the JPL is rate limiting, it says nothing about it: it answers with a normal
200 and an empty body, which is why every request here is retried up to three times with a wait in
between, rather than trusted the first time it comes back short.
Where to go next
The data is what ships instead of being fetched: the series, the tables and the corrections a chart actually needs, checked and committed rather than downloaded at request time. Orbits, phenomena and crossings are what a downloaded body can be asked once its file is there. And precision is how everything this engine computes, downloaded bodies included, is checked against JPL Horizons.