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