Haluk Demirhan

Where the planets are, in plain Dart: an engine I wrote, tested and opened

Haluk Demirhan ·

Kairos is a Commercium app that draws the sky of any moment and any place. To do that it has to know where the Sun, the Moon and the planets were, are or will be, to a fraction of a degree, on a phone, offline. This post is about the engine that does that sum, how I decided it was accurate enough, and why I released it as kairos_ephemeris, free for anyone to use.

The usual answer, and its cost

Software that needs planetary positions almost always uses the Swiss Ephemeris. It is precise, mature and well documented, and I have nothing against it. It does come with two costs for an app like this one.

The first is the build. It is a C library, so a Flutter app reaches it through FFI on every native platform and through WebAssembly on the web. That means a native toolchain on every target and one more moving part in every release.

The second is the licence. The Swiss Ephemeris is dual licensed. You can take the AGPL, which asks the whole app built on it to be released under the AGPL or a compatible licence. Or you can buy a professional licence from Astrodienst; the current contract asks CHF 700 for an unlimited number of projects, valid for six years. That is a fair price for a company. For an open-source project, or for anyone who would rather not tie a closed app to a contract, it is still a decision to make before the first line of code.

When I looked on pub.dev, the Dart options were bindings to that same C library. I could not find an engine in plain Dart, under a permissive licence, that also handled house cusps.

What I built

The engine is about 600 lines of Dart plus generated coefficient tables, and it uses nothing beyond dart:math.

  • Planets. The VSOP87 theory of Bretagnon and Francou, in the truncated form published by Astronomy Engine, Don Cross’s MIT-licensed library. On top of it sit light time, annual aberration, precession and nutation, following Jean Meeus’s Astronomical Algorithms.
  • The Moon. The Montenbruck and Pfleger lunar theory, again as ported by Astronomy Engine.
  • Pluto. No short series describes it well, so the engine samples Astronomy Engine’s simulated orbit every 20 days from 1799 to 2201 and interpolates.
  • Time. Clocks follow the Earth’s rotation, which slows down irregularly. The Espenak and Meeus polynomials bridge atomic time and Earth time (a quantity called Delta T).
  • Houses. Sidereal time, then the Ascendant, the Midheaven and the cusps, with Placidus solved by iteration. Inside the polar circles Placidus has no answer, so the engine falls back to Porphyry and says so.

A short Python script regenerates the tables from Astronomy Engine, so nothing in them is typed by hand.

The reference as an examiner, not a dependency

A sum like this is easy to get slightly wrong in ways no one notices: a sign error in aberration, a time scale mixed up, a cusp in the wrong quarter. So I let the reference library examine the engine instead of running inside it.

A second script, run only on my own machine, asks the Swiss Ephemeris for 400 random moments between 1850 and 2149 at random places from 60° south to 65° north, plus five fixed cities. It writes the answers to a file. The test suite asks the engine the same 400 questions and fails if any answer drifts past its limit. Kairos does not ship without passing it.

for (final b in Body.values) {
  expect(worst[b]!, lessThan(limits[b]!), reason: b.name);
}
expect(retrogradeMismatches, 0);

These are the worst differences in longitude across all 400 cases, in arcminutes (one arcminute is a sixtieth of a degree):

Body Worst difference
Sun 0.16′
Moon, up to 2025 0.08′
Mercury to Pluto 0.35′ at most
Lunar nodes 0.65′ at most
House cusps, Ascendant, Midheaven 0.09′

The direction of motion, forwards or retrograde, matched the reference in every case.

How accurate is accurate enough?

You could keep improving an engine like this for years. The question that told me when to stop came from the input. A moment in the app is a date and a time, usually to the minute. The Moon, the fastest body, moves about half an arcminute in a minute of time, so the worst Moon difference up to 2025 is about the distance it travels in nine seconds. The Ascendant turns on average about fifteen arcminutes a minute, so its 0.09′ is less than half a second of the clock. The screen shows degrees and whole minutes.

In other words, any error that matters comes from the time someone types, not from the engine. Beyond that point, more precision costs code and app size and changes nothing a person can see. I wrote the limits into the test so that the line stays where I drew it.

The future is a forecast

One result surprised me at first. For dates after 2025, the Moon’s difference grows, to about two arcminutes by 2149. Neither library is wrong. Delta T has been measured for the past but can only be forecast for the future, and the two libraries use different forecasts. The Moon moves fastest, so it shows the gap first.

I could have hidden this by testing only past dates. Instead the test widens the Moon’s limit a little for every year past 2025, and the README says why. A table that shows only the comfortable cases is not much of a test.

Limits

The engine is geocentric: it gives the sky as seen from the centre of the Earth, without the small shift for where on the surface you stand. It covers 1800 to 2199. It does not do asteroids, fixed stars or eclipse paths. These limits are fine for an app that draws the sky. They are not fine for navigation, and the README says so.

Why I opened it

The engine stands on open work: VSOP87 from the Bureau des Longitudes, decades of published algorithms, and Don Cross’s careful ports in Astronomy Engine. Releasing the Dart version, with its test and its worst cases, is a fair return. The repository holds the engine, the tests, the reference file and both scripts, so anyone can rerun the comparison, and its licence file carries Astronomy Engine’s notice next to Commercium’s.

Three things from this work apply well beyond astronomy:

  • Set the error budget from the input, not from the best library you can find.
  • Make the reference library an examiner: let it check your code in a test instead of shipping inside your app.
  • Publish the worst case and its reason, not the average.

The code is at github.com/The-Commercium/kairos-ephemeris, and Kairos is where it runs.