evo.polaris
Polar alignment and the night sky, for astronomers who cannot crouch behind an eyepiece.
A free web app I built with no dependencies, no build step, and no network once loaded. Give it where you are and it returns every number a star tracker needs — in the units the mount asks for — then shows you the sky: the pole, the planets, the Moon with the phase it actually has, the Milky Way and the constellations, live as you move the phone or driven by buttons if you cannot. This page is about the mathematics inside it, and about how each piece was checked against the people who define it rather than against itself.
The app that could not exist
This started as a request to control an iOptron SkyTracker Pro from a phone. The first thing the research established was that the tracker has no data port at all — its micro-USB socket is charge-only — so the app that was asked for cannot be written. What can be written is the app that gets the tracker aligned in the first place, which is the part people actually get wrong, and the part that every existing app charges for and quietly assumes a body that cooperates: crouch behind the polar scope, hold a phone steady overhead, hit a small target in the dark, read dim grey text on black.
Plenty of astronomers can do none of those things. So the product thesis became accessibility: single taps only, big targets that never move, text that scales to 1.8×, numbers spelled out in words, a Night Mode that is measurably red, and a sky view that works from a bed with buttons as well as from a field with a phone held up. Free, no account, nothing stored about anyone.
What a mount actually needs
A star tracker has two physical adjustments and one thing to look at. The app produces all three, in the units the hardware is engraved in, and reads them aloud on request:
- The altitude axis — the wedge scale is set to the observer's latitude, in degrees and decimal minutes, and spelled out in words, because 42 and 24 are one glance apart in the dark and this is the number that quietly ruins every exposure of the night if it is wrong.
- The azimuth — true north or true south, which is exactly 0° or 180° by definition, and what that reads on a magnetic compass here, which is not.
- Where the pole star sits in the polar scope — a clock-face position and a radius in arcminutes, which is what the reticle is marked in.
Alongside those: local sidereal time for an RA setting circle, the UTC offset a hand controller wants, three ways to find true north without a compass at all, and the order to do the steps in — levelling first, because on an unlevel tripod the two adjustments stop being independent and you chase the pole round without converging. Both hemispheres are handled, including the fact that there is no southern Polaris and the app says so rather than pretending.
Magnetic declination — WMM2025 and NOAA's hundred vectors
A phone compass reads magnetic north. A mount must point at true north. The gap between them is up to twenty degrees across the continental United States and depends on where you stand, so it is computed on the device from the World Magnetic Model 2025: a spherical-harmonic synthesis to degree 12, with Schmidt quasi-normalised associated Legendre functions built by recursion.
None of that is validated against itself. NOAA ships WMM2025_TestValues.txt with the model —
one hundred points with the expected declination, inclination and field components — and every one of
them runs in the test suite. The implementation agrees to better than 0.01° in
declination and inclination and better than 1 nT in every component, at every
point.
Three bugs the vectors caught — by the shape of the error, not by re-reading
- The east component alone was wrong, by exactly 1.414. A pure √2 in one axis points straight at the Schmidt normalisation: the √2 for m ≥ 1 enters the sectoral chain exactly once, at P11 = sin θ, and it had been seeded as √½·sin θ instead.
- Then the east component was right in size and wrong in sign. Bφ had been negated.
- Then X and Z carried a latitude-shaped error — zero at the equator and at the poles, largest near 45° — while Y stayed perfect. That pattern is the geodetic-to-geocentric rotation and nothing else: the angle ψ is geodetic minus geocentric latitude, and its sign had been reversed.
Each signature named its cause before a line of the algorithm was re-read. That is the whole argument for reference vectors from the model's authors rather than a hand-picked sanity value: a wrong declination still looks exactly like a declination.
Sidereal time, precession and Polaris
Where the sky is at this moment is a function of Greenwich Mean Sidereal Time, the IAU-1982 polynomial in days and centuries from J2000:
Checked at the anchor everyone quotes — 18h 41m 50.548s at J2000.0, matched to a microsecond — and against the USNO's independent linear form across 2026–2031, where the worst disagreement is under a hundredth of a second.
Polaris is not where the catalogue says. Its Hipparcos position is J2000; the pole has moved since, and Polaris has its own proper motion, which near the pole is large in right ascension because the RA shift is the on-sky shift divided by cos δ. The app applies proper motion and then the IAU-1976 precession angles ζ, z and θ. Checked two ways: the rates that fall out at RA 0, Dec 0 are 46.1245″ and 20.0431″ per year against the IAU's 46.1244″ and 20.0431″; and integrating those rates independently, year by year, puts Polaris 37.52′ from the pole in September 2026 against the app's 37.55′. It was 44.2′ away at J2000, and the difference is a third of the reticle's ring spacing.
The reticle, and a factor of two
An iOptron AccuAlign polar scope shows a clock face marked 0 to 12 around a full circle. One dial hour is therefore 30°, while one hour of hour angle is 15°. Mapping one straight onto the other is a silent factor-of-two error that produces a perfectly plausible dial position — it is also why iOptron tell owners of 24-hour reticles to halve their reading.
The 180° is the polar scope inverting the view: a star at upper culmination sits above the pole in the sky and at six o'clock in the eyepiece. A test pins the factor of two directly — two sidereal hours later the star must have moved exactly one dial hour.
The whole method is anchored to iOptron's own published worked example from the SkyTracker Pro manual: Boston, 10 August 2016, 17:50:18 EDT, N42°30′28″ W71°08′49″.
| Quantity | Computed | Published | Delta |
|---|---|---|---|
| Radius from the pole | 39.9′ | 40.2′ | 0.3′ |
| Dial position | 00h 13.7m | 00h 18.4m | 2.3° of position angle |
2.3° of position angle is about 1.6′ of alignment error at Polaris' radius — fine for a lens on a tracker, and not something to be smug about. It is not explained. The manual's figure is read off a screenshot in a PDF, and a clock offset of exactly −9.25 minutes zeroes the residual to 0.01°, which is consistent with a screenshot taken a few minutes from the timestamp printed beside it. That is a hypothesis, not a finding, and the test bounds stay loose and say why in a comment.
The Sun
The Sun is in the app to find north with no instrument at all: at local solar noon a vertical shadow points exactly true north (or true south, south of the tropics). The position comes from the USNO low-precision series, good to about 0.01°, and solar noon is found by iterating the Sun's hour angle to zero through the same sidereal-time code everything else is tested on — with the sidereal-to-solar ratio 0.99727 applied at each step so it converges on the right minute.
Checked against things I can vouch for. The 2026 September equinox lands 11.8 minutes after the published instant, which is exactly what 0.01° of solar longitude is worth (the Sun moves a degree a day). The equation of time comes out as it should: solar noon at Greenwich is 11:43:33 UT on 3 November (published +16.4 minutes), 12:14:12 on 11 February (−14.2), and within eight seconds of noon on 1 September, where it crosses zero.
The Moon
The Moon is the brightest thing that will ruin a deep-sky exposure, so the app answers "is it up, and how full" as one judgement. Its position is the truncated lunar theory — the handful of largest periodic terms out of hundreds: the equation of the centre, then evection and variation, without which the error triples. That is worth about a quarter of a degree, half the Moon's own width: ample for pointing, useless for an occultation, and the module says so. The illuminated fraction is far better than the position, because it depends only on the Sun–Moon elongation ψ:
Using the elongation in ecliptic longitude alone, and ignoring the Moon's latitude, costs at most 0.2% of a fraction — measured over a year, not assumed. The phase tests are deliberately free of quoted dates: new Moons are found by search and asserted to fall 29.53 days apart, the phase must repeat on the synodic month and explicitly not on the sidereal one, and the ecliptic latitude must peak at the orbit's 5.1° inclination. The one quoted date is the 2000 January 6 new Moon that the lunation tables are numbered from.
When the Moon is drawn into the live sky it carries the phase it actually has, and the bright limb faces the Sun — a crescent does not sit with its horns pointing wherever you like. The position angle of the limb is the standard expression in the Sun's and the Moon's equatorial coordinates:
A position angle is measured from celestial north through east, and which way those run on the screen changes with every turn of the view. Rather than derive it — working out which way east runs in a projection of the sky seen from inside, not from outside like a map, is a handedness argument that is very easy to get backwards — the app projects two neighbours a quarter-degree away, toward north and toward east, and reads the directions off. A mirrored crescent is exactly the kind of error that looks fine until someone who knows the sky glances at it.
The Moon is drawn larger than life — the real half-degree is six pixels at a 65° field, too small to read a phase from — and the caption says so. Its position is exact; its size is a chart symbol.
The planets
JPL's approximate Keplerian elements: six numbers per orbit plus six rates per century, published as good from 1800 to 2050. Propagate to the date, solve Kepler's equation by Newton's method, rotate the orbit into the ecliptic, subtract the Earth's own position, and what is left is a direction — worth a few arcminutes for the inner planets and better than an arcminute for the outer ones. No perturbation theory, no light-time, no nutation; all three are smaller than the element error they would be correcting inside.
The strongest checks are events anyone can look up, and each pins more than one thing at once:
| Event | What it fixes | Result |
|---|---|---|
| Great conjunction, 2020-12-21 | Jupiter and Saturn at once — published as about 6′ apart | < 0.2° on the day, and > 0.5° a week before, so a projection that piles everything into one spot cannot pass |
| Mars opposition, 2020-10-13 | Earth − Mars geometry | longitude difference 180.02° |
| Jupiter opposition, 2023-11-03 | — | 179.91° |
| Saturn opposition, 2024-09-08 | — | 180.10° |
| Transit of Venus, 2012-06-06 | Venus on the solar disc | 0.15° from the Sun's centre |
| Transit of Mercury, 2019-11-11 | Mercury on the solar disc | 0.02° |
| Earth at perihelion and aphelion | The Earth's own orbit | 0.98330 and 1.01670 au |
Behind those are invariants nothing in the code knows: every planet stays between its own perihelion and aphelion, computed from its own elements; each returns to its longitude after its own sidereal period; Mercury never gets more than about 28° from the Sun and Venus about 47° — the limits that make them morning and evening objects, falling straight out of the geometry; a superior planet is nearest at opposition; Venus outshines everything and Neptune is never naked-eye. Magnitudes use the standard phase-angle polynomials, with the honest exception that Saturn's ring tilt is not modelled, so Saturn can read up to 0.9 magnitudes too faint — it decides how big a dot is drawn and nothing else.
The summary line is honest about visibility too. "Above the horizon" is not "findable": anything within about 12° of the Sun is called out as lost in the glare rather than offered as a target, and the ones that need binoculars say so.
The live sky
Hold the phone up and the stars sit where they are — a gnomonic projection, the one a camera lens performs, centred on wherever the phone is aimed. Straight lines in the sky stay straight, the scale is set by a field of view, and anything behind the phone is refused rather than mirrored through the origin, which would otherwise produce a plausible sky that is upside down and back to front.
The three axes come from the device's full rotation matrix — the W3C ZXY composition of alpha, beta and gamma, written out as columns because the columns are the axes — rather than from "heading for left–right and tilt for up–down". The naive version gimbals near the zenith and cannot roll the sky when the phone tips sideways; the matrix costs a dozen lines and has neither problem. Magnetic declination is one spin about the up axis. Headings are smoothed on the unit circle, because averaging 359° and 1° the obvious way gives 180° and swings the sky to the opposite horizon every time the phone crosses north.
Every handedness case is a limiting-case test: facing north, a star ten degrees east lands on the right; facing south it lands on the left; a device held upright with alpha zero faces north and with alpha 90 faces west; flat on a table its back faces straight down. The Milky Way is not traced by hand — it is the galactic plane, rotated into the sky from two J2000 directions, the galactic pole and Sagittarius A*, so it cannot drift out of step with the stars drawn on top of it. That frame checks out against a third point it was not built from: galactic longitude 90° lands at RA 318.0°, Dec +48.33°, in Cygnus.
The circumpolar chart has its own independent check. The rule every southern observer learns — run the long axis of the Southern Cross out about four and a half times its own length to find the pole — is asserted against the chart, and nothing in the projection knows that number. It coming out at 4.5 validates scale, orientation and handedness in one line.
Steering this view by hand has a case study of its own: The wall at 71° — a drag that stopped eighteen degrees short of its limit, a diagnosis of gimbal lock that was wrong, the closed-form solve that replaced an iterating search, and the five attempts a green test suite could not tell apart.
The bug an audit found
When the features stopped, I audited every module independently: 96 checks against definitional constants, published events, and — most usefully — two separately-written implementations that must agree. Two calculations of the Sun's position, by different methods in different modules, disagreed by 0.3676°. General precession in longitude from J2000 to the date is 0.3733°. Once that is removed they agree to 0.006°. The methods were fine; the frames were not.
Sidereal time is of date. The star catalogue, the planetary elements and the galactic frame are all J2000. Placing one against the other put every star, every planet and the whole Milky Way off from where the sky had them: about 22′ near the equator and the ecliptic, and about 9′ near the pole, where the pole itself moves by the smaller precession constant. The Moon, whose series is of date, sat where it should — drawn two-thirds of its own width from the stars beside it. Polaris was on the dial at its true 37.5′ from the pole and in the sky view at its J2000 44.2′.
Nothing on screen looked wrong. Twenty-two arcminutes is three pixels at a 65° field, and every star was off by the same amount in the same direction. That is exactly the kind of error that survives: consistent, small, and invisible until something in a different frame is drawn beside it.
The fix is one rotation: the precession as a 3×3 matrix, built once per tick, applied wherever J2000 meets sidereal time — stars, planets, Milky Way, chart. The Moon is flagged as already being of date and left alone; precessing it with the rest would move it 22′ the other way. The matrix is pinned against the validated point routine to 10−10 degrees, and the two Suns are now held together in one frame by a permanent test. Polaris in the live view moved from 44.15′ to 37.54′ from the pole, which is where it is.
When the test was wrong and the code was right
This is the honest shape of verification, and it happened eight times. Each one was a failing test that turned out to be the test's own mistake — and in every case the code was left alone and the assumption was corrected, with the reason written into the test:
- The smallest signed turn from 0° to 180° is a tie; the test had picked a side.
- "Gamma is roll." It is rotation about the phone's long axis, and with the phone upright that is a yaw, not a roll.
- Gamma Sagittarii sits at galactic latitude 4.7°, not under 4°; the frame was right and the remembered figure was not.
- Adding 400 km of height moves you 399.9978 km further from the Earth's centre at 45° latitude — the local vertical does not point at the centre of an ellipsoid. The displacement is exactly 400; the radial change is not, and that is WGS-84, not an error.
- After one sidereal month the Moon's longitude has not changed — the difference is zero, not half a turn. Normalising to ±180° and asserting 180 was the same mistake as the first item.
- Neptune's aphelion came out 0.12 au short because the sampling window was 98 years and Neptune's orbit is 165. It never reached aphelion inside the test.
- The regression test for the frame bug expected 22′ at the pole. The pole moves by θ, about 20″ a year, 8.9′ over the interval; a star on the equator moves the full 50″ a year.
- Arc-cosine of a dot product cannot tell four milliarcseconds from zero. It flagged a perfect matrix-versus-function agreement as a failure. The separation metric is now a chord.
Also in this category: four lunation dates I wrote from memory and labelled "published". One was wrong by a fortnight and the test caught it. They were removed, because a reference value nobody can vouch for reads as corroboration while proving nothing — the same discipline that caught the WMM bugs, pointed at the author.
Bugs that looked completely correct
Every one of these shipped a plausible screen. None was found by looking at it.
- The circumpolar chart was 180° rotated. A rotated star chart is still a convincing star chart. It was caught by checking against the real sky — on a September evening at 42°N, Cassiopeia is high in the north-east and the Dipper low in the north-west — not by inspection.
- The sky view never attached an orientation listener, so the feature was inert on the very device it was written for. Found when a phone was finally held up to it: "nothing moved except the button."
- The service-worker precache list drifted three times, shipping modules unlisted. Works perfectly online; fails only in a field at night, which is the one place the app is for. A test now reads the module directory and the worker's list off disk and fails on any gap.
hiddennever hid anything. An author rule settingdisplay: gridbeats the browser's own[hidden], so the hand-entry form had been open since it shipped and its toggle button appeared dead.- Five button-label conventions in one interface. "Dark Mode" meant you are in Dark Mode; "Turn on compass", one card below, meant you are not. Now every label is the action, and a test reads the shipped HTML and JavaScript to enforce it. That test caught two flaws in itself: a regex that silently matched nothing, and alias resolution that credited one button with another's labels.
- Rotating back to portrait left the sky view stuck full screen. A media-query change listener fired one way and not the other. It is driven by
resizenow. - The share-preview image returned 444 for its whole life. Right bytes on disk, extension not on the edge whitelist, connection closed. Every link preview had been blank.
- The Evomedia mark was the only white light left on the screen in Night Mode — a white square behind a 0.7 brightness filter is still #b3b3b3, with green and blue in it, landing exactly where rods are most sensitive.
Accessibility as the product
This is the reason the project exists, not a pass made over it afterwards, and the choices are measurable rather than a vibe:
- Single taps only. No drag, pinch, swipe, double-tap or press-and-hold; no chords; zoom is two buttons. Targets are 3.4 rem and never move, because re-acquiring a target that has shifted is expensive for a mouthstick or a trackball.
- Night Mode is pure red channel — every colour is #RR0000, so the only variable is intensity — behind a 0.7 brightness filter, and nothing is filled: a filled button is a lantern, an outline is a marker. The first version used #ff5544, which reads as red and is not; 0x55 of green is a lot of light exactly where rods peak. Audited: 179 CSS colour properties, zero leaking green or blue; two canvases, zero green or blue pixels.
- Every button says what pressing it does. "Use Night Mode", not "Dark Mode". A state label only works if you can also see which state you are in; read aloud, "button, Dark Mode" says nothing about what happens next.
- The latitude is spelled out in words beneath the number, and the whole briefing is one sentence that is both printed and spoken, so the audio and the text cannot drift apart.
- The pointing pad appears only when it is the only way to drive. While the phone is steering, five full-width buttons do nothing and are not on screen; the moment there is no sensor to follow — every desktop, any phone that declines permission — they open themselves.
- Holding the phone up is never the only way. Buttons and arrow keys drive the same projection. That decision came from use, not taste: three testers could not line up using worded two-axis arrow instructions, and the live view is what fixed it.
- Turning the phone sideways fills the screen — via a CSS overlay, because the Fullscreen API needs a user gesture and a rotation is not one any browser accepts.
Engineering shape
| Measure | Value |
|---|---|
| Runtime dependencies | 0 — and no build step; the files served are the files written |
| Source | 15 modules, about 4,800 lines excluding data |
| Data shipped with the app | 9,096 stars (Yale Bright Star Catalog), 26 constellation figures in 161 segments, WMM2025 to degree 12 |
| Tests | 153 in 15 files, including NOAA's 100 magnetic vectors |
| Tests that read the shipped files | the offline precache list, every button label, every element id the app reaches for, and the plain-text twins of every doc |
| Pull requests to date | 27, each reviewed and merged by its own title |
| Network use | None after first load — except asking where the ISS is, which cannot be predicted without orbit propagation and says so |
The constellation figures are built by a script that resolves Bayer letters against the catalogue and fails the build on any star it cannot find, rather than silently dropping a line. The plain-text twin of each document is generated and checked by a test, never hand-kept, because a hand-kept copy drifts and the drift is invisible precisely because nobody reads both.
What is not claimed
- Android orientation semantics are unverified on hardware. Whether a given phone's absolute orientation is magnetic- or true-north referenced, and whether beta and gamma mean what the code assumes, can only be settled by holding one at a known angle. The app shows its raw sensor readout for exactly that reason.
- The southern dial direction rests on reasoning, not a published vector. iOptron publish no southern worked example. The test pins the two hemispheres as exact mirrors, which is the claim actually being made, and the docs say to confirm against Stellarium before trusting it in the field.
- The 2.3° residual against iOptron's example is open. A clock offset explains it perfectly; that is not the same as knowing.
- ISS passes are not predicted. Showing where the station is now is a fraction of the work of SGP4 propagation from fresh elements, and honest about being less.
- A planned date is deliberately not remembered between visits. Every other setting is; a stale planned date is the one that would hand someone confident mount numbers for the wrong night.
- Time zones. A planned time is read in the device's zone, and the app cannot know you will be somewhere on a different clock. The field says so in those words.