Skip to content
← Back to Blog · · By Swaroop Shere

Planning at the Speed of Your Camera

Measured USB Latency, a Placement-Aware Scheduler, and Squeezing Every Corona Bracket Out of Totality

Totality is short and unforgiving. This is the story of three fixes that all point the same direction: stop guessing, start measuring. A scheduler that used to split mid-totality into three equal thirds regardless of cost, a planner that priced every camera's USB write from a static table, and a log that reported the wrong number as "timing error." All three are gone now — replaced with an allocator that enumerates real bracket layouts, a Test button that measures your actual camera, and a log that tells you which one is degrading.

SchedulingUSB LatencyPerformanceCoronaBracketingBenchmarksExecution Engine

TL;DR

Three changes, one theme. The scheduler used to split mid-totality into three equal-time thirds for inner corona, outer corona, and earthshine — ignoring that an outer-corona bracket can cost 2× an inner one, and burying earthshine 24–68s after maximum eclipse when it wants the darkest sky at MAX. A placement-aware allocator that enumerates whole-bracket layouts took utilization from 71% to 89% of a 120-second totality. The planner used to price every camera's per-frame USB settings write from a static guess — 150ms for "vendor SDK." Real bodies vary by multiples: a Canon bridge's Tv verify loop alone can run ~600ms. A new Equipment-page Test button now measures each body's actual write latency and identity-gates it to that exact camera and backend. The logs used to report raw frame-to-frame interval as "timing error," so a perfectly on-plan bracket read as "+900ms late" on every frame. They now report start drift and interval separately. A dual-camera dry run — Canon 5D Mark II and Nikon D7200 — validated all three end-to-end: 0ms drift on both cameras, with the Canon's corona-block write degradation caught, measured, and priced rather than silently absorbed into schedule slip.

Act one: an allocator that didn't know what anything cost

Totality doesn't negotiate. A 2026 eclipse chaser standing at Hontoria de Valdearados, Spain gets 104.9 seconds. Other sites on other dates give 62, 120, or 240 seconds. Whatever the number is, mid-totality — the window between the diamond-ring phenomena at C2 and C3 — has to fit inner corona brackets, outer corona brackets, and earthshine brackets into it, and every one of those brackets takes real, unequal time to shoot.

The old allocator didn't know that. It split mid-totality into three equal-time slices, one per phenomenon, in table order, and packed as many brackets as would fit into each slice. Two things went wrong.

First, equal time slices ignore cost. An outer-corona Espenak Q-walk that runs all the way down to Q=−3 includes a 4-second exposure, which prices the whole bracket at 8.18 seconds. An inner-corona walk over the same site prices at 2.77 seconds. Raise the outer-corona bracket count from 4 to 20 in the wizard at a 180-second totality, and the schedule didn't move — the slice was already saturated at a handful of brackets, and the UI cap was decoration.

Second, equal time slices in table order put earthshine — the phenomenon that wants the darkest sky, i.e. maximum eclipse — dead last. It landed 24 to 68 seconds after maximum eclipse, timed by leftover slice position rather than by astronomy.

The 2× anomaly that made "just trim the walk" the real fix

Before touching placement, we measured what a bracket actually costs, because bracket cost is dominated by its longest frame, not its frame count. At f/8 ISO 100, the Q=−3 frame alone — 4 seconds of shutter — is 52% of the default four-frame outer walk's total time. Two three-frame walks can differ by 2× depending on which end of the Q range they keep:

Outer-corona walk Frames Cost per bracket Bracket ceiling @ 180s
Full Espenak walk (Q=+3…−3) 9 11.52 s 10
[1, 0, −1, −3] 4 8.18 s 14
[0, −1, −3] 3 7.28 s
[1, 0, −1] 3 3.53 s 34
[1, 0] 2 1.88 s 65

Look at rows three and four: [0, −1, −3] and [1, 0, −1] both shoot three frames, yet one costs twice the other, because the −3 frame is a 4-second exposure and the +1 frame isn't. Trimming which Q-steps a walk keeps beats any amount of rearranging where the walk sits in the timeline. That's the fix underneath the fix: before placement mattered at all, cost accounting had to be honest.

