Skip to content
Back to Blog
· By Swaroop Shere

Four Seconds Early: A User Report, a JPL Ephemeris, and Three Layers of Eclipse-Timing Bugs

A user compared eclipseClick against Xavier Jubier's calculator for a site in Spain and found a small, consistent discrepancy. Chasing it down meant giving up on cross-tool comparison entirely and building a referee out of a JPL ephemeris instead — which is how we found three unrelated bugs stacked on top of each other.

AstronomyMathematicsDelta TLunar LimbBug FixJPL Horizons

A user preparing for the August 12, 2026 total eclipse in Spain wrote in with a comparison table. eclipseClick's C1 matched Xavier Jubier's calculator almost exactly. But C2 (totality begins), maximum eclipse, and C3 (totality ends) were all 4–5.5 seconds early — consistently, at two sites roughly 9 km apart. Eclipse Orchestrator agreed with Jubier; at the user's primary site its maximum-eclipse time matched Jubier's to the exact tenth of a second. eclipseClick was the outlier, by a wide margin, on every tool it was checked against.

That report turned into three weeks of work and three unrelated bugs, only one of which was the one we went looking for. This is the postmortem, including the part where we discovered our own regression tests had been quietly rewritten to agree with the wrong answer.

The Report

The user's site was Baños de Hontoria, a village on the centerline of the 2026 path in northern Spain, with a second comparison site about 9 km away. Both showed the same fingerprint: C1 essentially exact, and then a growing early bias through C2, maximum, and C3. A constant offset would point at something simple — a wrong time zone, a transcription error. A growing offset that starts near zero and gets worse as the eclipse progresses points at something structural.

Two independent, well-regarded tools — Jubier's calculator and Eclipse Orchestrator — agreed with each other and disagreed with us. That's the point at which it's tempting to just split the difference, or assume the more popular tools are right by default. We didn't want to fix eclipseClick to match Jubier for the same reason you shouldn't grade your own exam against a classmate's answers: agreement isn't correctness, it's just agreement.

Why Cross-Tool Comparison Couldn't Settle It

eclipseClick, Jubier's calculator, and Eclipse Orchestrator all share ancestry: Fred Espenak's Besselian elements and IERS-derived Delta T. Two tools agreeing with each other is evidence that two implementations of a shared method agree — it is not proof that the shared method, or either implementation, is correct. If all three tools inherited the same subtle error from a common source, they'd agree perfectly and all be wrong together.

We first tried sidestepping the Besselian-element ecosystem entirely with a semi-analytic lunar ephemeris (the astronomy-engine library, ELP2000-based). It wasn't precise enough to referee anything: ELP2000-class ephemerides carry roughly 1–2 arcseconds of positional uncertainty, which translates directly into 2–4 seconds of contact-time uncertainty — the same order of magnitude as the bug we were trying to measure. Using it would have been like timing a 100-meter sprint with a stopwatch that has a 2-second margin of error.

The only kind of ephemeris precise enough to referee a sub-second dispute is a numerically integrated one. We pulled topocentric apparent Sun and Moon positions directly from the JPL Horizons API, using the DE441 ephemeris and current Earth-orientation data, for the user's exact coordinates. Then we solved the contact times from first principles — tangency conditions on the apparent solar and lunar disks — with no Besselian machinery anywhere in the computation. This gold-standard solve doesn't know what a Besselian element is, so it can't have inherited anyone's bug.

Building that referee had its own trap. Horizons returns positions at fixed time steps (we used one-minute samples), and the temptation is to interpolate the Sun–Moon angular separation directly to find where it crosses the eclipse threshold. That's wrong. Near conjunction, separation as a function of time is a sharp hyperbola, and fitting a parabola through samples straddling the minimum misplaces the vertex — sometimes by seconds. Our first attempt did exactly this, and it was self-inconsistent in a way that gave it away: its own reported maximum-eclipse time disagreed with its own mid-totality point. The fix is to interpolate the smooth underlying quantities — right ascension and declination for both bodies — and compute separation from the interpolated coordinates, not interpolate the separation itself. The corrected method agreed with itself to 0.1 seconds.

