From 1f743cebb82b5c0f190cc5c25f2f2d5d2c428314 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:18:24 +0530 Subject: [PATCH 01/11] docs(b2): dual-camera + laser-preset + timelapse-form spec + plan (3 tasks; live parts rig-deferred) --- .../2026-06-29-manual-mode-dual-camera.md | 38 +++++++++++ ...26-06-29-manual-mode-dual-camera-design.md | 63 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md create mode 100644 docs/superpowers/specs/2026-06-29-manual-mode-dual-camera-design.md diff --git a/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md new file mode 100644 index 00000000..96e4c141 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md @@ -0,0 +1,38 @@ +# Manual mode B2 — dual-camera + laser-preset browser + timelapse form Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Extend B1's single-camera manual mode with a laser-preset browser, dual-camera (side A/B) config, and a manual timelapse-config form — building the headless parts now (live dual-view + real acquisition are rig-deferred). + +**Architecture:** New `require_control` proxy routes wrapping existing client/device-layer + agent-tool paths; device_factory registers a second camera defensively; UI additions to `#devices-view-manual` / `devices.js`. + +## Global Constraints +- HEADLESS-buildable parts only; mark RIG-DEFERRED parts (live dual view, real acquisition/timelapse) as noted in the spec — don't fake hardware. +- Backward compatible: single-camera rigs must still start (defensive HamCam2 registration); the manual-view-entry laser-off safety (B1 I3) stays intact. +- Laser preset list already exists: `GET /api/devices/laser/configs` (data.py:526). Reuse it; add only the set proxy + UI. +- Proxy routes mirror the existing `routes/data.py` `require_control` pattern (e.g. `/api/devices/laser/off` :508). Device-layer/client calls mirror existing ones. +- UI extends `#devices-view-manual` (index.html ~:720-835) + `DevicesManager` in `devices.js`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Laser-preset browser +**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/laser/config` `require_control` → `client.set_laser_config(name)`, mirror `/laser/off` :508); `gently/ui/web/templates/index.html` (the Illumination group ~:800 — replace the static `#devices-ls-laser-status` indicator with a `` populated from `GET /api/devices/laser/configs`; on change POST the + chosen preset. Keep "ALL OFF" the safe default + the existing manual-view-entry laser-off safety (don't + remove the I3 guard — selecting a preset is an explicit user action). +- **Rig-deferred:** the actual laser firing (the preset just calls `setConfig` on the rig). + +## 2. Dual-camera config +- **Backend (headless):** register a second `DiSPIMCamera("HamCam2")` as `devices["camera_b"]` in + `device_factory.py` — DEFENSIVELY (only if the camera is in the core's loaded devices; skip + log + otherwise, so single-camera rigs still start). Add a `side` field ('A'|'B') to `_ls_params` + + `handle_lightsheet_params`; `_ensure_lightsheet_sequence_sync` picks `camera` vs `camera_b` by side and + restarts the sequence on side change (reuse the exposure-change restart path). New `GET /api/devices/cameras` + endpoint listing available camera roles (A always; B if registered). +- **UI (headless):** a "Side A / B" selector in the manual rail → carries `side` on the live/params POST. +- **Rig-deferred:** live DUAL view via the "Multi Camera" fusion device (live-only) + dual-side acquisition + (two parallel `startSequenceAcquisition` + tag demux). v1 = single live stream, switchable side. + +## 3. Timelapse config form +- **Backend (headless):** new `POST /api/devices/timelapse/start` (`require_control`) proxy wrapping the + agent path `start_adaptive_timelapse(embryo_ids, stop_condition, interval_seconds, condition_value, + monitoring_mode)` (validate params; resolve the orchestrator like the agent tool does). The volume + geometry (num_slices/exposure/galvo±/piezo±/laser_config/power) is captured in the form + passed through + / persisted to per-embryo calibration where applicable. +- **UI (headless):** a collapsible "Timelapse" panel in the manual rail gathering cadence/stop/embryos/ + monitoring_mode + the volume geometry, reading `GET /api/devices/scan_geometry` + `/api/devices/laser/configs` + for defaults. A "Start timelapse" submit → the new proxy. +- **Rig-deferred:** the actual timelapse run + galvo/piezo motion. + +## 4. Out of scope / deferred +- Live Multi-Camera dual view + dual-side acquisition demux (rig). +- Saving timelapse configs as reusable presets (could reuse the tactic-library/plan-template later). +- Per-line laser power UI beyond the preset (the clamps in `optical.py` still apply). + +## 5. Testing +- Laser-preset: the POST proxy (TestClient + mock client asserts `set_laser_config(name)`); `node --check` + + Chrome audit of the dropdown populated from a stubbed configs endpoint. +- Dual-camera: device_factory registers camera_b against a FAKE core that has HamCam2 (and skips when + absent); the `side` param threads into `_ls_params` + selects the camera; `/api/devices/cameras` lists + roles; `node --check` + Chrome audit of the side selector. +- Timelapse: the start proxy validates + calls the orchestrator path (mock); the form gathers + posts the + params; `node --check` + Chrome audit of the form. +- All three: backward compatible (single-camera rig unaffected; the laser-off safety intact). From 340ab96811dc7715f8712171b151c5734aede2b2 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:24:20 +0530 Subject: [PATCH 02/11] feat(b2): laser-preset browser (set proxy + manual-mode dropdown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/devices/laser/config proxy (require_control, 400 on missing/empty config, mirrors /laser/off client+error pattern). Add — populated on manual-view entry let _lsTempInput, _lsTempSet; // Room-light toggle (header). Drives the SwitchBot Bot that switches the @@ -201,6 +202,7 @@ const DevicesManager = (function () { _lsLastcap = document.getElementById('devices-ls-lastcap'); _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); _lsLaserStatus = document.getElementById('devices-ls-laser-status'); + _lsLaserPreset = document.getElementById('devices-laser-preset'); _lsTempInput = document.getElementById('devices-ls-temp-input'); _lsTempSet = document.getElementById('devices-ls-temp-set'); @@ -1365,6 +1367,59 @@ const DevicesManager = (function () { } } + /** Fetch laser config-group presets and populate the #devices-laser-preset select. + * Selects "ALL OFF" by default (entry safety preset). + * Wires the change handler to POST the selected preset. + * Fire-and-forget safe — failure leaves the fallback "ALL OFF" option in place. */ + async function populateLaserPresets() { + cacheDom(); + if (!_lsLaserPreset) return; + try { + const res = await fetch('/api/devices/laser/configs'); + if (!res.ok) return; + const data = await res.json(); + // data may be an array of preset names or {configs: [...]} + const presets = Array.isArray(data) ? data : (data.configs || []); + if (!presets.length) return; + // Rebuild option list + _lsLaserPreset.innerHTML = ''; + for (const name of presets) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + _lsLaserPreset.appendChild(opt); + } + // Default to "ALL OFF" — entry safety state + if (presets.includes('ALL OFF')) _lsLaserPreset.value = 'ALL OFF'; + // Wire change handler (idempotent — replace via re-assignment) + _lsLaserPreset.onchange = () => setLaserPreset(_lsLaserPreset.value); + } catch (err) { + console.debug('laser preset fetch failed:', err); + } + } + + /** POST a named laser preset to the device layer. + * Updates the status indicator on success. + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserPreset(config) { + cacheDom(); + if (!config) return; + try { + const res = await fetch('/api/devices/laser/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? config : 'warning: state unknown'; + } + if (!res.ok) console.debug('laser preset set failed:', await res.text()); + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser preset set failed:', err); + } + } + async function toggleLightsheetStream() { if (!_lsToggle) return; _lsToggle.disabled = true; @@ -1972,7 +2027,9 @@ const DevicesManager = (function () { } } // Entering Manual view — gate lasers off immediately (brightfield-safe). - if (viewName === 'manual') setLaserOff(); + // populateLaserPresets() runs after setLaserOff() so the select is always + // seeded with the entry-safety state first. + if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); } } function setupViewSwitcher() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1f09ba06..c491eea3 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -797,6 +797,13 @@

Properties

