From 487f29e801ceeba9fb6d850c286ea0e6edf91b4a Mon Sep 17 00:00:00 2001 From: Kesavan Date: Sat, 26 Sep 2026 01:08:06 -0400 Subject: [PATCH] feat(calibration): a running calibration can be aborted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "calibration routine -- while it is running, has no abort feature." It runs for a minute or more — sixty to eighty exposures, each judged — behind a disabled button, and there was no way to stop it short of closing the tab. Now there is an Abort beside Calibrate, shown only while a calibration runs. WHAT ACTUALLY STOPS The routine runs here, agent-side — Claude-vision edge search, adaptive focus sweeps — as a long series of SHORT device-layer plans: a snap, a move, a snap. There is nothing on the device layer to abort; each plan is over in a fraction of a second. What has to stop is the tool's task in the web process. The calibrate routes now run the tool as a task the new POST /api/devices/calibrate/abort can cancel: the routine stops at its next await, the plan in flight completes on its own, and every positioner is halted after it. Then the calibrate request answers 409 "aborted by operator" — a 409 like the pre-flight refusal, because nothing is broken and the operator chose it; the detail says which. A cancellation that is ours — the tab closed — is re-raised unchanged, so that still cancels the way it always did rather than reading as an abort. Abort with nothing running answers {aborted: false}: a press a moment too late is not a fault. HALT COVERS THE PIEZO Motion HALT stopped fdrive, XY and Z. The piezo is a stage to MMCore too, and an aborted sweep leaves it wherever it was heading; it is in the list now. The galvo is not a positioner and has no stop. Tests drive the real routes over ASGI with a calibration that runs until cancelled: abort cancels it, halts motion, the run answers 409, and the finished task is unregistered; nothing running is not an error; the batch route is abortable the same way; a client going away is not an abort. Source guards pin the pane and the halt list. Co-Authored-By: Claude Fable 5.1 --- gently/hardware/dispim/device_layer.py | 4 +- gently/ui/web/routes/data.py | 83 +++++++++- gently/ui/web/static/js/operate.js | 25 ++- gently/ui/web/templates/index.html | 3 + tests/test_calibration_can_be_aborted.py | 201 +++++++++++++++++++++++ 5 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 tests/test_calibration_can_be_aborted.py diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 2a94d2a1..19f69c92 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -3334,7 +3334,9 @@ async def handle_halt_motion(self, request): """ halted: list[str] = [] errors: dict[str, str] = {} - for key in ("fdrive", "xy_stage", "z_stage"): + # The piezo is a stage to MMCore too, and an aborted calibration can + # leave it mid-sweep. The galvo is not a positioner and has no stop. + for key in ("fdrive", "xy_stage", "z_stage", "piezo"): dev = self.devices.get(key) if dev is None: continue diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index e4dc4e8b..c4b2d253 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -1,5 +1,6 @@ """Data routes - calibration, snapshots, embryos, sequence, status, events.""" +import asyncio import logging from datetime import datetime from pathlib import Path @@ -42,6 +43,42 @@ def _json_safe(obj): return obj +class CalibrationAborted(Exception): + """The operator pressed Abort while a calibration was running.""" + + +async def _run_cancellable_calibration(agent, coro, what: str): + """Run a calibration as a task that POST /calibrate/abort can cancel. + + The routine runs here, agent-side — Claude-vision edge search, adaptive + focus sweeps — as a long series of SHORT device-layer plans (a snap, a + move, a snap). There is nothing on the device layer to abort: each plan + is over in a fraction of a second. What has to stop is this coroutine, + and cancelling its task stops it at its next await; the plan in flight + completes on its own and the halt that follows stops any motion. + + A CancelledError that is OURS — the HTTP client went away — is re-raised + unchanged, so a closed tab still cancels the way it always did. + """ + task = asyncio.create_task(coro) + agent._calibration_task = task + agent._calibration_what = what + try: + return await task + except asyncio.CancelledError: + # Task.cancelling() is 3.11+; the deps-less mypy run types against an + # older stdlib, so it is reached for by name. + current = asyncio.current_task() + cancelling = getattr(current, "cancelling", None) + if callable(cancelling) and cancelling(): + raise + raise CalibrationAborted(what) from None + finally: + if getattr(agent, "_calibration_task", None) is task: + agent._calibration_task = None + agent._calibration_what = None + + def _parse_dic_config(raw) -> dict | None: """The DIC overview block of a plan, validated. None when absent or off.""" if raw is None: @@ -1442,11 +1479,17 @@ async def calibrate_embryo_route(embryo_id: str, payload: dict = Body(default={} registry = get_tool_registry() try: - message = await registry.execute( - "calibrate_embryo", - args, - {"agent": agent, "client": client}, + message = await _run_cancellable_calibration( + agent, + registry.execute("calibrate_embryo", args, {"agent": agent, "client": client}), + embryo_id, ) + except CalibrationAborted: + # 409, like the pre-flight refusal: nothing is broken, the operator + # chose this. The detail says which 409 it is. + raise HTTPException( + status_code=409, detail=f"Calibration of {embryo_id} aborted by operator" + ) from None except Exception as exc: logger.exception("Calibration failed for %s", embryo_id) raise HTTPException(status_code=502, detail=f"calibration failed: {exc}") from exc @@ -1546,6 +1589,30 @@ async def restore_spim_alignment(payload: dict = Body(...)): # noqa: B008 raise HTTPException(status_code=404, detail="no alignment with that timestamp") return record.to_dict() + @router.post("/api/devices/calibrate/abort", dependencies=[Depends(require_control)]) + async def calibrate_abort(): + """Stop the calibration that is running, and any motion with it. + + Cancels the task the calibrate route registered, then halts every + positioner. Answers {aborted: false} when nothing is running rather + than erroring: an Abort pressed a moment too late is not a fault. + """ + agent = _require_agent_with_experiment() + task = getattr(agent, "_calibration_task", None) + what = getattr(agent, "_calibration_what", None) + if task is None or task.done(): + return {"success": True, "aborted": False, "detail": "No calibration running"} + task.cancel() + halted = None + client = _resolve_client() + if client is not None: + try: + halted = await client.halt_motion() + except Exception as exc: + halted = {"success": False, "error": str(exc)} + logger.warning("Calibration of %s aborted by operator (halt: %s)", what, halted) + return {"success": True, "aborted": True, "what": what, "halted": halted} + @router.post("/api/devices/calibrate/all", dependencies=[Depends(require_control)]) async def calibrate_all_route(payload: dict = Body(default={})): # noqa: B008 """Calibrate several embryos in one go. Body: {scope, ...cal settings}. @@ -1592,9 +1659,13 @@ async def calibrate_all_route(payload: dict = Body(default={})): # noqa: B008 args = {"embryo_ids": targets, **_calibration_args(payload)} registry = get_tool_registry() try: - message = await registry.execute( - "calibrate_all_embryos", args, {"agent": agent, "client": client} + message = await _run_cancellable_calibration( + agent, + registry.execute("calibrate_all_embryos", args, {"agent": agent, "client": client}), + "all", ) + except CalibrationAborted: + raise HTTPException(status_code=409, detail="Calibration aborted by operator") from None except Exception as exc: logger.exception("Batch calibration failed") raise HTTPException(status_code=502, detail=f"calibration failed: {exc}") from exc diff --git a/gently/ui/web/static/js/operate.js b/gently/ui/web/static/js/operate.js index 47749205..f754fbd1 100644 --- a/gently/ui/web/static/js/operate.js +++ b/gently/ui/web/static/js/operate.js @@ -1329,6 +1329,8 @@ const OperateManager = (function () { if (b) b.textContent = `Calibrating… ${Math.round((Date.now() - t0) / 1000)}s`; }, 1000); if (b) { b.disabled = true; b.textContent = 'Calibrating… 0s'; } + const ab = $('op-cal-abort'); + if (ab) { ab.hidden = false; ab.disabled = false; ab.textContent = 'Abort'; } if (out) out.textContent = 'sweeping…'; // The frames this run is about to take are already broadcast; the // progress panel shows them as they land. @@ -1359,7 +1361,13 @@ const OperateManager = (function () { // 409 is the pre-flight check declining, not a crash: nothing was // spent past one frame and the operator can say "anyway". Anything // else is a failure and reads as one. - if (e && e.status === 409) { + const detail = String((e && e.data && e.data.detail) || ''); + if (e && e.status === 409 && /aborted/i.test(detail)) { + // The operator's own doing: not a failure, not a refusal. + if (out) out.textContent = 'aborted'; + if (typeof CalProgressPanel !== 'undefined') CalProgressPanel.finish(false, 'aborted by operator'); + toast('Calibration aborted'); + } else if (e && e.status === 409) { if (out) out.textContent = 'nothing there'; _refusedFor = _selected; showRefusal(e); @@ -1376,10 +1384,24 @@ const OperateManager = (function () { } finally { clearInterval(tick); if (b) b.disabled = false; + if (ab) ab.hidden = true; renderCalTarget(); // restores the verb: Calibrate / Recalibrate } } + /** Stop the running calibration. The calibrate request then answers 409 "aborted". */ + async function abortCalibration() { + const ab = $('op-cal-abort'); + if (ab) { ab.disabled = true; ab.textContent = 'Aborting…'; } + try { + const d = await postJSON('/api/devices/calibrate/abort', {}); + if (!d.aborted) toast('Nothing to abort — the calibration had already finished'); + } catch (e) { + toastFail(`Abort failed (${why(e)})`); + if (ab) { ab.disabled = false; ab.textContent = 'Abort'; } + } + } + // 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. @@ -2565,6 +2587,7 @@ const OperateManager = (function () { // back-off button. Restored, and pinned by a test that counts them. const sp = $('op-spim-toggle'); if (sp) sp.addEventListener('click', toggleSpim); const cal = $('op-calibrate'); if (cal) cal.addEventListener('click', calibrateSelected); + const abort = $('op-cal-abort'); if (abort) abort.addEventListener('click', abortCalibration); const borrow = $('op-cal-borrow'); if (borrow) borrow.addEventListener('click', borrowCalibration); const all = $('op-cal-all'); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1efaf1c0..280219c1 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -824,6 +824,9 @@

Device