The eclipseClick wizard's Inner Corona bracket editor at 1/50, f/5.6, ISO 200, preset 'Espenak inner only', execution method Shutter Cable. Four Q-target chips each carry an amber cost contribution: Q=7 1/200 +1.0s, Q=5 1/50 +1.0s, Q=3 1/13 +1.0s, Q=1 0.4 +1.4s. Below them: '4 frames · 4.2s per bracket · up to 10 fit — the amber figure is what each Q adds; the faintest targets carry almost all of it.' A Bracket coverage panel maps Q=7.0 to 1/200, Q=5.0 to 1/50, Q=3.0 to 1/13, Q=1.0 to 0.4 against 0.1, 0.2, 0.5 and 1.0 solar radii, reports 'Espenak conformance: 4 / 4 frames match Table 23 OK', and estimates ~4.1 s.
Honest cost accounting, surfaced where the walk is edited. Every Q chip carries the amber number of seconds it adds, so the asymmetry is visible before you commit to it: on this inner walk (f/5.6, ISO 200, on Espenak's inner Q scale rather than the ±3 outer range in the table above) the faintest step, Q=1 at 0.4s of shutter, adds +1.4s against +1.0s for each of the three brighter ones. Remove a chip and both the 4.2s per bracket figure and the fit ceiling next to it move immediately.

The new allocator: earthshine pinned, corona split around it

Earthshine now sits centred on maximum eclipse, full stop — it no longer competes for a slice, it's placed first. Neither corona phenomenon is confined to one side of it either: inner corona takes the front of mid-totality, and outer corona takes two intervals — the tail end of the pre-MAX period plus all of the post-MAX period, wrapped around earthshine on both sides.

C2                                                                                          MAX                                                                                           C3
|-------------- inner corona --------------|---- outer corona ----|== earthshine ==|------------------------ outer corona ------------------------|
                                             (pre-MAX tail)                     (centred on MAX)          (all of post-MAX)

Getting there meant changing how brackets get granted, not just how they get sorted. The old pooled design handed out milliseconds and let the placement layer figure out how many brackets fit — which sounds reasonable until the pooled budget for a fragment split 1 + 4 brackets across two intervals but the pool granted 6, leaving a 9.5-second hole nobody scheduled anything into. The new allocator enumerates concrete whole-bracket layouts instead — a few thousand combinations of counts-per-bin — and picks among them with a lexicographic objective: fairness first, then total captures, then ordering preferences, then leftover slack.

Fairness needed its own guard rail. "Maximize granted brackets" sounds like the right first objective until you actually optimize for it — the raw optimum at one test totality was 27 inner-corona brackets and 0 outer-corona brackets, because inner brackets are cheap and the naive objective will spend the whole budget on whichever phenomenon is cheapest per bracket. Fairness is scored as the max-min ratio of granted-to-requested across phenomena, and it has to be optimized before total count, or the allocator will happily starve everything except the cheapest line item.

Totality Old pooled design Placement-aware allocator
T = 120s 71% utilized (21.7s idle) 89% utilized (14 captures)
T = 240s 94% utilized (35 captures)

With whole-bracket enumeration, granted always equals scheduled — there's no fragment left over to lose to a hole, because the allocator never proposes a layout it can't place in full.

Three enabled phenomenon rows in the eclipseClick script wizard, each with a minus/plus stepper and a fit ceiling to its right: Inner Corona set to 6, 'up to 10 fit'; Outer Corona set to 3, 'up to 6 fit'; Earthshine set to 1, 'up to 4 fit'.
The allocator, run live as you set counts. Each row's up to N fit is a real ceiling for that phenomenon's current bracket contents at this site's totality — not a UI cap — recomputed by the same placement-aware allocator that will build the plan. The three ceilings differ because the brackets behind them cost different amounts, which is exactly what the old equal-thirds design couldn't express.

Short totalities exposed a second, smaller bug: fixed padding. A flat 3-second buffer between phenomena plus 2 seconds of earthshine slack is cheap insurance at 240 seconds and a disaster at 62 — at one 62-second site, those fixed buffers ate 36% of a 31-second zone before a single bracket was scheduled. Buffers now scale with the zone: min(3000, max(500, zone × 0.02)) milliseconds, so a short totality gets a short buffer instead of a console-default one.

Act two: pricing the write from measurement, not a guess

A working allocator still needs an honest per-frame cost to allocate against. The planner priced the USB settings write — the call that sets shutter speed, aperture, or ISO on the body before each frame — from a static per-backend table: 70ms measured for Nikon gphoto2, 150ms as an estimate for every vendor SDK path, 250ms estimated for Sony gphoto2. Real bodies vary by multiples of those numbers. The Canon bridge's Tv verify loop alone can run around 600ms — four times the "vendor SDK" estimate the planner was using for it.

The executor's ground truth, for every DSUSB bracket frame, is: settings write, then a 100ms relay hold, then an inter-frame wait of max(400ms, exposure + 400ms) — the exposure only counts once it's a quarter-second or longer. The planner now reproduces that formula exactly. It didn't always: four independent cost models had grown up in different corners of the codebase, and the wizard was under-reporting the planner's own numbers by 22–49%, while a separate seeding path under-estimated a 9-frame walk by 11×. All four are gone; there's one shared cost module now, and everything prices against it.

Measuring the write instead of guessing it

The Equipment page's camera Test button now measures settings-write latency directly: 8 timed writes, cycling the shutter speed through 4 distinct ladder values so every write actually changes Tv — a repeated identical value would let some bridges take a fast path that skips the real device round-trip, understating the number. The measurement runs immediately after the burst test, while the buffer is still flushing, which turned out to matter (more on that below). Median and max are recorded and identity-gated to the exact body and backend that produced them — a Canon's number must never price a Nikon plan, and a Sony CrSDK number must never price the gphoto2 fallback for the same brand. Planning uses the max, not the median: an over-budgeted slot just idles for a moment, but an under-budgeted one drops the trailing frames of a bracket, which is the worse failure by a wide margin.

The bottom of an eclipseClick Equipment page camera card. A Readiness Checklist shows five green checks — lens set to Manual Focus, drive mode Continuous, exposure mode Manual, file format RAW, white balance not Auto. Beneath it a Burst Rate row reads 'Canon EDSDK: 1.2fps' with a Test button, 'USB latency: 353ms', 'Cable: 4fps' with its own Test button, and 'Cable FPS (Manually set): 4fps' with the value 4 in an input.
The measurement, in the place it gets made. The USB latency chip reports the slowest of the timed writes, not their median — 353ms for this Canon body over EDSDK — because that's the number script generation prices with; the median, the write count, and the date it was taken sit in the hover. It sits next to the burst-rate figures deliberately: both Test buttons characterize the same body, and both numbers are gated to it, so nothing here leaks into a plan for a different camera.
Regime Settings write Relay hold Inter-frame wait floor Per-frame budget
Old static table estimate 150 ms 100 ms 400 ms 650 ms
Measured (Test button, standard) 400 ms 100 ms 400 ms 900 ms
Sustained-load (corona-block brackets) 600 ms (1.5×) 100 ms 400 ms 1100 ms

Field validation: two cameras, one honest surprise

We ran a dual-camera dry run — a Canon 5D Mark II and a Nikon D7200, both of which measured around 400ms worst-case on the Test button — through a full scripted plan.

Camera Light-script intervals Corona-block intervals End-to-end drift
Nikon D7200 890–930 ms (900ms budget) 890–930 ms (no degradation) 0 ms
Canon 5D Mark II ~830 ms (~330ms writes) 1695 / 2069 / 2899 ms (1.2–2.4s writes) 0 ms (after 1.5× factor)

The Nikon executed frame-perfect throughout: bracket intervals of 890–930ms against a 900ms budget, zero drift, start to finish. The Canon matched in the light stretches of the script — around 830ms intervals, roughly 330ms writes, comfortably inside what the Test button had measured. Then it hit five brackets back-to-back in the corona block and degraded hard: intervals of 1695, 2069, and 2899ms, with individual writes of 1.2 to 2.4 seconds — three to six times the measured max. The cause wasn't a bad measurement. It was RAW brackets fired back-to-back with no gap for the buffer to drain, which is a load pattern no short calibration run can reproduce, because the load is the script itself.

That's the reasoning behind the sustained-load factor: corona-block brackets — the ones that fire in a dense run — price the measured write at 1.5× (400 → 600ms per frame). Everything else prices at the measured value as-is. It's a deliberately blunt instrument, and the last section of this post says why that's fine for now.

Two smaller wins in the same pass

Consecutive single or burst captures of the same phenomenon re-fire identical settings, and the executor's dedupe logic already knew not to send them over USB twice — but the planner hadn't caught up, and was still budgeting a full write for every capture in the train. Only the first capture of a train actually pays the write; a 7-capture Baily's beads train stops being over-budgeted by roughly 6 × 480ms ≈ 2.9 seconds, which matters inside the genuinely tight C2/C3 windows.

And the wizard's Connection step is gone. It used to ask the user to characterize their own trigger path by hand. That path is derivable from what's actually connected: a shutter cable gets the serial preset (20ms lead), anything else gets the USB default (80ms). The one number in that step that genuinely belongs to the user — inter-capture buffer — moved into a new Advanced Timing section instead of sitting in a step that was mostly asking people to describe their own hardware back to the app.

Cross-backend honesty: the write measurement runs on Canon EDSDK (Windows and macOS), Nikon MAID (Windows), the native macOS Nikon bridge, Nikon Remote SDK v2, and libgphoto2 — including the Sony fallback path — because all of those are synchronous calls: the function doesn't return until the camera has actually applied the setting. It deliberately does not run on Sony CrSDK, whose property-set call is an async submit. Timing that call would measure the USB acknowledgment, not the write, and would price plans tighter than the safe estimate already in use for that path. A fast wrong number is worse than a slow honest one.

Act three: drift and interval are not the same number

Execution logs used to record each bracket frame's raw frame-to-frame interval and label it "timing error." That's wrong for a bracket in a way it isn't wrong for a single capture: a perfectly on-plan four-frame walk, executing exactly as scheduled, reads "+900ms late" on every frame after the first — because the interval between frames inside a bracket is supposed to be 900ms, that's the plan. Two field runs got misdiagnosed as timing bugs before we noticed the log itself was lying.

Each frame now records two numbers, with tooltips explaining the difference: start drift — how far this frame's actual start time is from where the schedule says it should be, the number that's actually schedule-meaningful — and interval — the raw gap since the previous frame, which is expected to vary by design inside a bracket and is diagnostic of something else entirely.

Before
[L41] frame 2/4  timing error: +898ms
[L41] frame 3/4  timing error: +901ms
[L41] frame 4/4  timing error: +896ms
After
[L41] frame 2/4  interval 898ms · +0ms drift
[L41] frame 3/4  interval 931ms · +0ms drift
[L41] frame 4/4  interval 902ms · +0ms drift
Two eclipseClick execution panels side by side from the validated dual-camera dry run: Camera 1 (Canon), 52 of 81 lines completed, average 7ms with a 145ms max; Camera 2 (Nikon), 61 of 90 lines completed, average 18ms with a 388ms max. Per-frame log rows read, for example, 'L39 1/160 f/5.6 ISO 200 · interval 854ms ⧗854ms +0ms', ending in Execution Complete: 61 succeeded, 0 failed, 29 skipped.
The dual-camera dry run, both panels running at once. Every row carries an interval chip — the pacing number, expected to move around inside a bracket — sitting next to a +0ms drift badge, the schedule-meaningful one that isn't supposed to move at all. That's the whole section in one screenshot: the interval tells you how the camera is behaving, the drift tells you whether the plan is holding.

Same run, same underlying timings — only the second version tells you it's fine. The final validated dry run makes the distinction concrete. Nikon line L43 shows intervals of 898 / 931 / 902 / 501ms, drift +0ms on every frame — exactly the 400 (write) + 100 (relay) + 400 (wait) model, with the shorter final gap because there's no inter-frame wait after a bracket's last frame. The Canon's corona-block intervals ran around 830ms, comfortably inside their 600 + 100 + 400 = 1100ms sustained-load budget, and still landed at zero drift.

Pattern in the log What it means
Steady intervals near write + 500ms Camera is keeping pace with the budget. Nothing to look at.
Intervals growing bracket over bracket Write degradation — buffer saturation on the body, the Canon corona-block pattern above.
Drift growing line over line Degradation has outrun the priced budget — the sustained-load factor is too low for this body, or a body hasn't been re-measured after a firmware change.

What's next

Hardware calibration continues — the Test button's identity gating means every new body we validate adds real coverage rather than another guess. The 1.5× sustained-load factor is a single constant, and it's the one we're least attached to: if real eclipses show it too tight or too loose against actual field logs, it moves. That's the whole point of measuring instead of guessing — the number is allowed to be wrong today as long as it's checkable tomorrow.