+
+ + +
Laser: OFF (brightfield) diff --git a/tests/test_laser_preset_route.py b/tests/test_laser_preset_route.py new file mode 100644 index 00000000..ba6f4538 --- /dev/null +++ b/tests/test_laser_preset_route.py @@ -0,0 +1,127 @@ +"""Tests for B2 Task 1 — POST /api/devices/laser/config (set-preset proxy). + +Verifies: + - POST /api/devices/laser/config calls client.set_laser_config(config) + - Missing config body key → 400 + - Empty string config → 400 + - No client (microscope not connected) → 503 + - require_control gate (403 without override) +""" +from unittest.mock import MagicMock, AsyncMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.data import create_router +import gently.ui.web.auth as auth + + +def _app(client=None): + """Build a TestClient with the data routes wired up. + + Overrides require_control so the TestClient always gets the control role — + isolates route logic from auth, mirroring the pattern in test_lightsheet_routes. + """ + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +def test_set_laser_config_calls_client(): + """POST /api/devices/laser/config must call client.set_laser_config(config).""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": "488 only"}, + ) + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("488 only") + + +def test_set_laser_config_all_off(): + """POST /api/devices/laser/config works with 'ALL OFF'.""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("ALL OFF") + + +# --------------------------------------------------------------------------- +# 400 — missing / invalid config +# --------------------------------------------------------------------------- + +def test_set_laser_config_missing_key_returns_400(): + """POST without 'config' key in body → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"wrong_key": "ALL OFF"}, + ) + assert r.status_code == 400 + + +def test_set_laser_config_empty_string_returns_400(): + """POST with empty string config → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": ""}, + ) + assert r.status_code == 400 + + +def test_set_laser_config_null_returns_400(): + """POST with null config → 400.""" + client = MagicMock() + r = _app(client=client).post( + "/api/devices/laser/config", + json={"config": None}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# 503 — no client +# --------------------------------------------------------------------------- + +def test_set_laser_config_no_client_returns_503(): + """POST when microscope not connected → 503.""" + r = _app(client=None).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# require_control gate +# --------------------------------------------------------------------------- + +def test_set_laser_config_requires_control(): + """POST /api/devices/laser/config is gated by require_control.""" + server = MagicMock() + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # No dependency override — require_control will reject non-loopback hosts + r = TestClient(app, raise_server_exceptions=False).post( + "/api/devices/laser/config", + json={"config": "ALL OFF"}, + ) + assert r.status_code == 403 From 4ba1d05e4d3652f7c307d092d0618611baac7459 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:38:25 +0530 Subject: [PATCH 03/11] feat(b2): dual-camera config (HamCam2 + side selector) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register DiSPIMCamera("HamCam2") as devices["camera_b"] defensively in device_factory — only when HamCam2 is in core.getLoadedDevices(); single- camera rigs skip and log a warning. Add "side" ('A'|'B', default 'A') to _ls_params; _ensure_lightsheet_sequence_sync selects camera/camera_b by side and restarts the sequence on side change (mirrors the exposure-change restart path). Side B without camera_b falls back to A. New GET /api/cameras endpoint (device-layer + UI proxy) lists available camera roles. UI: #devices-ls-side select in the manual rail, hidden on single-camera rigs, shown when GET /api/devices/cameras lists 'B'. postLightsheetParams includes side on every call. populateCameraRoles() called on manual-view entry alongside populateLaserPresets. RIG-DEFERRED: live Multi-Camera dual view + dual-side acquisition. TDD: 16 tests, all pass; node --check exit 0; no new suite failures. --- gently/hardware/dispim/client.py | 15 +- gently/hardware/dispim/device_factory.py | 21 +++ gently/hardware/dispim/device_layer.py | 47 ++++- gently/ui/web/routes/data.py | 19 +- gently/ui/web/static/js/devices.js | 32 +++- gently/ui/web/templates/index.html | 10 + tests/test_dual_camera_factory.py | 107 +++++++++++ tests/test_lightsheet_side_param.py | 223 +++++++++++++++++++++++ 8 files changed, 461 insertions(+), 13 deletions(-) create mode 100644 tests/test_dual_camera_factory.py create mode 100644 tests/test_lightsheet_side_param.py diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 1437a8a8..b4b38557 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -834,6 +834,15 @@ async def get_laser_configs(self) -> dict: """ return await self._api_get("/api/laser/configs") + async def get_cameras(self) -> dict: + """List available SPIM camera roles. + + Hits ``GET /api/cameras`` — returns ``{"cameras": ["A"]}`` on + single-camera rigs or ``{"cameras": ["A", "B"]}`` when HamCam2 is + registered as camera_b in the device layer. + """ + return await self._api_get("/api/cameras") + async def get_led_status(self) -> dict: """Get current LED status.""" return await self._api_get("/api/led/status") @@ -1015,9 +1024,9 @@ async def stream_lightsheet(self, timeout: float | None = None): logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) async def set_lightsheet_live_params( - self, galvo=None, piezo=None, exposure=None + self, galvo=None, piezo=None, exposure=None, side=None ) -> dict: - """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + """POST live galvo/piezo/exposure/side to the device-layer lightsheet streamer.""" body = {} if galvo is not None: body["galvo"] = float(galvo) @@ -1025,6 +1034,8 @@ async def set_lightsheet_live_params( body["piezo"] = float(piezo) if exposure is not None: body["exposure"] = float(exposure) + if side is not None: + body["side"] = str(side) return await self._api_post("/api/lightsheet/live/params", body) async def set_camera_led_mode(self, use_led: bool = False) -> dict: diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index e914b1b1..96b0d276 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -64,6 +64,7 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non default_config = { "xy_stage_name": "XYStage:XY:31", "camera_name": "HamCam1", + "camera_b_name": "HamCam2", "scanner_name": "Scanner:AB:33", "piezo_name": "PiezoStage:P:34", "fdrive_name": "ZStage:V:37", @@ -108,6 +109,26 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create camera: %s", e) + # Defensively register the second SPIM camera (side B) only when present + # in the core's loaded-device list. Single-camera rigs that lack HamCam2 + # continue to start normally; camera_b is simply absent from devices. + try: + from .devices import DiSPIMCamera as _DiSPIMCamera + + cam_b_name = cfg.get("camera_b_name", "HamCam2") + loaded_devices = list(core.getLoadedDevices()) + if cam_b_name in loaded_devices: + camera_b = _DiSPIMCamera(device_name=cam_b_name, core=core) + devices["camera_b"] = camera_b + logger.info("Created camera_b (side B): %s", cam_b_name) + else: + logger.warning( + "camera_b (%s) not in loaded devices — single-camera rig or device absent; skipping", + cam_b_name, + ) + except Exception as e: + logger.warning("Could not create camera_b: %s", e) + try: from .devices import DiSPIMLightSource diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 0e44ebfa..69b51581 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -174,7 +174,7 @@ def __init__( self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows self._ls_target_max_dim: int = 512 self._ls_jpeg_quality: int = 70 - self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} + self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} self._ls_seq_started: bool = False self._ls_applied: dict = {} # last-applied galvo/piezo/exposure self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values @@ -1016,15 +1016,33 @@ def _park_lightsheet_sync(self) -> None: self._ls_parked["piezo"] = want def _ensure_lightsheet_sequence_sync(self) -> None: - """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" + """Start (or restart on exposure/side change) the continuous sequence on the SPIM camera. + + Side 'A' uses devices["camera"] (HamCam1); side 'B' uses devices["camera_b"] + (HamCam2) when registered. If side B is requested but camera_b is absent, + falls back to side A and logs a warning — single-camera rigs are unaffected. + """ core = self.system.core - cam = self.devices.get("camera") + p = self._ls_params + side = p.get("side", "A") + + # Resolve the camera for the requested side + if side == "B" and "camera_b" in self.devices: + cam = self.devices.get("camera_b") + else: + if side == "B": + logger.warning( + "Side B requested but camera_b not registered; falling back to side A" + ) + cam = self.devices.get("camera") + if cam is None: raise RuntimeError("No lightsheet camera configured") - p = self._ls_params + need_restart = ( not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] + or self._ls_applied.get("side") != side ) if need_restart: if core.isSequenceRunning(): @@ -1035,6 +1053,7 @@ def _ensure_lightsheet_sequence_sync(self) -> None: core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) self._ls_seq_started = True self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side def _grab_lightsheet_frame_sync(self): """Park → ensure sequence running → peek the latest frame (never drain).""" @@ -1167,15 +1186,17 @@ async def handle_lightsheet_stream(self, request): return response async def handle_lightsheet_params(self, request): - """POST /api/lightsheet/live/params — update live galvo/piezo/exposure. + """POST /api/lightsheet/live/params — update live galvo/piezo/exposure/side. - Body: {"galvo": float, "piezo": float, "exposure": float} (all optional). - Galvo/piezo apply on the next grab; exposure change triggers sequence restart. + Body: {"galvo": float, "piezo": float, "exposure": float, "side": "A"|"B"} (all optional). + Galvo/piezo apply on the next grab; exposure or side change triggers sequence restart. """ body = await request.json() for k in ("galvo", "piezo", "exposure"): if k in body and body[k] is not None: self._ls_params[k] = float(body[k]) + if "side" in body and body["side"] in ("A", "B"): + self._ls_params["side"] = body["side"] return web.json_response({"params": self._ls_params}) # ========================================================================= @@ -2084,6 +2105,17 @@ async def handle_get_laser_configs(self, request): status=500, ) + async def handle_get_cameras(self, request): + """GET /api/cameras — list available SPIM camera roles. + + Returns ``{"cameras": ["A"]}`` on single-camera rigs, or + ``{"cameras": ["A", "B"]}`` when camera_b (HamCam2) is registered. + """ + cameras = ["A"] + if "camera_b" in self.devices: + cameras.append("B") + return web.json_response({"cameras": cameras}) + async def handle_get_camera_exposure(self, request): """GET /api/camera/exposure - Get bottom camera exposure time""" try: @@ -3100,6 +3132,7 @@ async def on_start(self): # Lightsheet (SPIM) live stream — continuous sequence acquisition self._app.router.add_get("/api/lightsheet/stream", self.handle_lightsheet_stream) self._app.router.add_post("/api/lightsheet/live/params", self.handle_lightsheet_params) + self._app.router.add_get("/api/cameras", self.handle_get_cameras) # Start plan executor self._executor_task = asyncio.create_task(self._plan_executor()) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 8897b292..80ce9fd0 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -476,14 +476,14 @@ async def stop_lightsheet_live_stream(): @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 - """Forward galvo/piezo/exposure params to the device-layer lightsheet streamer.""" + """Forward galvo/piezo/exposure/side params to the device-layer lightsheet streamer.""" client = _resolve_client() if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") try: res = await client.set_lightsheet_live_params( galvo=payload.get("galvo"), piezo=payload.get("piezo"), - exposure=payload.get("exposure")) + exposure=payload.get("exposure"), side=payload.get("side")) except Exception as exc: logger.exception("lightsheet live params failed") raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc @@ -560,6 +560,21 @@ async def laser_config_set(payload: dict = Body(...)): # noqa: B008 logger.exception("Laser config set command failed") raise HTTPException(status_code=502, detail=f"laser config failed: {exc}") from exc + @router.get("/api/devices/cameras") + async def cameras_list(): + """Return the available SPIM camera roles (A always; B if camera_b registered). + + No require_control — read-only status route, mirrors GET /api/devices/laser/configs. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_cameras() + except Exception as exc: + logger.exception("Cameras list fetch failed") + raise HTTPException(status_code=502, detail=f"cameras failed: {exc}") from exc + @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 """Enable/disable automatic LED for bottom-camera captures. Body: {"use_led": bool}.""" diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index fad43df7..a5ce39dd 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -91,6 +91,7 @@ const DevicesManager = (function () { let _lsGalvo = 0; let _lsPiezo = 50; let _lsExposure = 20; // matches device-layer _ls_params default (20 ms) + let _lsSide = 'A'; // SPIM side — 'A' (HamCam1) or 'B' (HamCam2 if present) let _lsParamTimer = null; // Lightsheet control inputs (rail) @@ -100,6 +101,7 @@ const DevicesManager = (function () { let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls let _lsLaserPreset; // — shown only when camera_b present let _lsTempInput, _lsTempSet; // Room-light toggle (header). Drives the SwitchBot Bot that switches the @@ -203,6 +205,7 @@ const DevicesManager = (function () { _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); _lsLaserStatus = document.getElementById('devices-ls-laser-status'); _lsLaserPreset = document.getElementById('devices-laser-preset'); + _lsSideSelect = document.getElementById('devices-ls-side'); _lsTempInput = document.getElementById('devices-ls-temp-input'); _lsTempSet = document.getElementById('devices-ls-temp-set'); @@ -1398,6 +1401,31 @@ const DevicesManager = (function () { } } + /** Fetch SPIM camera roles and show the Side A/B selector if camera_b is present. + * Called on manual-view entry. Hides the selector on single-camera rigs. + * Fire-and-forget safe — failure leaves the selector hidden (safe default). */ + async function populateCameraRoles() { + cacheDom(); + const group = document.getElementById('devices-ls-side-group'); + try { + const res = await fetch('/api/devices/cameras'); + if (!res.ok) return; + const data = await res.json(); + // data may be {cameras: [...]} or a raw array + const cameras = Array.isArray(data) ? data : (data.cameras || []); + const hasSideB = cameras.includes('B'); + if (group) group.style.display = hasSideB ? '' : 'none'; + if (_lsSideSelect && hasSideB) { + _lsSideSelect.onchange = () => { + _lsSide = _lsSideSelect.value; + postLightsheetParams(); + }; + } + } catch (err) { + console.debug('camera roles fetch failed:', err); + } + } + /** POST a named laser preset to the device layer. * Updates the status indicator on success. * Fire-and-forget safe — failure shows a warning, never throws. */ @@ -1607,7 +1635,7 @@ const DevicesManager = (function () { fetch('/api/devices/lightsheet/live/params', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure }), + body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure, side: _lsSide }), }).catch(err => console.debug('lightsheet params post failed:', err)); } @@ -2029,7 +2057,7 @@ const DevicesManager = (function () { // Entering Manual view — gate lasers off immediately (brightfield-safe). // populateLaserPresets() runs after setLaserOff() so the select is always // seeded with the entry-safety state first. - if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); } + if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); populateCameraRoles(); } } function setupViewSwitcher() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index c491eea3..3000bf09 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -758,6 +758,16 @@

