From d4fc59650e1396c19ea9ece87c7bd98ecf323b16 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 6 Sep 2026 02:29:21 +0530 Subject: [PATCH] feat(operate): calibration gets its own pane (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calibrate was a button in the SPIM head's status bar, and its result appeared in a different tab entirely. Ryan, 25:30 on the 2026-08-07 walkthrough: "after setting up the bottom camera and SPIM head, I feel like I'm not quite sure what to do in gently at this point." There was no next step on screen, because the next step was a button in the corner of the step before. His own description of the order is the pane order, so that is the order: Bottom cam → SPIM head → Calibration → Acquisition The pane declares its method before running it — Kesavan on the same call: "it should show what is the method it is going to use to calibrate." There is one method on this rig, so it is named rather than offered as a choice, and the text says the part that matters: it needs the laser, because the LED alone gives a DIC-like image with no nuclei to find. That is the confusion #106 turns on, written where the button is. It carries the Calibrate button, the selected embryo, the fit readout, and the focus-sweep montage — which previously appeared under the SPIM head while the button that produced it was also there but the result was elsewhere. Control, evidence and result in one place. Leaving the pane closes the LED. `calibrateSelected` never did, which is half of #106; the pane's `onLeave` does it the same way the SPIM pane does. ## The rail says which embryos still need it `RosterPanel` gains `showFit`, reading the same field the server-side gate checks — a finite non-zero `slope_um_per_deg`. The shared rail sits beside every pane, so `CAL` / `UNCAL` is visible wherever you are. An operator now sees what a run will refuse before reaching Start, rather than discovering it there. Muted rather than red: an uncalibrated embryo is one you have not got to yet, not an error. ## Also `PANE_ORDER` is one list. It used to be spelled out in `showPane` and again in `showPaneInitial`, so adding a pane meant remembering both — and three stray double `publishRoster()` calls left by the roster rename are collapsed. JUDGEMENT CALL: Kesavan raised on the walkthrough whether one embryo's calibration should apply to all of them, or each should carry its own. That is a real question about the experiment, not the interface, so the pane is built per-embryo — matching what `calibrate_embryo` already does — and the question is left open rather than answered by a layout. Verified live: subtab order, the pane showing while the others hide, Calibrate and the montage host moved, the SPIM bar no longer carrying either, the fit readout reading "96.4 µm/deg · R² 0.91" for a calibrated embryo and "not calibrated" for one without, and the rail badges tracking both. Audit finding: #108, and the dead end at 25:30. Co-Authored-By: Claude Opus 5 (1M context) --- gently/ui/web/static/css/operate.css | 30 ++++++++ gently/ui/web/static/js/operate.js | 48 ++++++++++-- gently/ui/web/static/js/panels/roster.js | 25 +++++- gently/ui/web/templates/index.html | 60 ++++++++++++--- tests/test_calibration_has_its_own_pane.py | 90 ++++++++++++++++++++++ 5 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 tests/test_calibration_has_its_own_pane.py diff --git a/gently/ui/web/static/css/operate.css b/gently/ui/web/static/css/operate.css index 6c6db6fd..80c5890f 100644 --- a/gently/ui/web/static/css/operate.css +++ b/gently/ui/web/static/css/operate.css @@ -1044,3 +1044,33 @@ color: var(--op-ink); cursor: pointer; } + +/* Calibration state in the shared rail — visible from every pane, so an + operator sees what a run will refuse before reaching Start rather than + after. The field is the one the server-side gate checks + (gently/harness/calibration_gate.py). */ +.rp-fit { + margin-left: 6px; + padding: 0 4px; + border-radius: 3px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-size: 0.58rem; + letter-spacing: 0.04em; + text-transform: uppercase; + vertical-align: middle; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); +} + +/* Not an error — an embryo you have not got to yet. Muted, not red. */ +.rp-fit-none { + color: var(--op-ink-dim); + background: color-mix(in srgb, var(--op-ink) 8%, transparent); +} + +/* The method the Calibrate button will run, stated before it runs. */ +#op-cal-method { + margin: 0 0 var(--op-2); + max-width: 52ch; + line-height: 1.45; +} diff --git a/gently/ui/web/static/js/operate.js b/gently/ui/web/static/js/operate.js index 45c70c9d..d46d3671 100644 --- a/gently/ui/web/static/js/operate.js +++ b/gently/ui/web/static/js/operate.js @@ -965,6 +965,26 @@ const OperateManager = (function () { } finally { clearInterval(tick); if (b) { b.disabled = false; b.textContent = 'Calibrate'; } + renderCalTarget(); + } + } + + // The calibration pane names its subject and reports the fit it has, if any + // — the same field the server-side gate checks, so the pane shows what a run + // would refuse rather than leaving it to be discovered at Start. + function renderCalTarget() { + const t = $('op-cal-target'); + const emb = _embryos.find(e => e.id === _selected); + if (t) t.textContent = emb ? `embryo ${labelFor(emb)}` : 'no embryo selected'; + const out = $('op-cal-result'); + if (!out || !emb) return; + const slope = Number(((emb.calibration || {}).slope_um_per_deg)); + if (Number.isFinite(slope) && slope !== 0) { + const r2 = (emb.calibration || {}).r_squared; + out.textContent = `${slope.toFixed(1)} µm/deg` + + (r2 != null ? ` · R² ${Number(r2).toFixed(2)}` : ''); + } else { + out.textContent = 'not calibrated'; } } @@ -991,7 +1011,7 @@ const OperateManager = (function () { // once, as before. The set() is what makes the other copies of this // cursor follow. SharedState.set('selectedEmbryoId', id); - publishRoster(); renderSpimTarget(); renderSingle(); publishRoster(); + publishRoster(); renderSpimTarget(); renderCalTarget(); renderSingle(); } // Shared embryo list, left of every instrument surface. Reads the canonical @@ -1030,7 +1050,7 @@ const OperateManager = (function () { _selected = _embryos.length ? _embryos[0].id : null; SharedState.set('selectedEmbryoId', _selected); } - publishRoster(); publishRoster(); renderSpimTarget(); renderSingle(); drawMarkers(); + publishRoster(); renderSpimTarget(); renderSingle(); drawMarkers(); } catch (e) { toastFail(`Delete failed (${why(e)})`); } @@ -1268,6 +1288,11 @@ const OperateManager = (function () { // contend for MMCore, and the client swaps .src per frame with no throttle, // so two live decoders is the condition that risks a Video-TDR freeze. "The // camera is live while you are looking at it" guarantees at most one. + // Workflow order, and the single list. It used to be spelled out in + // showPane and again in showPaneInitial, so adding a pane meant remembering + // both. + const PANE_ORDER = ['bottom', 'spim', 'cal', 'acquire']; + const PANES = { bottom: { onEnter() { if (_bottomWasOn && !_bottomOn) toggleBottomCam(); drawMarkers(); }, @@ -1279,6 +1304,14 @@ const OperateManager = (function () { onLeave() { _spimWasOn = _spimOn; if (_spimOn) stopSpim(); forceLedOff(); }, render() { renderSpimTarget(); fd.render(); }, }, + cal: { + // The light-sheet view is what calibration reads, so entering here + // brings it back the same way the SPIM pane does, and leaving closes + // the LED — the calibrate path never did (#106). + onEnter() { if (_spimWasOn && !_spimOn) toggleSpim(); renderCalTarget(); }, + onLeave() { _spimWasOn = _spimOn; if (_spimOn) stopSpim(); forceLedOff(); }, + render() { renderCalTarget(); }, + }, acquire: { onEnter() { renderRun(); }, onLeave() {}, @@ -1327,7 +1360,10 @@ const OperateManager = (function () { // is the pre-run review surface and gets everything. Previously the // difference was an accident of where each button was added. if ($('op-erail-list')) { - RosterPanel.mount('op-erail-list', { actions: ['remove'] }); + // showFit: the rail is beside every pane, so calibration state is + // visible wherever you are — including before you reach the run + // and discover the gate refusing it. + RosterPanel.mount('op-erail-list', { actions: ['remove'], showFit: true }); } if ($('op-roster')) { RosterPanel.mount('op-roster', @@ -1349,7 +1385,7 @@ const OperateManager = (function () { const prev = _pane; _pane = name; if (PANES[prev]) PANES[prev].onLeave(); - ['bottom', 'spim', 'acquire'].forEach(p => { + PANE_ORDER.forEach(p => { const el = $(`op-pane-${p}`); if (el) el.hidden = p !== name; }); @@ -1471,7 +1507,7 @@ const OperateManager = (function () { SharedState.on('selectedEmbryoId', id => { if (id === _selected) return; // our own publish, already applied _selected = id; - publishRoster(); renderSpimTarget(); renderSingle(); publishRoster(); + publishRoster(); renderSpimTarget(); renderCalTarget(); renderSingle(); }); if (typeof ClientEventBus !== 'undefined') { @@ -1518,7 +1554,7 @@ const OperateManager = (function () { renderSubnavMeta(); } function showPaneInitial() { - ['bottom', 'spim', 'acquire'].forEach(p => { + PANE_ORDER.forEach(p => { const el = $(`op-pane-${p}`); if (el) el.hidden = p !== _pane; }); diff --git a/gently/ui/web/static/js/panels/roster.js b/gently/ui/web/static/js/panels/roster.js index 70060294..9e140b8a 100644 --- a/gently/ui/web/static/js/panels/roster.js +++ b/gently/ui/web/static/js/panels/roster.js @@ -55,6 +55,7 @@ const RosterPanel = (() => { mounts.set(hostId, { actions: (opts && opts.actions) || [], emptyAction: opts && opts.emptyAction, + showFit: !!(opts && opts.showFit), }); if (mounts.size === 1) { SharedState.on('embryos', render); @@ -82,6 +83,20 @@ const RosterPanel = (() => { return null; } + /** + * Does this embryo carry a galvo/piezo fit? + * + * The same field the server-side gate checks — a finite non-zero + * `slope_um_per_deg` (see gently/harness/calibration_gate.py). Showing it + * in the rail means an operator can see what a run is about to refuse, + * from any pane, instead of discovering it at Start. + */ + function fitOf(emb) { + const cal = (emb && emb.calibration) || {}; + const slope = Number(cal.slope_um_per_deg); + return Number.isFinite(slope) && slope !== 0 ? slope : null; + } + function labelOf(emb) { const m = emb && emb.id && String(emb.id).match(/(\d+)/); return m ? m[1] : '?'; @@ -134,13 +149,21 @@ const RosterPanel = (() => { return `
- Embryo ${esc(labelOf(emb))} + Embryo ${esc(labelOf(emb))}${fitBadge(emb, opts)} ${xy ? `${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}` : '—'} ${buttons}
`; } + function fitBadge(emb, opts) { + if (!opts.showFit) return ''; + const slope = fitOf(emb); + return slope == null + ? 'uncal' + : `cal`; + } + function wire(host) { host.onclick = e => { const v = verbs(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index d2f5e127..cb102363 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -536,6 +536,11 @@

Device + + @@ -636,23 +641,11 @@

Device
No embryo selected - - Piezo–galvo - - -
-