Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions gently/ui/web/static/css/operate.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
48 changes: 42 additions & 6 deletions gently/ui/web/static/js/operate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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)})`);
}
Expand Down Expand Up @@ -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(); },
Expand All @@ -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() {},
Expand Down Expand Up @@ -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',
Expand All @@ -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;
});
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
});
Expand Down
25 changes: 24 additions & 1 deletion gently/ui/web/static/js/panels/roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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] : '?';
Expand Down Expand Up @@ -134,13 +149,21 @@ const RosterPanel = (() => {
return `<div class="rp-row${emb.id === selected ? ' is-sel' : ''}" tabindex="0"
data-embryo="${esc(emb.id)}">
<span class="rp-main">
<span class="rp-label">Embryo ${esc(labelOf(emb))}</span>
<span class="rp-label">Embryo ${esc(labelOf(emb))}${fitBadge(emb, opts)}</span>
<span class="rp-xy">${xy ? `${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}` : '—'}</span>
</span>
<span class="rp-acts">${buttons}</span>
</div>`;
}

function fitBadge(emb, opts) {
if (!opts.showFit) return '';
const slope = fitOf(emb);
return slope == null
? '<span class="rp-fit rp-fit-none" title="Not calibrated — a run will refuse this embryo">uncal</span>'
: `<span class="rp-fit" title="Calibrated: ${slope.toFixed(1)} µm/deg">cal</span>`;
}

function wire(host) {
host.onclick = e => {
const v = verbs();
Expand Down
60 changes: 48 additions & 12 deletions gently/ui/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,11 @@ <h2 class="devices-title"><span class="devices-title-script">Device</span> <em c
<div class="view-switcher op-subviews" id="operate-subtab-switcher">
<button class="view-btn active" data-view="bottom" title="Bottom camera">Bottom cam</button>
<button class="view-btn" data-view="spim" title="SPIM head">SPIM head</button>
<!-- #108: the step between finding the embryo and setting up the
run. Ryan on 2026-08-07: "you find the embryos on the bottom
camera, you find them with the SPIM head, you calibrate it,
and then you set up your acquisition parameters." -->
<button class="view-btn" data-view="cal" title="Calibration">Calibration</button>
<button class="view-btn" data-view="acquire" title="Acquisition setup">Acquisition</button>
</div>
<span class="op-subnav-meta" id="op-subnav-meta"></span>
Expand Down Expand Up @@ -636,23 +641,11 @@ <h2 class="devices-title"><span class="devices-title-script">Device</span> <em c
</div>
<div class="op-bar">
<span class="op-cap" id="op-spim-target">No embryo selected</span>
<span class="op-bar-r">
<span class="op-label">Piezo–galvo</span>
<span class="op-num" id="op-cal-result">—</span>
<button class="op-btn" id="op-calibrate" type="button">Calibrate</button>
</span>
</div>
<!-- Seats itself while a routine is producing images and retires
when they stop. No open or close control on purpose: an
operator watching the scope should not have to ask for it. -->
<div class="op-block op-block-display" id="op-display-spim"></div>
<figure class="op-preview" id="op-preview" hidden>
<img class="op-preview-img" id="op-preview-img" alt="Focus sweep preview">
<figcaption class="op-preview-cap">
<span class="op-preview-title" id="op-preview-title"></span>
<span class="op-preview-meta" id="op-preview-meta"></span>
</figcaption>
</figure>
</div>

<aside class="op-inst">
Expand Down Expand Up @@ -714,6 +707,49 @@ <h2 class="devices-title"><span class="devices-title-script">Device</span> <em c

<!-- ══ ACQUISITION ══ roster + what to run. Reads the canonical embryo
list; nothing here is sequenced after anything else. -->
<!-- #108. Between SPIM head and Acquisition, because that is where
it sits in the work: find the embryo, then calibrate it, then set
up the run. Ryan at 25:30 on the 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." This is what came next. -->
<section class="op-pane" id="op-pane-cal" data-pane="cal" hidden>
<div class="op-lock" id="op-lock-cal" hidden>
<span class="op-lock-txt">Sample at objective · XY locked ·
<b class="op-num" id="op-lock-cal-d">—</b> <i>µm to floor</i></span>
<button class="op-btn op-btn-warn" data-backoff="1" type="button">Back off 100 µm</button>
</div>
<div class="op-main">
<div class="op-block">
<div class="op-block-head">Calibrate</div>
<!-- Declares the method before it runs. Kesavan on the
walkthrough: "when there is a calibrate option, 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. -->
<p class="op-cap" id="op-cal-method">
Lineaging-embryo method — a galvo sweep read by the
light-sheet camera, then a piezo focus sweep. Needs the
laser: the LED alone gives a DIC-like image with no nuclei
to find.
</p>
<div class="op-row"><span class="op-label">Selected</span>
<span class="op-num" id="op-cal-target">no embryo selected</span></div>
<div class="op-row"><span class="op-label">Piezo–galvo</span>
<span class="op-num" id="op-cal-result">—</span></div>
<div class="op-actions">
<button class="op-btn op-btn-primary" id="op-calibrate" type="button">Calibrate</button>
</div>
</div>
<figure class="op-preview" id="op-preview" hidden>
<img class="op-preview-img" id="op-preview-img" alt="Focus sweep preview">
<figcaption class="op-preview-cap">
<span class="op-preview-title" id="op-preview-title"></span>
<span class="op-preview-meta" id="op-preview-meta"></span>
</figcaption>
</figure>
</div>
</section>

<section class="op-pane op-pane-acquire" id="op-pane-acquire" data-pane="acquire" hidden>
<div class="op-main op-main-cols">
<div class="op-col">
Expand Down
90 changes: 90 additions & 0 deletions tests/test_calibration_has_its_own_pane.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Calibration is a step in the workflow, so it is a pane in the workflow.

#108. Calibrate used to be 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: "you find the embryos on
the bottom camera, you find them with the SPIM head, you calibrate it, and then
you set up your acquisition parameters."
"""

from __future__ import annotations

import re
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
OPERATE = ROOT / "gently" / "ui" / "web" / "static" / "js" / "operate.js"
INDEX = ROOT / "gently" / "ui" / "web" / "templates" / "index.html"


def test_the_pane_order_is_the_workflow_order() -> None:
src = OPERATE.read_text(encoding="utf-8")
m = re.search(r"const PANE_ORDER = \[([^\]]+)\]", src)
assert m, "PANE_ORDER is gone — the pane list is spelled out in two places again"
order = [p.strip().strip("'\"") for p in m.group(1).split(",")]
assert order == ["bottom", "spim", "cal", "acquire"], order


def test_one_pane_list_not_two() -> None:
"""It used to be written out in showPane and again in showPaneInitial."""
src = OPERATE.read_text(encoding="utf-8")
assert "['bottom', 'spim', 'acquire']" not in src
assert src.count("PANE_ORDER.forEach") >= 2, (
"a caller stopped using PANE_ORDER — adding a pane now means remembering two places again"
)


def test_the_subtab_sits_between_spim_and_acquisition() -> None:
html = INDEX.read_text(encoding="utf-8")
views = re.findall(r'#?operate|data-view="(bottom|spim|cal|acquire)"', html)
seen = [v for v in views if v]
# first occurrence of each, which is the switcher
order: list[str] = []
for v in seen:
if v not in order:
order.append(v)
assert order[:4] == ["bottom", "spim", "cal", "acquire"], order


def test_calibrate_left_the_spim_bar() -> None:
"""Its result appeared in another tab; the control belonged with the result."""
html = INDEX.read_text(encoding="utf-8")
spim = html[html.index('id="op-pane-spim"') : html.index('id="op-pane-cal"')]
assert 'id="op-calibrate"' not in spim, "Calibrate is back in the SPIM head bar"
cal = html[html.index('id="op-pane-cal"') : html.index('id="op-pane-acquire"')]
assert 'id="op-calibrate"' in cal
assert 'id="op-cal-result"' in cal, "the fit readout should sit with the button"


def test_the_pane_declares_its_method_before_running_it() -> None:
"""Kesavan on the walkthrough: "it should show what is the method it is
going to use to calibrate". One method on this rig, so it is named rather
than offered as a choice."""
html = INDEX.read_text(encoding="utf-8")
cal = html[html.index('id="op-pane-cal"') : html.index('id="op-pane-acquire"')]
assert 'id="op-cal-method"' in cal
# The LED-not-laser confusion is the thing #106 turned on; say it here.
assert "laser" in cal.lower()


def test_leaving_the_pane_closes_the_led() -> None:
"""The calibrate path never did, which is half of #106."""
src = OPERATE.read_text(encoding="utf-8")
block = src[src.index(" cal: {") :]
block = block[: block.index("\n }")]
assert "forceLedOff()" in block


def test_the_rail_shows_which_embryos_lack_a_fit() -> None:
"""So a refusal at Start is visible beforehand, from any pane."""
src = OPERATE.read_text(encoding="utf-8")
assert "showFit: true" in src
panel = (ROOT / "gently" / "ui" / "web" / "static" / "js" / "panels" / "roster.js").read_text(
encoding="utf-8"
)
# Same field the server-side gate checks.
assert "slope_um_per_deg" in panel
Loading