Properties

+ + +
diff --git a/tests/test_dual_camera_factory.py b/tests/test_dual_camera_factory.py new file mode 100644 index 00000000..5476184e --- /dev/null +++ b/tests/test_dual_camera_factory.py @@ -0,0 +1,107 @@ +"""Tests for B2 Task 2 — dual-camera device_factory (HamCam2 defensive registration). + +Verifies: + - FakeCore with "HamCam2" in getLoadedDevices() → devices["camera_b"] created (name == "HamCam2") + - FakeCore without "HamCam2" → camera_b absent, no crash, devices["camera"] still created + - camera_b registration uses core.getLoadedDevices() as the presence check +""" +import sys +from unittest.mock import MagicMock + +# Patch heavy hardware deps before importing device_factory. +# All device classes use pymmcore / ophyd.status only (no ophyd base classes), +# so patching at the module level is sufficient. +for _mod in ( + "pymmcore", + "ophyd", + "ophyd.status", + "bluesky", + "bluesky.run_engine", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_factory import create_devices_from_mmcore # noqa: E402 + + +class FakeCore: + """Minimal CMMCore stand-in with configurable loaded-device list.""" + + def __init__(self, loaded_devices=None): + self._loaded = list(loaded_devices or []) + + def getLoadedDevices(self): + return self._loaded + + # stubs that DiSPIMCamera.__init__ may indirectly trigger via ophyd proxies + def getProperty(self, dev, prop): + return "0" + + def setProperty(self, dev, prop, val): + pass + + def getCameraDevice(self): + return "" + + def setCameraDevice(self, name): + pass + + +# --------------------------------------------------------------------------- +# Happy path — HamCam2 present +# --------------------------------------------------------------------------- + + +def test_camera_b_created_when_hamcam2_loaded(): + """HamCam2 in getLoadedDevices → devices['camera_b'] created with name 'HamCam2'.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" in devices, "camera_b must be registered when HamCam2 is in loaded devices" + assert devices["camera_b"].name == "HamCam2" + + +def test_camera_b_name_is_hamcam2(): + """The camera_b name attribute is exactly the configured camera_b_name.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:AB:33"]) + devices = create_devices_from_mmcore(core) + assert devices["camera_b"].name == "HamCam2" + + +# --------------------------------------------------------------------------- +# Single-camera rig — HamCam2 absent +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_when_hamcam2_not_loaded(): + """HamCam2 NOT in getLoadedDevices → camera_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices, "camera_b must be absent on single-camera rigs" + + +def test_camera_still_created_without_hamcam2(): + """Primary camera (HamCam1) is created regardless of HamCam2 presence.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera" in devices + assert devices["camera"].name == "HamCam1" + + +# --------------------------------------------------------------------------- +# Edge case — empty loaded-device list +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_on_empty_device_list(): + """Empty loaded-device list → camera_b not attempted, no extra error.""" + core = FakeCore(loaded_devices=[]) + # DiSPIMCamera.__init__ only stores name/core; it succeeds even with no devices. + # So "camera" is still created; "camera_b" is skipped. + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices + assert "camera" in devices diff --git a/tests/test_lightsheet_side_param.py b/tests/test_lightsheet_side_param.py new file mode 100644 index 00000000..25009334 --- /dev/null +++ b/tests/test_lightsheet_side_param.py @@ -0,0 +1,223 @@ +"""Tests for B2 Task 2 — lightsheet side param (handle_lightsheet_params + +_ensure_lightsheet_sequence_sync + handle_get_cameras). + +Verifies: + - handle_lightsheet_params accepts 'side' ('A'|'B'); invalid values ignored + - side change in _ensure_lightsheet_sequence_sync selects the correct camera + and triggers a sequence restart (mirroring the exposure-change path) + - side B without camera_b falls back to A + no crash + - handle_get_cameras returns ["A"] or ["A","B"] based on devices dict +""" +import json +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeCore: + """Minimal CMMCore stub tracking sequence-start/stop calls.""" + + def __init__(self): + self.running = False + self.cam = None + self.started = 0 + self.stopped = 0 + self.exposure = None + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam + + def setExposure(self, n, ms): + self.exposure = ms + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def isSequenceRunning(self): + return self.running + + +def _make_dl(with_camera_b: bool = True) -> DeviceLayerServer: + """Build a minimal DeviceLayerServer with faked core + devices.""" + dl = DeviceLayerServer.__new__(DeviceLayerServer) + dl.system = type("S", (), {"core": FakeCore()})() + cam_a = type("C", (), {"name": "HamCam1"})() + cam_b = type("C", (), {"name": "HamCam2"})() + dl.devices = {"camera": cam_a} + if with_camera_b: + dl.devices["camera_b"] = cam_b + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# _ensure_lightsheet_sequence_sync — camera selection by side +# --------------------------------------------------------------------------- + + +def test_side_a_selects_camera_a(): + """Side A → sequence started on HamCam1.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_b_selects_camera_b(): + """Side B with camera_b present → sequence started on HamCam2.""" + dl = _make_dl(with_camera_b=True) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam2" + assert dl.system.core.started == 1 + + +def test_side_b_fallback_to_a_when_no_camera_b(): + """Side B without camera_b → graceful fallback to A (HamCam1), no crash.""" + dl = _make_dl(with_camera_b=False) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_change_triggers_restart(): + """Switching from A to B stops the running sequence and restarts on HamCam2.""" + dl = _make_dl(with_camera_b=True) + # First call — start on A + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + assert dl.system.core.cam == "HamCam1" + + # Switch to B — must restart + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.stopped >= 1 + assert dl.system.core.started >= 2 + assert dl.system.core.cam == "HamCam2" + + +def test_no_restart_when_side_unchanged(): + """Same side, same exposure → sequence already running, no extra restart.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + starts_after_first = dl.system.core.started + + # Manually mark the applied state (as the real code does) + dl._ls_applied["exposure"] = 20.0 + dl._ls_applied["side"] = "A" + + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == starts_after_first + + +# --------------------------------------------------------------------------- +# handle_lightsheet_params — side accepted / validated +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_b(): + """POST {side: 'B'} → _ls_params['side'] updated to 'B'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "B" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_a(): + """POST {side: 'A'} when already A → _ls_params['side'] stays 'A'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "A"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_ignores_invalid_side(): + """Invalid side value (not 'A'|'B') → _ls_params['side'] unchanged.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + req = MagicMock() + req.json = AsyncMock(return_value={"side": "C"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_exposure_and_side(): + """POST with both exposure and side → both applied.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"exposure": 50.0, "side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["exposure"] == 50.0 + assert dl._ls_params["side"] == "B" + + +# --------------------------------------------------------------------------- +# handle_get_cameras — roles endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_get_cameras_with_camera_b(): + """Both A and B listed when camera_b is registered.""" + dl = _make_dl(with_camera_b=True) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A", "B"] + + +@pytest.mark.asyncio +async def test_handle_get_cameras_without_camera_b(): + """Only A listed when camera_b is absent.""" + dl = _make_dl(with_camera_b=False) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A"] From 5f7011e7cf62219264497b80566678ffd3fa1615 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:51:07 +0530 Subject: [PATCH 04/11] feat(b2): manual timelapse-config form (start proxy + rail panel) Adds POST /api/devices/timelapse/start (require_control) that validates params and calls orchestrator.start via server.agent_bridge.agent.timelapse_orchestrator. Adds a collapsible Timelapse panel to #devices-view-manual with cadence, stop condition, embryo IDs, monitoring mode, and volume geometry fields. devices.js populates defaults from GET /api/devices/scan_geometry + /api/devices/laser/configs on manual-view entry and POSTs on submit. 16 TDD tests; node --check passes; galvo/piezo motion is rig-deferred. --- gently/ui/web/routes/data.py | 112 +++++++++++ gently/ui/web/static/js/devices.js | 168 ++++++++++++++++- gently/ui/web/templates/index.html | 130 +++++++++++++ tests/test_timelapse_start_route.py | 275 ++++++++++++++++++++++++++++ 4 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 tests/test_timelapse_start_route.py diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 80ce9fd0..8342fe09 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -671,6 +671,118 @@ async def acquire_volume(payload: dict = Body(...)): # noqa: B008 logger.exception("Volume acquisition failed") raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc + # ------------------------------------------------------------------ + # Timelapse + # ------------------------------------------------------------------ + + @router.post("/api/devices/timelapse/start", dependencies=[Depends(require_control)]) + async def timelapse_start(payload: dict = Body(...)): # noqa: B008 + """Start an adaptive timelapse from the manual UI. + + Body fields (all optional except interval_seconds has a default): + interval_seconds (float, default 120) — cadence; must be > 0 + stop_condition (str, default "manual") — "manual", "timepoints", "duration" + embryo_ids (list[str] | null) — null = all active embryos + condition_value (int | null) — timepoints count or duration hours + monitoring_mode (str | null) — "idle" / "expression_monitoring" / + "pre_terminal_monitoring" + num_slices (int, default 50) — must be >= 1 if provided + exposure_ms (float, default 10.0) + galvo_amplitude (float, default 0.5) + galvo_center (float, default 0.0) + piezo_amplitude (float, default 25.0) + piezo_center (float, default 50.0) + laser_config (str | null) + + Validation: + - interval_seconds must be > 0 + - num_slices must be >= 1 + + Orchestrator access: server.agent_bridge.agent.timelapse_orchestrator + RIG-DEFERRED: the actual acquisition + galvo/piezo motion. + """ + # --- Validate --- + raw_interval = payload.get("interval_seconds", 120.0) + try: + interval_seconds = float(raw_interval) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="interval_seconds must be a number") + if interval_seconds <= 0: + raise HTTPException(status_code=400, detail="interval_seconds must be > 0") + + raw_slices = payload.get("num_slices") + if raw_slices is not None: + try: + num_slices = int(raw_slices) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="num_slices must be an integer") + if num_slices < 1: + raise HTTPException(status_code=400, detail="num_slices must be >= 1") + else: + num_slices = 50 + + stop_condition = str(payload.get("stop_condition") or "manual") + embryo_ids = payload.get("embryo_ids") or None + condition_value = payload.get("condition_value") + monitoring_mode = payload.get("monitoring_mode") or None + + # Volume geometry — passed through for context / future calibration write; + # not forwarded to orchestrator.start (which owns its own geometry via the + # per-embryo calibration). RIG-DEFERRED: real acquisition uses these. + volume_geometry = { + "num_slices": num_slices, + "exposure_ms": float(payload.get("exposure_ms", 10.0)), + "galvo_amplitude": float(payload.get("galvo_amplitude", 0.5)), + "galvo_center": float(payload.get("galvo_center", 0.0)), + "piezo_amplitude": float(payload.get("piezo_amplitude", 25.0)), + "piezo_center": float(payload.get("piezo_center", 50.0)), + "laser_config": payload.get("laser_config") or None, + } + + # --- Resolve orchestrator --- + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + orchestrator = getattr(agent, "timelapse_orchestrator", None) if agent else None + if orchestrator is None: + raise HTTPException( + status_code=503, + detail="Timelapse orchestrator not initialised (agent not running or no session)", + ) + + # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + try: + result = await orchestrator.start( + embryo_ids=embryo_ids, + stop_condition=stop_condition, + base_interval_seconds=interval_seconds, + condition_value=condition_value, + ) + except Exception as exc: + logger.exception("Timelapse start failed") + raise HTTPException(status_code=502, detail=f"timelapse start failed: {exc}") from exc + + # Optionally install a monitoring mode at startup (mirrors start_adaptive_timelapse) + mode_result = None + if monitoring_mode and monitoring_mode != "idle": + try: + mode_result = orchestrator.enable_monitoring_mode(monitoring_mode) + except Exception as exc: + mode_result = f"warning: failed to enable monitoring mode: {exc}" + + return { + "started": True, + "result": result, + "monitoring_mode_result": mode_result, + "config": { + "interval_seconds": interval_seconds, + "stop_condition": stop_condition, + "embryo_ids": embryo_ids, + "condition_value": condition_value, + "monitoring_mode": monitoring_mode, + "volume_geometry": volume_geometry, + }, + } + @router.get("/api/calibration") async def list_calibration(embryo_id: str | None = None): """Get calibration images""" diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index a5ce39dd..72f1e7b9 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -104,6 +104,13 @@ const DevicesManager = (function () { let _lsSideSelect; // + s +
+ + +
+ + +
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + ms +
+ +
+ + + ° +
+ +
+ + + ° +
+ +
+ + + µm +
+ +
+ + + µm +
+ +
+ + +
+
+ + +
+ +
+ + + + + diff --git a/tests/test_timelapse_start_route.py b/tests/test_timelapse_start_route.py new file mode 100644 index 00000000..9f5c3c1b --- /dev/null +++ b/tests/test_timelapse_start_route.py @@ -0,0 +1,275 @@ +"""Tests for B2 Task 3 — POST /api/devices/timelapse/start (manual timelapse proxy). + +Verifies: + - POST with valid params calls orchestrator.start with the right args → 200 + - monitoring_mode is forwarded to orchestrator.enable_monitoring_mode + - interval_seconds <= 0 → 400 + - num_slices < 1 → 400 + - missing interval_seconds uses default 120.0 (no error) + - orchestrator not initialised → 503 + - require_control gate (403 without override) + +Orchestrator access path: + server.agent_bridge.agent.timelapse_orchestrator + (mirroring the `require_timelapse_orchestrator(agent)` helper in harness/tools/helpers.py) +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import gently.ui.web.auth as auth +from gently.ui.web.routes.data import create_router + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _app(orchestrator=None): + """Build a TestClient wired with the data routes. + + The mock server exposes `agent_bridge.agent.timelapse_orchestrator`. + `require_control` is overridden so route logic is isolated from auth. + """ + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orchestrator + # Satisfy other routes that call _resolve_client() or store + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +def _make_orchestrator(start_return="Timelapse started."): + """Return a mock orchestrator with an async start and sync enable_monitoring_mode.""" + orch = MagicMock() + orch.start = AsyncMock(return_value=start_return) + orch.enable_monitoring_mode = MagicMock(return_value="Monitoring mode enabled.") + return orch + + +# --------------------------------------------------------------------------- +# Happy path — minimal valid payload +# --------------------------------------------------------------------------- + +def test_timelapse_start_minimal(): + """POST with just interval_seconds calls orchestrator.start; returns 200 with started=True.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 200 + body = r.json() + assert body["started"] is True + orch.start.assert_awaited_once() + + +def test_timelapse_start_calls_start_with_correct_args(): + """orchestrator.start receives the right interval, stop_condition, embryo_ids, condition_value.""" + orch = _make_orchestrator() + _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 60, + "stop_condition": "timepoints", + "embryo_ids": ["e1", "e2"], + "condition_value": 10, + }, + ) + orch.start.assert_awaited_once_with( + embryo_ids=["e1", "e2"], + stop_condition="timepoints", + base_interval_seconds=60.0, + condition_value=10, + ) + + +def test_timelapse_start_uses_default_interval(): + """Omitting interval_seconds uses the default (120.0) without error.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={}, + ) + assert r.status_code == 200 + _, kwargs = orch.start.call_args + assert kwargs["base_interval_seconds"] == 120.0 + + +def test_timelapse_start_result_in_response(): + """The orchestrator.start return value appears in the response.""" + orch = _make_orchestrator(start_return="Timelapse running — 3 embryos.") + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 90}, + ) + assert r.json()["result"] == "Timelapse running — 3 embryos." + + +# --------------------------------------------------------------------------- +# Monitoring mode +# --------------------------------------------------------------------------- + +def test_timelapse_start_enables_monitoring_mode(): + """monitoring_mode != 'idle' triggers enable_monitoring_mode on the orchestrator.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "expression_monitoring"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_called_once_with("expression_monitoring") + assert r.json()["monitoring_mode_result"] == "Monitoring mode enabled." + + +def test_timelapse_start_idle_mode_skips_enable(): + """monitoring_mode='idle' does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "idle"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_not_called() + + +def test_timelapse_start_no_mode_skips_enable(): + """Omitting monitoring_mode does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + _app(orch).post("/api/devices/timelapse/start", json={"interval_seconds": 120}) + orch.enable_monitoring_mode.assert_not_called() + + +# --------------------------------------------------------------------------- +# Volume geometry passed through in response config +# --------------------------------------------------------------------------- + +def test_timelapse_start_volume_geometry_in_config(): + """Volume geometry fields appear in response['config']['volume_geometry'].""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 120, + "num_slices": 80, + "exposure_ms": 15.0, + "galvo_amplitude": 0.7, + "galvo_center": 0.1, + "piezo_amplitude": 30.0, + "piezo_center": 55.0, + "laser_config": "488 only", + }, + ) + assert r.status_code == 200 + vg = r.json()["config"]["volume_geometry"] + assert vg["num_slices"] == 80 + assert vg["exposure_ms"] == 15.0 + assert vg["laser_config"] == "488 only" + + +# --------------------------------------------------------------------------- +# 400 — validation failures +# --------------------------------------------------------------------------- + +def test_timelapse_start_interval_zero_returns_400(): + """interval_seconds = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 0}, + ) + assert r.status_code == 400 + assert "interval_seconds" in r.json()["detail"] + + +def test_timelapse_start_negative_interval_returns_400(): + """interval_seconds < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": -10}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_num_slices_zero_returns_400(): + """num_slices = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": 0}, + ) + assert r.status_code == 400 + assert "num_slices" in r.json()["detail"] + + +def test_timelapse_start_num_slices_negative_returns_400(): + """num_slices < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": -5}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_non_numeric_interval_returns_400(): + """Non-numeric interval_seconds → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": "fast"}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# 503 — orchestrator not reachable +# --------------------------------------------------------------------------- + +def test_timelapse_start_no_orchestrator_returns_503(): + """orchestrator is None (agent not running / no session) → 503.""" + r = _app(orchestrator=None).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +def test_timelapse_start_no_agent_bridge_returns_503(): + """agent_bridge missing entirely → 503.""" + server = MagicMock(spec=[]) # no agent_bridge attribute + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + r = TestClient(app).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# require_control gate +# --------------------------------------------------------------------------- + +def test_timelapse_start_requires_control(): + """POST /api/devices/timelapse/start is gated by require_control (403 without override).""" + orch = _make_orchestrator() + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orch + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # No dependency override — require_control will reject non-loopback hosts + r = TestClient(app, raise_server_exceptions=False).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 403 From 65525e94878afe723c002cb259aa45cd0ca3dbbf Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 09:04:24 +0530 Subject: [PATCH 05/11] fix(b2): style the timelapse panel (define ls-collapsible/ls-sub-* in app CSS) Add 8 CSS classes to main.css that the timelapse panel (#devices-view-manual) references but were never defined in the app: ls-collapsible-head/body/arrow, ls-sub-row/label/section/section-label, ls-tl-status. Panel was rendering unstyled in the real app; scratchpad harness had masked this with inline CSS. Classes mirror the existing .ls-row/.ls-group/.ls-label pattern (same CSS variables, spacing, dark instrument aesthetic). [hidden] fights prevented via explicit [hidden]{display:none} scoped rules. Verified via Chrome MCP harness loading the real main.css with no harness-local ls-* overrides. Also: TODO comment in timelapse_start route noting UI path skips plan auto-linking. --- gently/ui/web/routes/data.py | 2 + gently/ui/web/static/css/main.css | 90 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 8342fe09..99dd07d8 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -750,6 +750,8 @@ async def timelapse_start(payload: dict = Body(...)): # noqa: B008 ) # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + # TODO: UI-initiated timelapses skip the agent tool's plan auto-linking; + # this is intentional — the agent path wires the plan, this route does not. try: result = await orchestrator.start( embryo_ids=embryo_ids, diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index ff964886..7d3a0f7f 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10843,3 +10843,93 @@ body.modal-open { color: var(--map-ink); word-break: break-all; } + +/* ── Timelapse collapsible panel (B2 Task 3) ─────────────────────────────── */ + +/* Clickable header button (also carries .ls-label for font/color) */ +.ls-collapsible-head { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +/* ▶/▼ caret — rotates when expanded */ +.ls-collapsible-arrow { + font-size: 0.5rem; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: transform 0.15s; +} + +.ls-collapsible-head[aria-expanded="true"] .ls-collapsible-arrow { + transform: rotate(90deg); +} + +/* Collapsible body — hidden via [hidden] attribute; explicit rule prevents + display overrides from fighting the attribute (mirrors .ls-lastcap[hidden]) */ +.ls-collapsible-body { + display: flex; + flex-direction: column; + gap: 0.32rem; + margin-top: 0.35rem; +} + +.ls-collapsible-body[hidden] { display: none; } + +/* Sub-rows: like .ls-row but slightly indented for the body interior */ +.ls-sub-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding-left: 0.25rem; +} + +/* Sub-label: like .ls-label but fixed-width to align inputs column */ +.ls-sub-label { + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + min-width: 4.5rem; + flex-shrink: 0; +} + +/* Volume-Geometry inset sub-group */ +.ls-sub-section { + display: flex; + flex-direction: column; + gap: 0.28rem; + padding: 0.4rem 0.5rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + margin: 0.15rem 0; +} + +/* "VOLUME GEOMETRY" sub-header */ +.ls-sub-section-label { + font-size: 0.52rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--map-ink-mute); + margin-bottom: 0.1rem; +} + +/* Start-result / status line — mono, muted */ +.ls-tl-status { + font-family: 'JetBrains Mono', monospace; + font-size: 0.62rem; + color: var(--map-ink-mute); + padding: 0.2rem 0.25rem; + word-break: break-all; +} + +.ls-tl-status[hidden] { display: none; } From aca9a2589b9eb0c2bc998abe1b2925fd4245fb06 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 10:09:33 +0530 Subject: [PATCH 06/11] fix(manual): single LED toggle + laser on/off toggle (replace two-button LED, add laser on/off) --- gently/ui/web/static/css/main.css | 4 ++ gently/ui/web/static/js/devices.js | 70 ++++++++++++++++++++++++++---- gently/ui/web/templates/index.html | 16 ++++--- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 7d3a0f7f..9215466a 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10725,6 +10725,10 @@ body.modal-open { flex-shrink: 0; } +.ls-laser-dot--on { + background: var(--map-accent); +} + /* Laser preset row (B2) */ .ls-laser-row { display: flex; diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 72f1e7b9..2319d9b2 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -96,8 +96,11 @@ const DevicesManager = (function () { // Lightsheet control inputs (rail) let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; - let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn; + let _lsLedToggle, _lsCamLed, _lsRoomLightBtn; + let _lsLedIsOpen = false; // LED toggle state: false = Closed (safe default) let _lsCamLedOn = false; + let _lsLaserToggle; + let _lsLaserOn = false; // Laser toggle state: false = OFF (entry-safe default) let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls let _lsLaserPreset; // From 7d21f5f4670d75a7b1d932c1489405d3995f07f4 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:06:53 +0530 Subject: [PATCH 07/11] fix(device): bound live-view memory (cap+drain+snap mode) + side-B galvo/piezo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX 1 — buffer hardening: - Add snap-loop mode (default): _grab_lightsheet_frame_sync uses snapImage()+getImage() per frame; no circular buffer, hard memory ceiling. - Continuous mode (opt-in via _ls_params["mode"]="continuous"): cap buffer with setCircularBufferMemoryFootprint(256) before start; drain with clearCircularBuffer() after each getLastImage() peek. - _stop_lightsheet_sequence_sync: always calls clearCircularBuffer() on every exit path (disconnect, error, pause, view-exit via finally block). FIX 2 — side-B galvo/piezo: - device_factory.py: defensively register scanner_b (Scanner:CD:33) and piezo_b (PiezoStage:Q:35) when present in getLoadedDevices(); skip+log on single-side rigs. - _park_lightsheet_sync: select scanner_b/piezo_b for side B, fall back to A with warning when absent; single-camera rigs unaffected. TDD: 12 tests red → green; 40 target tests pass; 0 new failures in full suite. --- gently/hardware/dispim/device_factory.py | 37 ++++ gently/hardware/dispim/device_layer.py | 137 ++++++++++-- tests/test_dual_camera_factory.py | 56 +++++ tests/test_lightsheet_buffer.py | 252 +++++++++++++++++++++++ tests/test_lightsheet_side_param.py | 131 +++++++++++- tests/test_lightsheet_streamer.py | 6 +- 6 files changed, 593 insertions(+), 26 deletions(-) create mode 100644 tests/test_lightsheet_buffer.py diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index 96b0d276..cfef7d24 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -67,6 +67,9 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non "camera_b_name": "HamCam2", "scanner_name": "Scanner:AB:33", "piezo_name": "PiezoStage:P:34", + # Side-B optics: registered defensively (absent on single-side rigs) + "scanner_b_name": "Scanner:CD:33", + "piezo_b_name": "PiezoStage:Q:35", "fdrive_name": "ZStage:V:37", "bottom_camera_name": "Bottom PCO", "led_name": "LED:X:31", @@ -129,6 +132,40 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create camera_b: %s", e) + # Defensively register the side-B galvo scanner (Scanner:CD:33). + # Absent on single-side rigs — skip + log, do not crash. + try: + scanner_b_name = cfg.get("scanner_b_name", "Scanner:CD:33") + loaded_devices = list(core.getLoadedDevices()) + if scanner_b_name in loaded_devices: + scanner_b = DiSPIMScanner(name=scanner_b_name, core=core) + devices["scanner_b"] = scanner_b + logger.info("Created scanner_b (side B): %s", scanner_b_name) + else: + logger.warning( + "scanner_b (%s) not in loaded devices — single-side rig or device absent; skipping", + scanner_b_name, + ) + except Exception as e: + logger.warning("Could not create scanner_b: %s", e) + + # Defensively register the side-B imaging piezo (PiezoStage:Q:35). + # Absent on single-side rigs — skip + log, do not crash. + try: + piezo_b_name = cfg.get("piezo_b_name", "PiezoStage:Q:35") + loaded_devices = list(core.getLoadedDevices()) + if piezo_b_name in loaded_devices: + piezo_b = DiSPIMPiezo(name=piezo_b_name, core=core) + devices["piezo_b"] = piezo_b + logger.info("Created piezo_b (side B): %s", piezo_b_name) + else: + logger.warning( + "piezo_b (%s) not in loaded devices — single-side rig or device absent; skipping", + piezo_b_name, + ) + except Exception as e: + logger.warning("Could not create piezo_b: %s", e) + try: from .devices import DiSPIMLightSource diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 69b51581..2c5127a3 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -174,7 +174,15 @@ def __init__( self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows self._ls_target_max_dim: int = 512 self._ls_jpeg_quality: int = 70 - self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + self._ls_params: dict = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + # "snap" → per-frame snapImage()+getImage() — hard memory ceiling, safe default. + # "continuous" → startContinuousSequenceAcquisition (opt-in, higher FPS). + "mode": "snap", + } self._ls_seq_started: bool = False self._ls_applied: dict = {} # last-applied galvo/piezo/exposure self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values @@ -981,6 +989,11 @@ async def _broadcast_camera(self, payload: dict[str, Any]): def _park_lightsheet_sync(self) -> None: """Park scanner galvo + imaging piezo at the current live params (static sheet). + Side 'A' drives devices["scanner"] / devices["piezo"] (Scanner:AB:33 / + PiezoStage:P:34). Side 'B' drives devices["scanner_b"] / devices["piezo_b"] + (Scanner:CD:33 / PiezoStage:Q:35) when registered. Falls back to side A + with a warning on single-side rigs so they are never broken. + Guards: - set_spim_state("Idle") fires once per stream session, not per frame (4 serial round-trips on first call → 0 on every subsequent frame). @@ -988,8 +1001,32 @@ def _park_lightsheet_sync(self) -> None: last-applied value (no-op on steady-state frames). """ p = self._ls_params - scanner = self.devices.get("scanner") - piezo = self.devices.get("piezo") + side = p.get("side", "A") + + # Resolve scanner for the requested side (with fallback to A). + if side == "B": + if "scanner_b" in self.devices: + scanner = self.devices["scanner_b"] + else: + logger.warning( + "Side B requested but scanner_b not registered; falling back to side A for scanner" + ) + scanner = self.devices.get("scanner") + else: + scanner = self.devices.get("scanner") + + # Resolve piezo for the requested side (with fallback to A). + if side == "B": + if "piezo_b" in self.devices: + piezo = self.devices["piezo_b"] + else: + logger.warning( + "Side B requested but piezo_b not registered; falling back to side A for piezo" + ) + piezo = self.devices.get("piezo") + else: + piezo = self.devices.get("piezo") + # SPIM Idle state machine: drive both devices Idle once per session. if not self._ls_spim_idle: if scanner is not None: @@ -1016,7 +1053,18 @@ def _park_lightsheet_sync(self) -> None: self._ls_parked["piezo"] = want def _ensure_lightsheet_sequence_sync(self) -> None: - """Start (or restart on exposure/side change) the continuous sequence on the SPIM camera. + """Configure (or reconfigure on exposure/side change) the SPIM camera. + + Two modes gated by ``_ls_params["mode"]``: + + "snap" (default, safe) — per-frame snapImage()+getImage(). No circular + buffer at all; memory is bounded to one frame. Sets camera device + and exposure only when a reconfiguration is needed; never calls + startContinuousSequenceAcquisition. + + "continuous" (opt-in) — startContinuousSequenceAcquisition with a hard + footprint cap (256 MB) so the buffer can't balloon on a production + box. The grab loop drains with clearCircularBuffer() after each peek. Side 'A' uses devices["camera"] (HamCam1); side 'B' uses devices["camera_b"] (HamCam2) when registered. If side B is requested but camera_b is absent, @@ -1025,10 +1073,11 @@ def _ensure_lightsheet_sequence_sync(self) -> None: core = self.system.core p = self._ls_params side = p.get("side", "A") + mode = p.get("mode", "snap") # Resolve the camera for the requested side if side == "B" and "camera_b" in self.devices: - cam = self.devices.get("camera_b") + cam = self.devices["camera_b"] else: if side == "B": logger.warning( @@ -1039,33 +1088,76 @@ def _ensure_lightsheet_sequence_sync(self) -> None: if cam is None: raise RuntimeError("No lightsheet camera configured") - need_restart = ( + need_reconfigure = ( not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] or self._ls_applied.get("side") != side ) - if need_restart: - if core.isSequenceRunning(): - core.stopSequenceAcquisition() - if core.getCameraDevice() != cam.name: - core.setCameraDevice(cam.name) - core.setExposure(cam.name, float(p["exposure"])) - core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) - self._ls_seq_started = True - self._ls_applied["exposure"] = p["exposure"] - self._ls_applied["side"] = side + + if mode == "snap": + # Snap mode: configure camera/exposure on change; no continuous sequence. + if need_reconfigure: + # Stop any lingering continuous sequence before snap takes over. + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + try: + core.clearCircularBuffer() + except Exception: + pass + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + self._ls_seq_started = True # "camera configured" flag; no sequence running + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side + else: + # Continuous mode: cap buffer footprint then start sequence. + if need_reconfigure: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + # Hard cap: prevent buffer balloon that crashed the production box. + try: + core.setCircularBufferMemoryFootprint(256) + except Exception: + logger.debug( + "setCircularBufferMemoryFootprint not available on this core", + exc_info=True, + ) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side def _grab_lightsheet_frame_sync(self): - """Park → ensure sequence running → peek the latest frame (never drain).""" + """Park → configure camera → grab one frame. + + Snap mode (default): snapImage()+getImage() — no circular buffer, + hard memory ceiling, safe for long live-view sessions. + + Continuous mode (opt-in): peeks via getLastImage() then drains the + buffer with clearCircularBuffer() to prevent it sitting full. + """ try: - self._park_lightsheet_sync() # galvo/piezo applied live - self._ensure_lightsheet_sequence_sync() # start / restart on exposure + self._park_lightsheet_sync() + self._ensure_lightsheet_sequence_sync() try: from gently.hardware.dispim.devices.acquisition import _safe_obtain except (ImportError, AttributeError): _safe_obtain = None core = self.system.core - img = core.getLastImage() + mode = self._ls_params.get("mode", "snap") + if mode == "snap": + core.snapImage() + img = core.getImage() + else: + img = core.getLastImage() + try: + core.clearCircularBuffer() + except Exception: + pass if _safe_obtain is not None: try: img = _safe_obtain(img) @@ -1082,6 +1174,11 @@ def _stop_lightsheet_sequence_sync(self) -> None: self.system.core.stopSequenceAcquisition() except Exception: logger.debug("stop lightsheet sequence failed", exc_info=True) + # Drain the buffer on every stop path (snap mode: no-op; continuous: release memory). + try: + self.system.core.clearCircularBuffer() + except Exception: + pass self._ls_seq_started = False self._ls_applied = {} # Reset park guard so the next stream session re-idles the state diff --git a/tests/test_dual_camera_factory.py b/tests/test_dual_camera_factory.py index 5476184e..7149f82e 100644 --- a/tests/test_dual_camera_factory.py +++ b/tests/test_dual_camera_factory.py @@ -105,3 +105,59 @@ def test_camera_b_absent_on_empty_device_list(): devices = create_devices_from_mmcore(core) assert "camera_b" not in devices assert "camera" in devices + + +# --------------------------------------------------------------------------- +# Side-B optics: scanner_b / piezo_b defensive registration +# --------------------------------------------------------------------------- + + +def test_scanner_b_created_when_present(): + """Scanner:CD:33 in getLoadedDevices → devices['scanner_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "Scanner:CD:33"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices, "scanner_b must be registered when Scanner:CD:33 is loaded" + assert devices["scanner_b"].name == "Scanner:CD:33" + + +def test_scanner_b_absent_when_not_loaded(): + """Scanner:CD:33 NOT loaded → scanner_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + + +def test_piezo_b_created_when_present(): + """PiezoStage:Q:35 in getLoadedDevices → devices['piezo_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" in devices, "piezo_b must be registered when PiezoStage:Q:35 is loaded" + assert devices["piezo_b"].name == "PiezoStage:Q:35" + + +def test_piezo_b_absent_when_not_loaded(): + """PiezoStage:Q:35 NOT loaded → piezo_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" not in devices + + +def test_scanner_b_piezo_b_both_present_on_dual_side_rig(): + """Full dual-side rig: both scanner_b and piezo_b created; side A unchanged.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:CD:33", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices + assert "piezo_b" in devices + # Side-A optics must still be present + assert "scanner" in devices + assert "piezo" in devices + + +def test_scanner_b_piezo_b_both_absent_on_single_side_rig(): + """Single-side rig: neither scanner_b nor piezo_b created; A-side and camera OK.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + assert "piezo_b" not in devices + assert "scanner" in devices + assert "piezo" in devices diff --git a/tests/test_lightsheet_buffer.py b/tests/test_lightsheet_buffer.py new file mode 100644 index 00000000..de9f9235 --- /dev/null +++ b/tests/test_lightsheet_buffer.py @@ -0,0 +1,252 @@ +"""Tests for live-view buffer hardening. + +Verifies: + - snap mode (default) uses snapImage()+getImage() — no circular buffer + - snap mode does NOT call startContinuousSequenceAcquisition + - continuous mode calls setCircularBufferMemoryFootprint(256) before starting + - continuous mode calls clearCircularBuffer() after each getLastImage() + - _stop_lightsheet_sequence_sync calls clearCircularBuffer() in both modes +""" +import sys +from unittest.mock import MagicMock + +import numpy as np +import pytest + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +# --------------------------------------------------------------------------- +# Full-featured FakeCore tracking every buffer-related call +# --------------------------------------------------------------------------- + + +class BufferFakeCore: + """CMMCore stub that records every buffer/snap/sequence call.""" + + def __init__(self): + self.cam = None + self.running = False + self.started = 0 + self.stopped = 0 + self.exposure = None + self.footprint_set: int | None = None # value passed to setCircularBufferMemoryFootprint + self.clear_called = 0 + self.snap_called = 0 + self.get_image_called = 0 + self.get_last_image_called = 0 + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam or "" + + def setExposure(self, n, ms): + self.exposure = ms + + def isSequenceRunning(self): + return self.running + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def setCircularBufferMemoryFootprint(self, mb: int): + self.footprint_set = mb + + def clearCircularBuffer(self): + self.clear_called += 1 + + def snapImage(self): + self.snap_called += 1 + + def getImage(self): + self.get_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + def getLastImage(self): + self.get_last_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + +class FakeAxisOffset: + def __init__(self): + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + +class FakeScanner: + def __init__(self, name): + self.name = name + self.sa_offset_y = FakeAxisOffset() + + def set_spim_state(self, state): + pass + + +class FakePiezo: + def __init__(self, name): + self.name = name + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + def set_spim_state(self, state): + pass + + +def _make_dl_buffer(mode: str = "snap") -> DeviceLayerServer: + """Build a minimal DL for buffer-hardening tests.""" + dl = DeviceLayerServer.__new__(DeviceLayerServer) + core = BufferFakeCore() + dl.system = type("S", (), {"core": core})() + cam_a = type("C", (), {"name": "HamCam1"})() + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": mode, + } + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# Snap mode — snapImage + getImage, no circular buffer +# --------------------------------------------------------------------------- + + +def test_snap_mode_calls_snapimage_and_getimage(): + """snap mode: _grab_lightsheet_frame_sync calls snapImage() + getImage().""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.snap_called >= 1, "snapImage() must be called in snap mode" + assert core.get_image_called >= 1, "getImage() must be called in snap mode" + + +def test_snap_mode_does_not_call_get_last_image(): + """snap mode: getLastImage() is NOT called (avoids the never-draining buffer).""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.get_last_image_called == 0 + + +def test_snap_mode_does_not_start_continuous_sequence(): + """snap mode: startContinuousSequenceAcquisition is never called.""" + dl = _make_dl_buffer(mode="snap") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 0, ( + "startContinuousSequenceAcquisition must not be called in snap mode" + ) + + +def test_snap_mode_does_not_start_continuous_on_grab(): + """snap mode grab loop never starts continuous acquisition.""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.started == 0 + + +# --------------------------------------------------------------------------- +# Continuous mode — buffer cap + drain +# --------------------------------------------------------------------------- + + +def test_continuous_mode_sets_circular_buffer_footprint(): + """continuous mode: setCircularBufferMemoryFootprint(256) called before start.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + core = dl.system.core + assert core.footprint_set is not None, ( + "setCircularBufferMemoryFootprint must be called in continuous mode" + ) + assert core.footprint_set == 256, ( + f"footprint must be 256 MB, got {core.footprint_set}" + ) + + +def test_continuous_mode_calls_clear_after_grab(): + """continuous mode: clearCircularBuffer() called after each getLastImage().""" + dl = _make_dl_buffer(mode="continuous") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.clear_called >= 1, ( + "clearCircularBuffer() must be called after getLastImage() in continuous mode" + ) + + +def test_continuous_mode_starts_sequence(): + """continuous mode: startContinuousSequenceAcquisition is called.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + + +# --------------------------------------------------------------------------- +# Stop sequence — clearCircularBuffer on every exit path +# --------------------------------------------------------------------------- + + +def test_stop_calls_clear_circular_buffer(): + """_stop_lightsheet_sequence_sync always calls clearCircularBuffer().""" + dl = _make_dl_buffer(mode="continuous") + # Start a sequence first so there's something to stop + dl._ensure_lightsheet_sequence_sync() + clear_before = dl.system.core.clear_called + dl._stop_lightsheet_sequence_sync() + assert dl.system.core.clear_called > clear_before, ( + "clearCircularBuffer must be called in _stop_lightsheet_sequence_sync" + ) + + +def test_stop_resets_seq_started(): + """_stop_lightsheet_sequence_sync resets _ls_seq_started to False.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_seq_started is True + dl._stop_lightsheet_sequence_sync() + assert dl._ls_seq_started is False + + +def test_stop_clears_applied_state(): + """_stop_lightsheet_sequence_sync clears _ls_applied so next start does a full restart.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_applied != {} + dl._stop_lightsheet_sequence_sync() + assert dl._ls_applied == {} diff --git a/tests/test_lightsheet_side_param.py b/tests/test_lightsheet_side_param.py index 25009334..e4679c47 100644 --- a/tests/test_lightsheet_side_param.py +++ b/tests/test_lightsheet_side_param.py @@ -68,17 +68,82 @@ def stopSequenceAcquisition(self): def isSequenceRunning(self): return self.running + def setCircularBufferMemoryFootprint(self, mb): + pass # no-op for camera-selection tests -def _make_dl(with_camera_b: bool = True) -> DeviceLayerServer: - """Build a minimal DeviceLayerServer with faked core + devices.""" + def clearCircularBuffer(self): + pass # no-op for camera-selection tests + + +class FakeAxisOffset: + """Stub for DiSPIMScanner.sa_offset_y tracking setPosition calls.""" + + def __init__(self): + self.last_pos = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + +class FakeScanner: + """Stub scanner with sa_offset_y and set_spim_state.""" + + def __init__(self, name: str): + self.name = name + self.sa_offset_y = FakeAxisOffset() + self.spim_state = None + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +class FakePiezo: + """Stub piezo tracking setPosition and set_spim_state calls.""" + + def __init__(self, name: str): + self.name = name + self.last_pos = None + self.spim_state = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +def _make_dl( + with_camera_b: bool = True, + with_scanner_b: bool = False, + with_piezo_b: bool = False, +) -> DeviceLayerServer: + """Build a minimal DeviceLayerServer with faked core + devices. + + mode is set to "continuous" so existing sequence-start assertions remain + meaningful (snap mode is the production default but tested separately). + """ dl = DeviceLayerServer.__new__(DeviceLayerServer) dl.system = type("S", (), {"core": FakeCore()})() cam_a = type("C", (), {"name": "HamCam1"})() cam_b = type("C", (), {"name": "HamCam2"})() - dl.devices = {"camera": cam_a} + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } if with_camera_b: dl.devices["camera_b"] = cam_b - dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + if with_scanner_b: + dl.devices["scanner_b"] = FakeScanner("Scanner:CD:33") + if with_piezo_b: + dl.devices["piezo_b"] = FakePiezo("PiezoStage:Q:35") + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": "continuous", # explicit — snap mode tested in test_lightsheet_buffer.py + } dl._ls_seq_started = False dl._ls_applied = {} dl._ls_interval_sec = 0.0 @@ -221,3 +286,61 @@ async def test_handle_get_cameras_without_camera_b(): resp = await dl.handle_get_cameras(req) body = json.loads(resp.body) assert body["cameras"] == ["A"] + + +# --------------------------------------------------------------------------- +# _park_lightsheet_sync — side selects correct scanner/piezo +# --------------------------------------------------------------------------- + + +def test_side_a_parks_scanner_a(): + """Side A → _park_lightsheet_sync drives scanner A's galvo.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_b_parks_scanner_b(): + """Side B with scanner_b present → _park_lightsheet_sync drives scanner B.""" + dl = _make_dl(with_scanner_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["scanner_b"].sa_offset_y.last_pos is not None + # Side A scanner must be untouched + assert dl.devices["scanner"].sa_offset_y.last_pos is None + + +def test_side_b_scanner_b_absent_falls_back_to_scanner_a(): + """Side B without scanner_b → graceful fallback to scanner A, no crash.""" + dl = _make_dl(with_scanner_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + # Fallback: scanner A gets the park + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_a_parks_piezo_a(): + """Side A → _park_lightsheet_sync drives piezo A.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None + + +def test_side_b_parks_piezo_b(): + """Side B with piezo_b present → _park_lightsheet_sync drives piezo B.""" + dl = _make_dl(with_scanner_b=True, with_piezo_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo_b"].last_pos is not None + # Side A piezo must be untouched + assert dl.devices["piezo"].last_pos is None + + +def test_side_b_piezo_b_absent_falls_back_to_piezo_a(): + """Side B without piezo_b → graceful fallback to piezo A, no crash.""" + dl = _make_dl(with_piezo_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py index c36377a1..9091ea13 100644 --- a/tests/test_lightsheet_streamer.py +++ b/tests/test_lightsheet_streamer.py @@ -64,7 +64,8 @@ async def test_grab_parks_and_peeks(monkeypatch): dl = _make_dl() dl._state_pause_counter = 0 dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 - dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0} + # Explicit continuous mode: this test verifies the sequence-start + getLastImage path. + dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0, "mode": "continuous"} dl._ls_seq_started = False; dl._ls_applied = {} dl._ls_interval_sec = 0.0 img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) @@ -79,7 +80,8 @@ async def test_exposure_change_restarts_sequence(): dl = _make_dl() dl._state_pause_counter = 0 dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 - dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0} + # Explicit continuous mode: this test verifies restart on exposure change. + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0, "mode": "continuous"} dl._ls_seq_started = False; dl._ls_applied = {} dl._ls_interval_sec = 0.0 await asyncio.to_thread(dl._grab_lightsheet_frame_sync) From 2441bb2697d954a61a718eb89c761fd036567071 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:19:36 +0530 Subject: [PATCH 08/11] feat(manual): timelapse config as an accordion with active/inactive section states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the flat timelapse panel into 3 independently collapsible accordion sections — Schedule (open by default), Targets, Volume geometry. Active/inactive state: - Each section header starts muted (hollow 5-px dot, dimmed label). - Any user input/change event in a section flips it active: dot fills accent cyan, label turns accent, a one-line summary appears inline. - Summaries: "60 s · 5 frames" / "all · Idle (plain imaging)" / "80 sl · 10 ms · ALL OFF". - The outer Timelapse panel header shows an accent dot once any section is set; the Start Timelapse button turns accent-bordered when ready. - Re-entering the manual view (initTlForm) resets all touched state and clears visual active state. Payload unchanged: all field IDs preserved, POST body identical. node --check: exit 0. --- gently/ui/web/static/css/main.css | 132 ++++++++++++++++ gently/ui/web/static/js/devices.js | 121 +++++++++++++-- gently/ui/web/templates/index.html | 235 ++++++++++++++++------------- 3 files changed, 366 insertions(+), 122 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 9215466a..7e04b232 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10937,3 +10937,135 @@ body.modal-open { } .ls-tl-status[hidden] { display: none; } + +/* ── Timelapse accordion (active/inactive section states — B3 UX) ─────────── */ + +/* Wrapper for each accordion section */ +.ls-acc-section { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--map-rule-soft); + padding-bottom: 0.12rem; + margin-bottom: 0.08rem; +} +.ls-acc-section:last-of-type { + border-bottom: none; + margin-bottom: 0; +} + +/* Section header button */ +.ls-acc-head { + display: flex; + align-items: center; + gap: 0.32rem; + background: transparent; + border: none; + padding: 0.26rem 0; + cursor: pointer; + width: 100%; + text-align: left; + font-family: inherit; +} + +/* Status dot — hollow/muted when inactive */ +.ls-acc-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid var(--map-ink-mute); + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} + +/* Section label */ +.ls-acc-title { + font-size: 0.575rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: color 0.15s; +} + +/* One-line summary — shown only when section is touched */ +.ls-acc-summary { + flex: 1; + font-size: 0.57rem; + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.02em; + color: var(--map-ink-mute); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.ls-acc-summary[hidden] { display: none; } + +/* Caret arrow */ +.ls-acc-arrow { + font-size: 0.44rem; + color: var(--map-ink-mute); + flex-shrink: 0; + margin-left: auto; + transition: transform 0.15s, color 0.15s; +} +.ls-acc-head[aria-expanded="true"] .ls-acc-arrow { + transform: rotate(90deg); +} + +/* ── Active / touched state ────────────────────────────────────────────────── */ +.ls-acc-head.is-active .ls-acc-dot { + background: var(--map-accent); + border-color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-title { + color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-summary { + color: var(--map-accent); + opacity: 0.85; +} +.ls-acc-head.is-active .ls-acc-arrow { + color: var(--map-accent); +} + +/* Section body */ +.ls-acc-body { + display: flex; + flex-direction: column; + gap: 0.26rem; + padding: 0.18rem 0 0.32rem 0.5rem; +} +.ls-acc-body[hidden] { display: none; } + +/* Outer timelapse panel header dot — signals any section is set */ +.ls-tl-outer-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} +.ls-tl-outer-dot.is-active { + background: var(--map-accent); + border-color: var(--map-accent); +} + +/* Start button reads "ready" (accent) once any section is configured */ +.ls-tl-start-btn { + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ls-tl-start-btn.is-ready { + border-color: var(--map-accent); + color: var(--map-accent); + background: rgba(34, 211, 238, 0.08); +} + +/* Submit row — give the button its full row width */ +.ls-tl-submit-row { + margin-top: 0.3rem; +} diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 2319d9b2..67f8ca5d 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -113,6 +113,8 @@ const DevicesManager = (function () { let _tlEmbryos, _tlMode; let _tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr, _tlLaser; let _tlStart, _tlStatus, _tlStatusText; + // Accordion active-state per section: { sched, targets, geom } + let _tlTouched = { sched: false, targets: false, geom: false }; // Room-light toggle (header). Drives the SwitchBot Bot that switches the // diSPIM room light. State is the bot's cached on/off; hidden until the @@ -1486,12 +1488,71 @@ const DevicesManager = (function () { // Timelapse config form (Manual view) // ===================================================================== - /** Wire the timelapse panel's collapsible toggle and stop-condition selector. - * Safe to call multiple times (idempotent via re-assignment of event handlers). */ + // ── Accordion section summary builders ─────────────────────────────────── + + function _tlSchedSummary() { + const interval = (_tlInterval && _tlInterval.value) ? _tlInterval.value : '120'; + const stop = (_tlStop && _tlStop.value) ? _tlStop.value : 'manual'; + const condVal = (_tlCondVal && _tlCondVal.value) ? _tlCondVal.value : '10'; + if (stop === 'timepoints') return `${interval} s · ${condVal} frames`; + if (stop === 'duration') return `${interval} s · ${condVal} h`; + return `${interval} s · manual`; + } + + function _tlTargetsSummary() { + const embryos = (_tlEmbryos && _tlEmbryos.value.trim()) + ? _tlEmbryos.value.trim() + : 'all'; + const modeEl = _tlMode; + const modeText = (modeEl && modeEl.value) + ? modeEl.options[modeEl.selectedIndex].text + : 'none'; + return `${embryos} · ${modeText}`; + } + + function _tlGeomSummary() { + const slices = (_tlSlices && _tlSlices.value) ? _tlSlices.value : '50'; + const exposure = (_tlExposure && _tlExposure.value) ? _tlExposure.value : '10'; + const laser = (_tlLaser && _tlLaser.value) ? _tlLaser.value : 'ALL OFF'; + return `${slices} sl · ${exposure} ms · ${laser}`; + } + + /** Update a section's header active state and summary text, then sync the + * outer panel dot and the start button. sec = 'sched'|'targets'|'geom'. */ + function _tlUpdateSection(sec) { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const summary = document.getElementById(`devices-tlacc-${sec}-sum`); + const touched = _tlTouched[sec]; + + if (head) head.classList.toggle('is-active', touched); + if (summary) { + summary.hidden = !touched; + if (touched) { + if (sec === 'sched') summary.textContent = _tlSchedSummary(); + else if (sec === 'targets') summary.textContent = _tlTargetsSummary(); + else if (sec === 'geom') summary.textContent = _tlGeomSummary(); + } + } + + // Outer panel dot + start button "ready" state + const anyActive = Object.values(_tlTouched).some(Boolean); + const outerDot = document.getElementById('devices-tl-outer-dot'); + if (outerDot) outerDot.classList.toggle('is-active', anyActive); + if (_tlStart) _tlStart.classList.toggle('is-ready', anyActive); + } + + /** Wire the timelapse panel: outer collapsible toggle, accordion section + * toggles, touch listeners, and the submit button. + * Safe to call multiple times (re-assigns handlers idempotently). */ function initTlForm() { cacheDom(); - // Collapsible head + // Reset touched state on each init (re-entering the manual view = fresh) + _tlTouched = { sched: false, targets: false, geom: false }; + // Clear any leftover active-state visuals from a previous visit + ['sched', 'targets', 'geom'].forEach(sec => _tlUpdateSection(sec)); + + // ── Outer collapsible toggle ────────────────────────────────────────── if (_tlToggle && _tlBody) { _tlToggle.onclick = () => { const open = _tlBody.hidden; @@ -1502,22 +1563,52 @@ const DevicesManager = (function () { }; } - // Conditional stop-value row: show for timepoints / duration, hide for manual - if (_tlStop && _tlCondRow) { - _tlStop.onchange = () => { + // ── Accordion section toggles ───────────────────────────────────────── + ['sched', 'targets', 'geom'].forEach(sec => { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const body = document.getElementById(`devices-tlacc-${sec}-body`); + if (!head || !body) return; + head.onclick = () => { + const open = body.hidden; + body.hidden = !open; + head.setAttribute('aria-expanded', String(open)); + const arrow = head.querySelector('.ls-acc-arrow'); + if (arrow) arrow.textContent = open ? '▼' : '▶'; + }; + }); + + // ── Touch listeners ─────────────────────────────────────────────────── + const markTouched = sec => { + _tlTouched[sec] = true; + _tlUpdateSection(sec); + }; + + // Schedule — interval and stop condition drive summary; cond-row visibility unchanged + [_tlInterval, _tlCondVal].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('sched')); + }); + if (_tlStop) { + _tlStop.addEventListener('change', () => { const v = _tlStop.value; const show = v === 'timepoints' || v === 'duration'; - _tlCondRow.hidden = !show; - if (_tlCondLabel) { - _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; - } - }; + if (_tlCondRow) _tlCondRow.hidden = !show; + if (_tlCondLabel) _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; + markTouched('sched'); + }); } - // Submit button - if (_tlStart) { - _tlStart.onclick = startTimelapse; - } + // Targets + if (_tlEmbryos) _tlEmbryos.addEventListener('input', () => markTouched('targets')); + if (_tlMode) _tlMode.addEventListener('change', () => markTouched('targets')); + + // Volume geometry + [_tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('geom')); + }); + if (_tlLaser) _tlLaser.addEventListener('change', () => markTouched('geom')); + + // ── Submit ──────────────────────────────────────────────────────────── + if (_tlStart) _tlStart.onclick = startTimelapse; } /** Populate timelapse volume-geometry defaults from GET /api/devices/scan_geometry, diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 55090248..b069c156 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -849,127 +849,148 @@

Properties

- +