Frank's architecture sketches: Sandy Fire model-tuning app (rendered, v2) (Sandy Fire Camera Tuner)

**Note** from Bead: Sandy Fire Camera Tuner · [canonical source](https://redfish.acequia.io/guerin/.agents/0671f6fa-eb89-42a1-a854-6a95b97af23f/2026-06-22/notes/02-frank-architecture-sketches.md) · session 2026-06-22 · discussion: Talk: Sandy Fire Camera Tuner

> Replaces the ASCII version per Stephen's 2026-06-22 directive. These sketches are now **rendered, low-fidelity, interactive HTML** — not box-drawing. The point (Buxton): the design lives in the **transition**, the behavior over time. Architecture sketches in particular only come alive when you show the **data moving through the loop**, not the static boxes around it — so the centerpiece animates a packet flowing `params → render → compare → LM → params` while a residual bar drops to convergence, and the toggles visibly re-route the diagram. > > Same architecture **questions** and **forks** as v1 — same plane tags (0 DATA · 1 UI · 2 TIME · 3 ENGINE · 4 BUS). Fork them; don't approve/reject. Tufte refinement comes *after* a plane is chosen. **Rendered sketch:** https://redfish.acequia.io/guerin/.agents/0671f6fa-eb89-42a1-a854-6a95b97af23f/2026-06-22/artifacts/sketches/frank-architecture-sketches.html The questions below are **answerable inside the sketch** — answers post to this bead's dock (`request/`) that Debbie (and the architect) monitor.

## Where this app sits (unchanged framing) This is **not a new pipeline** — it is the **assimilation/calibration step** of the wildfire-world-model spine, the first place the loop closes for a real fire. It fits the forecast model (block **M**) against MEASURE-side observations by minimizing `residual(render(params), observation)`. Two ground truths drive the fit and they are *different kinds of constraint* — that tension is what the sketches resolve: - **TOA reference raster** `ref-2026sandyv1` — world-space, already-encoded (minutes-from-origin per pixel on the DEM, dual worldfiles). Comparing is a cheap, dense grid diff; no camera needed. - **AlertWildfire PTZ frames** — image-space, un-encoded. Per-frame `az/el/fov` but no fixed position or distortion. Comparing means through-the-lens: render from the camera's pose, diff the frame. Both are **soft constraints on one error surface** (*every-constraint-is-soft*); the fit is the current minimum over them. The architecture question underneath all sketches: **which residual we descend, where it lives, and whether we point-estimate (LM) or ensemble (EnKF)**.

## Centerpiece — the tuning loop · *plane 3 ENGINE* - **The question:** the whole app is one cycle — how does `params → render-from-camera-pose → compare → LM update → params` close, and on which path? - **The transition it shows:** "▶ run the loop" sends a token around the cycle; the residual bar drops geometrically each iteration until it converges. The three toggles below re-route the diagram live (see the fork cards). - **Least-action seam:** the loop is **two HTML files already composed** — `fire-in-taos.html` renders fire+smoke from any pose; `through-the-lens-test-v1.html` already implements the LM (`solveLocal`, damping, framebuffer snapshot). Swap the residual term; walk the model params `[slope, base, wind, wdir]`.

## Fork 1 — Residual space: image vs world · *plane 3 ENGINE · spine S3↔S4* - **The question:** do we minimize the residual in the camera image plane, or on the terrain grid? - **Two forks:** *image* (through-the-lens — render from pose, read back framebuffer, diff PTZ frame; needs calibrated pose + readback) vs *world* (grid-diff — model TOA vs `ref-2026sandyv1` on the shared DEM; cheap, dense, no camera). - **The transition it shows:** the `image`/`world` toggle re-routes the compare node — `image` routes the render **through a camera** node; `world` routes the model TOA **straight to the grid**. The residual floor differs (image converges looser, world tighter), visible in the bar. - **Least-action seam:** **world-space first** (no pose dependency, ~30 lines to derive model TOA, loop over `N*N`); **image-space as a second weighted residual term** on top of the same harness. The honest target is a re-weightable sum of both.

## Fork 2 — Per-camera pose: input-contract vs joint-solve · *plane 0 DATA · spine S2 FUSE* - **The question:** is the per-camera pose an INPUT this app reads, or something it SOLVES? - **Two forks:** *B1 contract* (read a calibrated `imagePose` STAC sidecar produced by the camera-calibrator stack `74c30681`; clean separation, but doesn't exist yet for all 10 cameras) vs *B2 joint-solve* (fold pose into the same LM as the fire params — what `through-the-lens-test-v1.html` already does; no upstream dep, but couples two error surfaces). - **The transition it shows:** in the loop, `image` mode surfaces the camera node carrying `pose+intrinsics`; the az/el/fov telemetry feeds it as the soft prior that a calibration reweights. - **Least-action seam:** **B1 as the contract, B2 as the fallback.** Define the calibrated `imagePose` STAC item now (a world-model contract); calibrated cameras feed image-space directly, the rest fall back to the joint solve. Home = the pose stack (`74c30681`/`edb06426`), not this app.

## Fork 3 — Where the loop runs: in-browser vs headless · *plane 3 ENGINE · spine S4 FORECAST* - **The question:** is the fit an interactive in-browser loop, or a headless batch solve? - **Two forks:** *in-browser* (JS LM in the page; model step + render on the same WebGPU path the viewer runs; interactive, citizen-runnable, but readback-per-iter is slow) vs *headless* (node + CDP batch render, score offline; scales to ensembles, scriptable, but loses the "watch it converge" UX). - **The transition it shows:** the `headless` toggle slides the whole loop box into a dashed off-page frame (node+CDP) — the same diagram, relocated. - **Least-action seam:** **in-browser for the interactive fit + demo** (reuses both precedents verbatim on one WebGPU path); the **CDP harness is the verify/ensemble lane**, not the product.

## Fork 4 — Output on the bus · *plane 4 BUS · spine S4→S5* - **The question:** is the tuned result a shared-state STAC product on the bus, or a private app artifact? - **Two forks:** *D1 STAC forecast* (PUT a `scenario.json` + regenerated model TOA in the **same RGB-MSB + dual-worldfile shape as `ref-2026sandyv1`**; composes by sharing URIs, re-enters as a prior) vs *D2 app-local* (localStorage/download; trivial but dead-ends). - **The transition it shows:** the namespace-bus sketch animates GET priors (TOA, frames, imagePose) flowing in, the fit running, and a **PUT packet** flowing the fitted product back out to the commons as a sibling of the reference. - **Least-action seam:** **D1 — reuse the `ref-2026sandyv1` contract verbatim for the output.** The fitted forecast is the same file shape with better numbers; it drops onto the bus and the viewer renders it with zero new code. The forager pattern run efferently: GET priors, PUT product.

## Fork 5 — Point-estimate (LM) vs ensemble (EnKF) · *plane 0/3 · spine S4 assimilation* - **The question:** report the single best-fit fire, or an ensemble with uncertainty? - **Two forks:** *E1 LM* (one param vector minimizing the residual; interactive, demoable today, no uncertainty) vs *E2 EnKF* (N vectors → distribution; each observation nudges the ensemble → burn-probability + lead-time uncertainty; N× compute, pushes toward headless). - **The transition it shows:** the `EnKF` toggle turns the single yellow packet at the params node into a drifting **cloud of N**, annotated "distribution (burn-prob + uncertainty)." - **Least-action seam:** **E1 now, architected so E2 is a swap.** Both are the same observe→update descent on the soft-constraint surface — LM is the EnKF mean with covariance dropped. Build the residual + param-vector contract so "one vector" generalizes to "an ensemble"; parameterize the count, don't fork the code.

## How the planes compose for this app + what to build first - **Plane 3 ENGINE is the spine:** `fire-in-taos.html` ⊕ `through-the-lens-test-v1.html` — the fit *is* render→compare→optimize on the same WebGPU path. (Forks 1/3) - **Plane 0 DATA** carries three input contracts (TOA raster — exists; calibrated `imagePose` — define now; per-frame telemetry — exists) and one output contract (fitted **scenario + TOA forecast**, reusing `ref-2026sandyv1`'s exact shape). (Forks 2/4/5) - **Plane 4 BUS** is the forager pattern run efferently: GET priors, PUT the fitted product; it re-enters assimilation as a new prior. (Fork 4) - **Planes 1/2 UI+TIME** compose by *reuse*: mount the same `acq` app-shell + master time-cursor as incident-viewer/camera-grid. "Observed-vs-fitted" is one more properties pane, not a new viewer. ### Build-first recommendation (value-first, each step demoable) 1. **World-space residual loop (Fork 1 world + Fork 4 D1).** Derive a model-TOA raster from first-arrival ticks, diff against `ref-2026sandyv1`, run the existing LM over `[slope, base, wind, wdir]`, emit `scenario.json` + fitted RGB-MSB TOA. *Demo: watch the sliders converge so model-TOA matches observed-TOA.* Zero new infra, no camera calibration. 2. **Through-the-lens residual (Fork 1 image + Fork 2 B1) for one calibrated camera.** Define `imagePose`, calibrate a co-viewing camera (Saddle Peak / Topanga), render fire+smoke from its pose, add `|render − frame|` as a second weighted term. *Demo: now it also matches what the camera saw.* 3. **Generalize to an ensemble (Fork 5 E2) on the headless lane (Fork 3).** Single param vector → ensemble → burn-probability + uncertainty. *Demo: a self-correcting forecast — the world model, live.* The seam, in one line: **this app already exists as two HTML files and four data contracts — the work is composing them into one render→compare→optimize loop on the bus, world-space residual first, through-the-lens second, ensemble last.**