Baños de Hontoria — contact times, seconds delta vs. DE441 gold standard (top row is absolute UTC)
Tool C1 C2 MAX C3
JPL Horizons DE441 (gold) 17:34:26.4 18:29:19.0 18:30:12.5 18:31:05.8
Xavier Jubier −1.2 +1.4 +0.1 −1.3
Eclipse Orchestrator −2.1 +1.0 +0.1 −2.8
eclipseClick (before) −0.9 −3.2 −4.4 −5.5
Delta vs. DE441 gold standard (seconds) C1 C2 MAX C3 0 (DE441) −6 −4 −2 +2 Jubier Eclipse Orchestrator eclipseClick (before) eclipseClick (after)
Figure 3. eclipseClick before the fixes was the clear outlier, sliding steadily negative from C1 to C3. After the fixes, its dots sit with Jubier and Eclipse Orchestrator, clustered near zero.

Layer 1: The Hour-Angle Bug

With a trustworthy referee in hand, we started eliminating suspects in order of plausibility:

  • Delta T value. A wrong Delta T shifts every contact by the same constant amount. It can't produce an error that's ~0s at C1 and grows to 4–5.5s by C3.
  • Coefficient transcription. A misread Besselian coefficient was good for roughly 0.5 seconds of error — an order of magnitude too small.
  • Observer geometry. Ruled out directly: the two comparison sites were 9 km apart and showed identical residuals. A geometry bug tied to a specific location wouldn't produce the same error at two different points.
  • Stale elements. We reconstructed x, y, d, and μ independently from DE441 geocentric vectors. The catalog matched to about 30 meters, and the μ rotation rate matched to 0.003 arcsec/hour. The elements were fine.

Every obvious suspect checked out clean, and the answer was still wrong. The last place left to look was the observer's position itself — not as computed from geodetic coordinates, but as it actually sits on the fundamental plane. Using lunar parallax (the difference between Horizons' topocentric and geocentric Moon positions), we measured the true observer position directly and compared it to what eclipseClick's solver was using.

eclipseClick's observer was displaced 6.3 km from where it should have been — and that displacement corresponds exactly to a +0.29° hour-angle over-rotation, which is precisely how far the Earth spins during Delta T (15.041°/hour × 69 seconds ≈ 0.29°).

Before — contact-solver.ts
// mu is the Besselian ephemeris hour angle, used directly
const H = mu

The root cause: Besselian μ is the ephemeris Greenwich hour angle, referenced to Terrestrial Time. But the observer physically rotates with the Earth on Universal Time — and UT lags TT by Delta T. Using μ as-is silently assumes the observer rotated as far as TT implies, which is 69 seconds' worth of extra spin more than actually happened. The classical fix is the "ephemeris longitude" correction, documented in Jean Meeus's Elements of Solar Eclipses:

After — contact-solver.ts
// Ephemeris-longitude correction (Meeus, "Elements of Solar Eclipses").
// mu is referenced to TT; the observer rotates on UT, which lags TT
// by deltaT. Without this term the solver over-rotates the observer
// by Earth's spin during deltaT.
const OMEGA_EARTH = 7.2921150e-5  // rad/s

const H = mu + longitudeRad - OMEGA_EARTH * deltaT

The embarrassing part: this exact correction was already present elsewhere in the codebase — eclipseClick's own eclipse-map rendering code applies it, with a comment noting that it "matches Xavier Jubier's kSIDEREAL2SOLARTIME constant." The map was drawing the path correctly the entire time. The contact-time solver, a completely separate code path, had never gotten the memo.

This also explains the user's exact fingerprint. Baños de Hontoria sees the Sun at only about 8° altitude during this eclipse, and the site is close to central — the observer sits almost directly on the shadow axis. At that geometry, 6 km of lateral displacement swings the time of closest approach by roughly 4 seconds. C1's geometry, by contrast, is nearly insensitive to that same displacement — which is exactly why C1 matched Jubier almost exactly while C2, maximum, and C3 didn't.

