A 2-Stop Exposure Error: Recalibrating eclipseClick's Partial-Phase Formula
A customer's overexposed photos of the April 2024 eclipse turned out to be right — and eclipseClick's exposure formula was borrowing the wrong constant from the right table.
A customer wrote in with a Canon EOS Rebel T3i, a Tamron 16–300mm zoom, and a Baader AstroSolar ND 5.0 filter. At ISO 100, f/6.3, eclipseClick recommended 1/250s for the partial phases. Their camera actually needed roughly 1/1000–1/2500s to avoid blown highlights — and their real photos from the April 2024 eclipse backed that up. That's a gap of almost two full stops, and it doesn't go away when you change ISO: at ISO 400 the app still overexposed by the same margin.
A consistent 2-stop offset across ISO and aperture is the signature of a wrong constant somewhere in the exposure formula, not a rounding error. This post walks through the formula, the bug, and how we verified the fix against three independent sources before shipping it.
The Exposure Formula
eclipseClick derives shutter speed from Fred Espenak's (NASA/GSFC) standard eclipse-photography exposure equation:
f is the f-number and ISO is the sensor sensitivity — both known quantities. Q is a single "brightness exponent" that encodes how bright the subject is: a higher Q means a dimmer subject and a longer shutter speed. Espenak's tables give Q for the bare solar disk, for the diamond ring, for the corona at various radii — and, critically for this bug, for the solar disk seen through specific neutral-density filter strengths.
eclipseClick modeled the filtered case as a simple two-term linear function:
ND is the filter's optical density (5.0 for a typical Baader AstroSolar film, for example). slope converts filter density into stops of light lost. Get the slope wrong, and every filtered exposure is off by an amount that scales with filter strength — exactly the failure mode the customer reported.
The Bug: A Physically-Exact Slope in an Empirically-Calibrated Model
eclipseClick already has a general-purpose utility, ndToStops(),
that converts optical density to stops using the exact physical relationship
stops = ND × log2(10)
≈ ND × 3.322. That relationship is correct —
it's the textbook definition of optical density. The bug was reusing it here:
const Q_SOLAR_DISK = 23 // derived from a misread reference table
function getEffectiveQ(filterNd: number): number {
return Q_SOLAR_DISK - ndToStops(filterNd) // ndToStops = nd × log2(10)
}
The problem: Espenak's own published table for filtered partial-phase exposure
(Solar Eclipse Exposure Guide, Table 4.2) doesn't use the physically-exact log2(10)
slope. It uses an empirical value calibrated against real film/sensor response, and that value is
3.0 stops per unit of ND, not 3.322. The 9% difference between
3.0 and 3.322 doesn't sound like much, but multiplied by a filter density of 5.0 it becomes a
1.6-stop error — and the original Q_SOLAR_DISK = 23
constant compounded it further, having itself been transcribed from the wrong column of that same
table.
Reading Table 4.2 Correctly
Table 4.2 lays out shutter speed against ISO and f-number for a range of filter densities, but the
table uses a diagonal layout: the f-number column you're meant to read shifts by one position for
every doubling of ISO. A straight vertical read at a fixed column — which is what produced the
original Q_SOLAR_DISK = 23 comment
years ago — silently reads the wrong ISO row's exposure value.
Reading the table correctly gives two clean anchor points for filtered partial-phase exposure:
| Filter density | Espenak's Q |
|---|---|
| ND 4.0 | 11 |
| ND 5.0 | 8 |
The slope implied by these two real, labeled anchors is (11 − 8) / (5.0 − 4.0) = 3.0 stops per ND — Espenak's empirical calibration, not the theoretical 3.322. We rebuilt the model to interpolate directly between these two anchors instead of relying on a single misread constant:
// Espenak, NASA/GSFC, "Solar Eclipse Exposure Guide" Table 4.2, "Partial" rows.
// The table's own slope between these two labeled anchors is 3.0 stops/ND —
// not the theoretical log2(10)≈3.322 — so this is Espenak's empirical
// calibration, not a derived physical constant.
const ESPENAK_PARTIAL_ANCHOR_LOW = { nd: 4.0, q: 11 } as const
const ESPENAK_PARTIAL_ANCHOR_HIGH = { nd: 5.0, q: 8 } as const
export const PARTIAL_PHASE_ND_SLOPE =
(ESPENAK_PARTIAL_ANCHOR_LOW.q - ESPENAK_PARTIAL_ANCHOR_HIGH.q) /
(ESPENAK_PARTIAL_ANCHOR_HIGH.nd - ESPENAK_PARTIAL_ANCHOR_LOW.nd) // = 3.0
export const Q_SOLAR_DISK =
ESPENAK_PARTIAL_ANCHOR_HIGH.q + PARTIAL_PHASE_ND_SLOPE * ESPENAK_PARTIAL_ANCHOR_HIGH.nd // = 23
function getEffectiveQ(filterNd: number): number {
return Q_SOLAR_DISK - filterNd * PARTIAL_PHASE_ND_SLOPE
} Interesting wrinkle: Q_SOLAR_DISK
re-derives to the same numeral, 23, as before. The visible constant wasn't actually wrong —
the slope multiplying it was. This is why the bug was hard to spot by inspection: the headline
number that looked authoritative was correct all along, while the real error was hiding in a
shared utility function that is, in every other context in the codebase, completely correct.
ndToStops() is untouched
— it's still the right tool for physically-exact ND conversions elsewhere in the app. It was
just the wrong tool for this one, empirically-calibrated model.
Cross-Checking Against Three Independent Sources
Before shipping a recalibration, we wanted more than one line of evidence. Three independent sources turned out to agree almost exactly on Q = 8 for an ND 5.0 filter:
- Espenak's published table — Table 4.2's own labeled anchor, read directly from the source PDF rather than a secondhand transcription.
- The customer's real photos — back-solving their reported working exposures (ND 5.0, various ISO/aperture combinations) for the implied Q gave values clustering tightly around 8, not the old model's 6.39.
- An existing test fixture referencing a real Eclipse Orchestrator exposure (1/1600s at f/5.6, ISO 200) — back-solved under the corrected model, it implies Q ≈ 7.97, matching Espenak's anchor almost exactly. Under the old model, that same fixture had been mislabeled as an "ND 4.5" reference, which was itself an artifact of the buggy slope; it's actually ND 5.0.
That third point mattered most during review: the first fix we drafted (averaging the two anchors into a single constant, rather than interpolating between them) actually broke that pre-existing, real-world-validated test. Tracing why revealed the mislabeled fixture, and confirmed that anchor interpolation — not averaging — was the right model.
Worked Example
ND 5.0 filter, f/8, ISO 100 — the customer's reported configuration:
| Model | Qeff | Recommended shutter |
|---|---|---|
| Before (log2(10) slope) | 6.39 | 1/125s (overexposed) |
| After (Espenak anchor slope) | 8.0 | 1/500s |
A 2-stop shift, exactly matching the gap the customer reported — and it holds across every aperture/ISO combination, since the error was in the slope, not a fixed offset.
What's Still an Approximation
Only ND 4.0 and ND 5.0 are directly anchored to Espenak's table. Other common filter densities — ND 3.8 glass, ND 4.5 welder's shade, custom densities — are linear extrapolations along the same 3.0 stops/ND slope, and real filters carry manufacturing tolerance that no formula fully closes. As always, we recommend a quick bracketing test on the uneclipsed sun with your actual gear before eclipse day — Espenak's own stated mitigation, and the cheapest insurance available.
Action Item: Regenerate Existing Scripts
If you built a script before this fix shipped — especially one with partial-phase lines through a solar filter — the shutter speeds stored in it were computed under the old, overexposed model. They will not update themselves.
- Update to the latest version of eclipseClick.
- Open each saved script and regenerate the partial-phase timeline through the wizard, or build fresh.
- Don't re-run an old script as-is — the shutter speeds it stored were computed under the old formula and won't be recalculated automatically.
One line-item bug report, traced back to a primary source and cross-checked three ways, turned into a 2-stop correction across every filtered partial-phase exposure eclipseClick recommends. Thanks to the customer who took the time to compare their own photos against the app's numbers — that's exactly the kind of report that catches this class of bug.
Shooting partial phases through a solar filter?
eclipseClick computes exposure for every phase of totality, calibrated directly against Espenak's published tables.