From ad023843e9f783521363f70d8edfa19c1bd8990c Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 11 Jul 2026 16:46:50 +0200 Subject: [PATCH] docs: add e-ink refresh-latency curve and boot-time budget notes --- docs/notes/README.md | 1 + docs/notes/boot-time-budget.md | 73 ++++++++++++ docs/tradeoff-curves/README.md | 1 + docs/tradeoff-curves/epd-refresh-latency.md | 117 ++++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 docs/notes/boot-time-budget.md create mode 100644 docs/tradeoff-curves/epd-refresh-latency.md diff --git a/docs/notes/README.md b/docs/notes/README.md index c1644de..314af8c 100644 --- a/docs/notes/README.md +++ b/docs/notes/README.md @@ -10,3 +10,4 @@ | --- | --- | | [`ctrl-g-perceived-latency.md`](ctrl-g-perceived-latency.md) | Durability before delivery — surfacing "commit landed" at ~0.2 s makes the 5–10 s `Ctrl-G` push feel instant. | | [`git-sync-images-and-repo-size.md`](git-sync-images-and-repo-size.md) | Why we don't shrink the notes repo — its 153 MB of media is remanso's image CDN, so rewriting history to slim the on-device clone breaks the web app. | +| [`boot-time-budget.md`](boot-time-budget.md) | Where the ~4.3 s to cursor goes — and why the ≤ 3 s v1.0 target is hard: one ~1.9 s full refresh is unavoidable at cold boot, so the splash is nearly free. | diff --git a/docs/notes/boot-time-budget.md b/docs/notes/boot-time-budget.md new file mode 100644 index 0000000..cc1582d --- /dev/null +++ b/docs/notes/boot-time-budget.md @@ -0,0 +1,73 @@ +# Boot-time budget — where the ~4.3 s to cursor goes + +> **Measured 2026-07-11:** cold boot is **4258 ms** power-on → cursor (the +> `boot: cursor ready` log prefix). That clears the **≤ 5 s v0.1 gate** with +> ~742 ms to spare. This note breaks the number down, and argues that the +> **≤ 3 s v1.0 target** is hard on this panel because one ~1.9 s full refresh is +> architecturally unavoidable at cold boot. +> +> Notes index: [`README.md`](README.md). Docs index: +> [`../README.md`](../README.md). Backs the boot-time acceptance criterion in +> [`../v0.1-mvp-product.md`](../v0.1-mvp-product.md#acceptance-criteria) and the +> v1.0 goal in [`../roadmap.md`](../roadmap.md). Refresh cost model: +> [`../tradeoff-curves/epd-refresh-latency.md`](../tradeoff-curves/epd-refresh-latency.md). + +## The waterfall + +From the boot serial log (power-on → first editor frame + input loop live): + +| Phase | ~ms | Lever | +| --- | ---: | --- | +| ROM + 2nd-stage bootloader + app image load | ~550 | flash speed (DIO now; QIO / 80 MHz ≈ −200 ms, speculative) | +| PSRAM init + **memtest** + heap | ~920 | `CONFIG_SPIRAM_MEMTEST=n` → **−730 ms** (kept **on**: a real HW sanity check on a hand-wired board) | +| EPD reset + init | ~130 | fixed panel bring-up | +| **Splash full refresh** | ~1850 | e-ink floor — see below | +| SD mount + note load | ~70 | quick on the genuine 32 GB SDHC | +| USB host install + git thread spawn | ~60 | background | +| **First editor render** (full-area partial) | ~680 | already fixed from ~1870 ms (was a *second* full refresh) | +| **Total** | **~4260** | | + +Two lines carry the weight: the **splash full refresh (~1.85 s)** and the +**first editor render (~0.68 s)**. Everything else is ≤ ~0.9 s combined, and the +biggest of *those* — the ~0.73 s PSRAM memtest — is a deliberate keep. + +## The insight: one full refresh is unavoidable, so the splash is nearly free + +After power-on the panel controller's `0x26` "previous" RAM bank holds garbage. A +partial refresh *diffs the new image against that bank* +([`../tradeoff-curves/epd-refresh-latency.md`](../tradeoff-curves/epd-refresh-latency.md)), +so the **first clean paint must be a full refresh** (~1.9 s) to establish a known +image. There is no way around this on this panel short of a different waveform. + +That reframes two things: + +- **The splash costs almost nothing.** Boot needs one full refresh regardless; the + splash simply *is* that refresh, turned into a "boot is happening" affordance. + Dropping the splash would **not** save the 1.9 s — the editor's first frame would + then have to be the full refresh instead. (This is exactly what the old boot did + and why it paid *two* full refreshes.) +- **The v0.1 win was removing the second full refresh, not the first.** Once the + splash has seeded a clean baseline, the editor rides in on a full-area *partial* + (~0.63 s) instead of a second full refresh (~1.9 s) — the ~1.25 s saving that + took cold boot from ~5.5 s to ~4.26 s. Verified clean on-panel (no splash + ghost behind the editor text). + +## Is ≤ 3 s (v1.0) reachable? + +To go from ~4.26 s to ≤ 3 s needs ~1.26 s cut. The honest lever list: + +- **PSRAM memtest off:** −0.73 s → ~3.5 s. Costs the boot-time hardware check; + reasonable once the board is no longer hand-wired. +- **Faster flash boot (QIO / 80 MHz):** ~−0.2 s, speculative, needs a bench check. +- **Overlap cheap init under the splash busy-wait:** SD mount + note load + USB + install (~0.13 s total) currently run *after* the splash refresh returns, but + the refresh is a `wait_while_busy` spin — those could be kicked off before it. + Saves ~0.1 s at most. + +Even stacked, that lands around **~3.2 s** — still over. The ~1.9 s full-refresh +floor is the wall, and it can't be cut without dropping the clean first image or +moving to a faster panel/waveform. **Conclusion:** ≤ 3 s is marginal-to-unreachable +on the GDEY0579T93 as driven today. When v1.0 comes, either revisit the target +(≤ 3.5 s is achievable with the memtest off), or accept the splash as the +deliberate cover for the one refresh e-ink makes us pay. Recorded here so the v1.0 +boot-time item is scoped against physics, not optimism. diff --git a/docs/tradeoff-curves/README.md b/docs/tradeoff-curves/README.md index 82f3ddb..d25d382 100644 --- a/docs/tradeoff-curves/README.md +++ b/docs/tradeoff-curves/README.md @@ -10,3 +10,4 @@ | Curve | What it decides | | --- | --- | | [`wifi-auto-sync.md`](wifi-auto-sync.md) | `auto_sync` interval vs Wi-Fi energy (a `1/T` hyperbola) — why the default is 10 min and opportunistic, not a wall-clock timer. | +| [`epd-refresh-latency.md`](epd-refresh-latency.md) | E-ink refresh latency vs rows driven — the full / full-area-partial / windowed-Y cost model behind typing responsiveness and the boot splash→editor swap. | diff --git a/docs/tradeoff-curves/epd-refresh-latency.md b/docs/tradeoff-curves/epd-refresh-latency.md new file mode 100644 index 0000000..9f95338 --- /dev/null +++ b/docs/tradeoff-curves/epd-refresh-latency.md @@ -0,0 +1,117 @@ +# E-ink refresh latency vs rows driven + +> **Model:** on this GDEY0579T93 (SSD1683 dual-controller) panel, refresh time is +> set by **how many gate lines (rows, the Y axis) are driven** and **which +> waveform LUT runs** — *not* by the pixel column width. Three refresh modes fall +> out of that: full refresh (~1870 ms), full-area partial (~630 ms), and +> windowed-Y partial (~100–130 ms for one text line). This is the cost model +> behind per-keystroke typing, the boot splash→editor swap +> ([`../notes/boot-time-budget.md`](../notes/boot-time-budget.md)), and the +> scroll/gutter spikes. +> +> Tradeoff-curves index: [`README.md`](README.md). Docs index: +> [`../README.md`](../README.md). Driver: +> [`../../firmware/src/epd.rs`](../../firmware/src/epd.rs). Bench origin: +> [`../spikes.md`](../spikes.md) (Spikes 5 + 8). + +## The model + +A refresh is three serial costs: + +``` +set RAM window → clock the pixels out over SPI → run the update waveform + (fixed) (scales with bytes = rows) (scales with gate lines + AND with which LUT) +``` + +Two things do **not** help, and one thing does: + +- **Column width (X) is free to keep full.** The panel is a master (`0x00`) + + slave (`0x80`) pair with the framebuffer split at the seam; every refresh + drives *both* controllers full width so the seam/mirror math stays intact + (`update_part` / `write_frame_bank` in + [`epd.rs`](../../firmware/src/epd.rs)). Narrowing the *column* saves nothing — + "the waveform time dominates, not the data clock-out." +- **Row count (Y) is the knob.** E-paper drive time scales with the number of + gate lines transitioned, so restricting the refresh to a horizontal band of + rows is the real win — a one-line band is far cheaper than the whole panel. +- **The LUT sets the tier.** A *partial* update (`0x22`←`0xFF`) runs a short + waveform that only nudges pixels that changed. A *full* update (`0x22`←`0xD7`, + the fast-full LUT) runs ~3× as many frames per pixel to fully clear-and-set — + slower, but it erases ghosting and re-establishes a known image. + +Within partial mode the latency is roughly linear in rows: + +``` +t_partial(rows) ≈ 90 ms + 2 ms · rows + └ floor ┘ └ per-gate-line slope ┘ +``` + +Floor ≈ fixed SPI setup + border/VCOM commands + waveform ramp; slope ≈ the +per-row waveform drive. Full refresh sits in its own flat tier (~1870 ms, all 272 +rows, full-clear LUT) and **cannot be windowed** — the clear waveform needs the +whole panel. + +## The curve + +``` + Partial-refresh latency vs rows driven (Y-band) t ≈ 90 ms + 2 ms · rows + + ms + 1870 |========================================= FULL refresh — separate tier: + | all 272 rows, full-clear LUT, + | un-windowable. ~3× the partial + | waveform. De-ghosts + seeds a + | known image. + | + 630 | * ← full-area partial (272 rows) + | . · + | . · + | . · slope ≈ 2 ms / gate line + 300 | . · + | . · + 110 | * ← one text line (~10 rows): the per-keystroke path + 90 | * ← floor (SPI setup + border/VCOM + waveform ramp) + +----+----+----+----+----+----+----+----+----+----+----+--- + 0 25 50 75 100 125 150 175 200 225 250 272 rows +``` + +| Mode | Rows driven | Latency | LUT | Used for | +| --- | ---: | ---: | --- | --- | +| Full refresh | 272 (all) | **~1870 ms** (measured) | full-clear | first cold-boot image; periodic de-ghost (every `FULL_REFRESH_EVERY` = 64 updates) | +| Full-area partial | 272 | **~630 ms** (measured; 680 ms at boot) | partial | deletes, caret moves, mode switches, the snackbar, and the boot splash→editor swap | +| Windowed-Y partial | ~10 (1 line) | **~100–130 ms** (estimated¹) | partial | additive per-keystroke typing | + +¹ The single-line windowed figure is projected from the floor+slope model and the +Spike 5 full-area measurement; the exact bench number is still to be confirmed +from the on-device refresh log (`{mode} refresh #N … {ms} ms` in +[`main.rs`](../../firmware/src/main.rs)). The 1870 ms full and 630 ms full-area +figures are measured. + +## Two things that bound it + +**Ghosting caps the partial streak.** Partial updates leave faint residue, so a +full refresh every 64 updates resets clarity and panel state. You can't "always +partial" — the ~1870 ms tier is a periodic tax paid for longevity, not a mode you +can retire. + +**The first cold-boot image must be a full refresh.** After power-on the `0x26` +"previous" bank holds garbage, and a partial refresh *diffs against it* — so the +very first clean paint has to be the full tier. This is why boot pays exactly one +unavoidable ~1.9 s full refresh, and why the splash (which rides it) is nearly +free while the *editor's* first frame can be a cheap partial on top. Full +derivation: [`../notes/boot-time-budget.md`](../notes/boot-time-budget.md). + +## What it decides + +- **Per-keystroke typing → windowed-Y partial.** Only the touched line's band is + driven (~100–130 ms), so typing keeps up with the keyboard; the panel never + repaints per keystroke off that line. +- **Boot splash→editor → full-area partial (~630 ms), not a second full refresh + (~1870 ms).** The splash already seeded the baseline, so the editor rides in on + a partial — the ~1.25 s cold-boot win recorded in the boot-time budget. +- **Deletes, caret moves, mode flips, snackbar → full-area partial.** These erase + ink or change the panel off the cursor line, which a windowed band would ghost, + so they take the whole-panel partial rather than a windowed one. +- **Splash + periodic → full refresh.** The unavoidable first image and the + every-64 de-ghost.