After this fix alone, the maximum-eclipse error at Baños de Hontoria went from −4.4s to −0.5s.

Earth & hour angle Earth's rotation True position (UT) eclipseClick's position (TT, uncorrected) ω × ΔT ≈ 0.29° (exaggerated for clarity) Fundamental plane Umbral radius ≈ 35 km 6.3 km displacement (≈18% of umbral radius)
Figure 1. The missing ephemeris-longitude correction over-rotated the observer by Earth's rotation during ΔT — a 0.29° hour-angle error that displaces the observer 6.3 km on the fundamental plane, about 18% of the umbral shadow's radius.

Layer 2: Stale Delta T Everywhere

Fixing the hour-angle bug didn't close the investigation — it exposed a second, unrelated problem sitting underneath it. eclipseClick's Delta T catalog turned out to be full of old model extrapolations rather than current data:

  • The catalog's value for April 8, 2024 was 70.6s. IERS observed data for that date is 69.20s.
  • Catalog entries for eclipses in the 2030s ranged 77–80.6s. Current IERS long-term predictions put them at roughly 70–72s — up to 8.6 seconds of error for future eclipses.

The interesting physics is why the catalog was wrong in this particular direction. Earth's rotation has been anomalously fast since 2020, which means Delta T has been decreasing: from 69.36s in 2020 down to 69.11s in January 2026. The old table assumed the textbook long-term trend — tidal braking slowly increasing Delta T — and simply extrapolated that curve forward. Reality did something the textbook curve doesn't capture.

ΔT, Jan-1 values (seconds) 73 72 71 70 69 Earth rotating anomalously fast Aug 12, 2026: ΔT=69.17s 2020 2024 2028 2032 2036 Old table (stale extrapolation) Refreshed (IERS + Bulletin A)
Figure 5. The old table assumed a smooth tidal-braking increase. The refreshed table tracks the 2020–2026 dip from anomalously fast Earth rotation, then reconnects to IERS long-term predictions — diverging from the old curve by up to 8.6 seconds in the 2030s.

All 26 catalog entries were refreshed from three sources depending on how far in the future the eclipse falls: IERS observed data (deltat.data) for past eclipses, Bulletin A predictions for the near term, and IERS long-term predictions further out. The August 12, 2026 entry now uses ΔT = 69.17s (Bulletin A for that date) — fresher than Jubier's 69.1s and Eclipse Orchestrator's 69.15s.

A wrinkle worth flagging: NASA/GSFC's own published page for this eclipse quotes ΔT = 75.4s, from an older Morrison–Stephenson model. We still re-synced the Besselian elements themselves to NASA's published values, because the elements are Delta-T-independent — they describe the shadow's geometry, not the clock correction. We just pair those elements with the fresher IERS value (69.17s) rather than the number printed alongside them.

Layer 3: The Limb Data Was Noise

With the smooth-limb solve fixed, we expected the limb-correction regression tests to get easier. Instead they started failing outright — producing corrections of 5.5 seconds where roughly 1 second was expected, and a 0.35-arcsecond change to the assumed solar radius shifted the answer by a further 5 seconds. Something underneath the correction math was unstable.

The trail led to the pre-computed limb-profile dataset, built from the LRO LOLA 64 ppd digital elevation model. Adjacent samples 0.1° apart — which should change by 0.1–0.3 arcsec on a real lunar limb — were jumping by a mean of 2.5 arcsec, with physically impossible −7.6″ valleys. We found three data bugs and one physics bug:

  1. Wrong byte order. The generator read the DEM as big-endian, but the PDS product is LSB_INTEGER — little-endian. Every pixel was byte-swapped garbage. The landmark proof: decoded correctly, the Selenean summit gives +10,730 m against a true value of +10,780 m, and the Antoniadi crater floor gives −9,115 m against a true −9,100 m. Decoded big-endian, both landmarks come out as nonsense.
  2. Missing scale factor. The PDS product's ×0.5 m scaling factor was never applied.
  3. Fill-value guard misfiring. A "fill value" guard at ±15000 was silently zeroing 54% of the already byte-swapped pixels.
  4. Wrong physics. The profile point-sampled the geometric 90° limb circle instead of computing the actual silhouette — the maximum apparent elevation, (R+h)·sinθ − R, over terrain within ±3° of the limb circle. A peak sitting 1° behind the limb loses only about 264 m to foreshortening and can still be the actual skyline — point-sampling the circle misses it entirely.

