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.
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 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.
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.
| 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.
[L41] frame 2/4 timing error: +898ms [L41] frame 3/4 timing error: +901ms [L41] frame 4/4 timing error: +896ms
[L41] frame 2/4 interval 898ms · +0ms drift [L41] frame 3/4 interval 931ms · +0ms drift [L41] frame 4/4 interval 902ms · +0ms drift
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.