Put together: every limb correction the app had ever produced contained no real lunar terrain signal at all. On top of that, the correction algorithm's fixed-point iteration was divergent at near-central sites, where the contact position angle rotates roughly 0.15°/second — each trial correction was re-anchoring the terrain lookup on different ground, and the iterates oscillated instead of converging. One real trace: 5.78s → 0.13s → 5.47s, never settling.

Limb profile deviation (arcsec), position angle 107.0°–111.8° +6 +2 0 −4 −6 107.0° 108.0° 109.0° 110.0° 111.0° 111.8° Old (byte-swapped) New (silhouette)
Figure 4. The old dataset's roughness averaged 2.5″ per 0.1° step, including several samples that landed on exact 0.0 — the zeroed fill-value holes. The regenerated dataset's roughness is 0.10″ per 0.1° step, in line with real lunar terrain at this scale.

The fixes: regenerate the dataset with correct byte order, correct scaling, and true silhouette physics; and anchor the terrain lookup once, at the smooth-contact position angle, using an exact Newton solve rather than re-anchoring on every iteration. The new dataset's deviations range from −2.46″ to +3.87″, with an RMS of 0.97″ and a roughness of 0.10″ per 0.1° step — a 25× improvement. The generator now also has a hard-coded landmark sanity check: it refuses to run if the Selenean summit doesn't decode to approximately 10,730 m.

The Tests That Guarded the Bugs

The most uncomfortable part of this investigation was finding that three existing regression tests hadn't just failed to catch these bugs — they had actively encoded the bugs as expected behavior:

  • One test asserted that timing errors at an Iceland site and a Spain site had opposite signs, with a comment claiming this "proved" the error was element-driven noise rather than a systematic bug. It was the opposite: that consistent sign pattern was the hour-angle bug's fingerprint, misread as evidence of its absence.
  • One test asserted a specific Besselian x₀ coefficient value that had, at some point, been hand-patched to mask the hour-angle bug. With the correct NASA-published coefficient in place, the pre-fix solver misclassified Reykjavík as partial at magnitude 0.99997 — so the coefficient had been quietly bent to compensate, producing a nonsense 4-second "totality" for a location the buggy solver otherwise couldn't place inside the path. With the solver fixed, the correct coefficient gives Reykjavík its real, physically legitimate ~56-second edge-of-path totality.
  • One test asserted that Reykjavík's totality duration was under 30 seconds — a number that only made sense downstream of the other two bugs.

The lesson is a plain one, but worth stating directly: a regression test calibrated against a buggy implementation becomes that bug's bodyguard. It will fail loudly the moment you fix the bug it was quietly protecting, and it will look, at a glance, like the fix broke something. Tests need to be anchored to external ground truth, not to whatever the code currently outputs.

That's now permanent. A committed JPL Horizons DE441 regression harness pins the solver to first-principles gold times, with a maximum-eclipse tolerance of 1.0 second. The hour-angle bug class described above would fail this harness at roughly 4 seconds — it cannot come back unnoticed.

Results

With all three layers fixed, eclipseClick's contact-time solver was re-validated against every reference we had:

Comparison Agreement
Xavier Jubier (3 validation sites: Iceland + 2 Spain) Within 0.1s on all five contacts
Eclipse Orchestrator Within 1.0s
JPL Horizons DE441 (user's sites) Maximum exact to <0.1s; worst contact 1.4s
Overall smooth-limb RMS (25 contacts, 3 reference sources) 0.80s (was ~4.4s)
Baños de Hontoria — error vs. DE441 (seconds) −8 −6 −4 −2 0 +2 C1 C2 MAX C3 C4 Before After
Figure 2. Before the fixes, error grew steadily more negative from C1 through C4. After, every contact sits within about a second and a half of the DE441 gold standard, with no clear trend left to chase.

Limb corrections themselves also look physically sane again: mid-path corrections now run ±0.4–1.7 seconds with terrain-driven signs (rather than a fixed direction), edge-of-path corrections run around 6 seconds — which matches Espenak's own note that limb corrections are "far more critical near path limits" — and the solar-radius option shifts C2 by the physically-correct 0.6–1.0 seconds, instead of the 5-second swing that first tipped us off that something in the limb dataset was broken.

Update (July 22): The Hardening Pass

Since this post went up, the investigation turned into a systematic audit of the whole pipeline — from Besselian elements through limb correction to the code that actually fires the shutter. Everything below is merged and ships in the pre-eclipse release.

The gold harness now covers every reported site. The DE441 referee was extended from the original Spain pair to all five user-reported locations — four in Spain plus one in Iceland, a ~950 km baseline across the path. Maximum-eclipse agreement is within 0.03 seconds at every site, with a site-to-site spread of 0.06 seconds; contacts sit within ~1.6 seconds, explained by known limb and solar-radius convention differences. All of it is pinned as permanent regression tests.

A second time-scale conflation, caught early. The solver was using Delta T (TT−UT1) to convert internal ephemeris times to the UTC timestamps your camera schedule runs on. The correct offset for that conversion is TT−UTC — exactly 69.184 s since 2017, from the leap-second table. Today the difference is a harmless ~0.01 s; for eclipses in the 2030s, once leap seconds are abolished and UT1 drifts freely from UTC, it would have grown to seconds. The two time scales are now separated in the code by type, not by convention.

The Moon you see is not the Moon the geocenter sees. Lunar parallax shifts the apparent sub-observer point — the libration that decides which mountains and valleys sit on the limb — by up to a full degree for a ground observer versus the geocentric value the old code computed. On the 0.5° terrain grid that is two whole cells of Baily's-beads feature selection, worth roughly 0.4–1 second of bead timing. The libration model was rebuilt on the fuller Meeus theory (extended lunar series, nutation, physical libration) with a topocentric parallax correction, and validated the same way as the contacts: against JPL Horizons' own sub-observer points at all five gold sites, agreeing to better than 0.03° everywhere — with a hard tolerance gate committed as a test.

Correct times deserve a correct trigger. Two execution-engine fixes landed alongside the solver work. Capture commands now fire early by the measured per-method latency (about 20 ms for a serial DSUSB cable, about 80 ms for USB SDK control), so the shutter opens at the scheduled instant rather than a command-latency late. And the execution clock is pinned to a monotonic source the moment a run starts: an OS clock sync mid-eclipse — NTP deciding 18:29 is the perfect time to adjust your laptop by two seconds — can no longer move a single scheduled capture. The app logs a warning and stays on the timebase your contact times were computed against.

One honest caveat surfaced by the same harness: our catalog entries for the 2027 and 2028 eclipses, which still carry NASA-fit polynomial elements, sit ~1 second off DE441 at maximum and up to ~3 seconds on contacts. They're pinned at those wider tolerances for now; refitting the whole catalog against DE441 is queued for after August 12. For the 2026 eclipse itself, the numbers above are the numbers.

None of this would have gotten fixed without a user comparing eclipseClick's numbers against another tool and writing in about a discrepancy that, on paper, looked small enough to ignore. If you're curious about the underlying math, see How eclipseClick Computes Eclipse Contact Times. We've also gone back and added an editor's note to two earlier posts — When Two Eclipse Predictors Disagree at the Path Edge and Two Bugs at the Edge — where conclusions we were confident in at the time turned out to be downstream of the bugs described here.

Want contact times you can check against a JPL ephemeris?

eclipseClick's solver is now pinned to a DE441 regression harness, with fresh Delta T and a rebuilt lunar limb dataset.