diff --git a/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md b/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md new file mode 100644 index 00000000..d1a4abd9 --- /dev/null +++ b/docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md @@ -0,0 +1,57 @@ +# Lightsheet live-view FPS measurement + transport decision (B1 Task 7) + +Status: **deferred to the rig** — the numbers below must be filled in on the microscope +(the streamer needs the real `pymmcore` core, SPIM camera, and rpyc transport; it cannot run +on the Linux dev box). This file is the measurement protocol + the decision gate. + +## What to measure + +With the Manual view open and lightsheet live running, record three rates: + +| Metric | Where to read it | +|---|---| +| **device grab rate** (frames peeked/encoded /s) | device-layer log / instrument `_lightsheet_streamer` | +| **delivered rate** (frames broadcast /s) | device-layer `_broadcast_lightsheet` | +| **browser paint rate** | the Manual-view FPS readout (`computeLightsheetFps`) | + +Record at two resolution/quality settings: +- default **512 px / JPEG q70** (`_ls_target_max_dim=512`, `_ls_jpeg_quality=70`) +- reduced **384 px / q60** + +Note the exposure used (the peek floor is `max(exposure, 1/30 s)`). + +## Target + +**≥ ~15 fps usable for focus** (stretch 25–30), end-to-end latency < ~150 ms. + +## Diagnosis rule (the gate) + +- **device grab < target** → limiter is exposure / readout / rpyc, **not** transport. Tune + exposure, the 512 px size, and JPEG quality. A binary transport path will NOT help — stop here. +- **device grab ≥ target but browser paint < target** → transport is the bottleneck → build the + **binary WebSocket path** (below). + +## Conditional escalation — binary WebSocket path + +Only if the diagnosis points to transport. The current path is base64-JPEG-in-JSON over SSE → +EventBus → `ConnectionManager.broadcast` (`json.dumps` + `send_text`) — `connection_manager.py` +has **no `send_bytes` path** (confirmed). Escalation, on the **agent→browser hop** (where cost +multiplies per client): + +- push raw JPEG bytes via `websocket.send_bytes(prefix + jpeg)` (a 1-byte type tag identifies a + lightsheet frame), bypassing **base64 (+33%)**, the **per-client `json.dumps`**, and the + **EventBus fan-out**; +- browser `onmessage` (binary) → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`; +- the device→agent SSE stays as-is (single consumer = the monitor, so its base64 cost is paid + once, not per browser). + +Re-measure after building; record the before/after numbers here. + +## Results (fill in on the rig) + +| setting | device grab fps | delivered fps | browser paint fps | exposure | notes | +|---|---|---|---|---|---| +| 512px/q70 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | | +| 384px/q60 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | | + +Decision: _TBD (transport bottleneck? build binary path Y/N)_ diff --git a/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md new file mode 100644 index 00000000..8c84eeeb --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md @@ -0,0 +1,728 @@ +# Manual Mode — SPIM Live View (B1) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A Manual view in the Devices tab with a continuous SPIM single-slice brightfield live view (galvo/piezo/exposure controlled live), brightfield illumination, temperature, and by-hand burst/volume triggers — the hand-driven surface for next week's temperature-strain experiments. + +**Architecture:** A device-layer lightsheet live streamer (MMCore continuous sequence acquisition + peek-latest, parked galvo/piezo), bridged to the browser by a `LightSheetStreamMonitor` (EventBus `LIGHTSHEET_FRAME`) over the existing base64-JPEG/SSE→WS transport; `require_control` FastAPI proxy routes; a Manual view that mirrors the bottom-camera panel. FPS is measured on the rig; a binary transport path is a conditional follow-up. + +**Tech Stack:** Python asyncio + aiohttp (device layer), FastAPI (viz proxy), `pymmcore.CMMCore`, the project EventBus, vanilla-JS + canvas/SVG frontend (no build step), pytest (`asyncio_mode=auto`). + +## Global Constraints + +- **No new dependency.** Reuse `_encode_frame_for_stream` (OpenCV already present), the bottom-camera streamer pattern, A's portable temperature graph. +- **Single SPIM camera** today: `self.devices.get("camera")` (`HamCam1`). No side-A/B selector in B1 (deferred to B2). +- **Live = continuous sequence acquisition**, never a snap loop: `core.startContinuousSequenceAcquisition(0)` → peek `core.getLastImage()` (NEVER `popNextImage` — don't drain) → `stopSequenceAcquisition()` on exit. The core handle is `self.system.core` in the device layer; unwrap rpyc frames with `_safe_obtain`. +- **Park** before/under live: `piezo.setPosition(z)`, `scanner.sa_offset_y.setPosition(deg)` (or `scanner.set_y_offset(deg)`), `scanner.set_spim_state("Idle")`, `piezo.set_spim_state("Idle")`. galvo/piezo updates apply live (no restart); exposure change → stop→`setExposure`→restart. +- **Brightfield safety:** laser forced off in manual live (`setConfig(laser_group, "ALL OFF")` / `set_laser_power(...,0)`). +- **Concurrency:** the streamer honors `self._state_pause_counter > 0` (heavy plan owns MMCore → back off / stop sequence). Only one live stream at a time. +- **Lightsheet stream resolution/quality:** its own config — default `_ls_target_max_dim = 512`, `_ls_jpeg_quality = 70` (higher than the bottom-camera 360/55 thumbnail, for focus). +- **Transport stays JSON/`send_text`** (no binary path exists); a binary hop is Task 8, conditional on the FPS measurement. +- **Auth:** browser-facing writes are FastAPI proxy routes guarded by `Depends(require_control)`; device-layer aiohttp routes have no auth, so the browser must go through the proxy. +- **Tests:** `pytest`; `file_store`-style fixtures; FastAPI `TestClient` + mock client for routes; a fake core for the streamer. Frontend has no JS unit harness → `node --check` + Chrome-MCP harness + UI audit. Much of B1 needs the real rig; off-rig we cover streamer (fake core), routes, client, frontend harness, and defer live/FPS verification. + +--- + +### Task 1: `LIGHTSHEET_FRAME` event type + frontend exclusion + +**Files:** +- Modify: `gently/core/event_bus.py` (enum near `:88`; `_NO_HISTORY_TYPES` near `:186-195`) +- Modify: `gently/ui/web/static/js/websocket.js` (exclusion guard `:104-107`) +- Test: `tests/test_lightsheet_event.py` + +**Interfaces:** +- Produces: `EventType.LIGHTSHEET_FRAME` (declared with `auto()`, matching `BOTTOM_CAMERA_FRAME`), in `_NO_HISTORY_TYPES`. Wire serialization uses `.name` (so the browser receives `"LIGHTSHEET_FRAME"`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_event.py +from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES + +def test_lightsheet_frame_event_exists(): + assert EventType.LIGHTSHEET_FRAME.name == "LIGHTSHEET_FRAME" + +def test_lightsheet_frame_excluded_from_history(): + assert EventType.LIGHTSHEET_FRAME in _NO_HISTORY_TYPES + +def test_lightsheet_frame_publishes_to_subscriber(): + bus = EventBus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.LIGHTSHEET_FRAME, data={"jpeg_b64": "x"}, source="t") + assert seen == [{"jpeg_b64": "x"}] +``` + +> Confirm `EventBus.subscribe` signature / `_NO_HISTORY_TYPES` exportability against the real file; adapt the import/subscribe if needed, keep the assertions. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_lightsheet_event.py -v` +Expected: FAIL — `AttributeError: LIGHTSHEET_FRAME` + +- [ ] **Step 3: Write minimal implementation** + +In `gently/core/event_bus.py`, add the member next to `BOTTOM_CAMERA_FRAME` (`:88`): +```python + LIGHTSHEET_FRAME = auto() # Live JPEG frame from the SPIM lightsheet live stream +``` +Add to `_NO_HISTORY_TYPES` (next to `BOTTOM_CAMERA_FRAME`): +```python + EventType.LIGHTSHEET_FRAME, # high-volume live frames — keep out of history +``` +In `gently/ui/web/static/js/websocket.js`, extend the exclusion guard (`:104-107`) so the frame skips the Events table but still reaches `ClientEventBus.emit`: +```javascript + if (msg.event_type !== 'DEVICE_STATE_UPDATE' && + msg.event_type !== 'BOTTOM_CAMERA_FRAME' && + msg.event_type !== 'TEMPERATURE_UPDATE' && + msg.event_type !== 'LIGHTSHEET_FRAME') { +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_lightsheet_event.py -v` (3 passed) and `node --check gently/ui/web/static/js/websocket.js` (exit 0) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/event_bus.py gently/ui/web/static/js/websocket.js tests/test_lightsheet_event.py +git commit -m "feat(manual-mode): add LIGHTSHEET_FRAME event type + frontend exclusion" +``` + +--- + +### Task 2: Device-layer lightsheet live streamer (continuous sequence acquisition) + +**Files:** +- Modify: `gently/hardware/dispim/device_layer.py` — add config attrs (near `:160-169`), `_park_lightsheet_sync`, `_grab_lightsheet_frame_sync`, `_lightsheet_streamer`, `_broadcast_lightsheet`, `handle_lightsheet_stream`, `handle_lightsheet_params`; register two routes (near `:2806`). +- Test: `tests/test_lightsheet_streamer.py` +- Reference (mirror): `_bottom_camera_streamer`/`_broadcast_camera`/`handle_bottom_camera_stream`/`_encode_frame_for_stream` (same file); sequence-acq calls in `devices/acquisition.py:186-254`. + +**Interfaces:** +- Consumes: `self.system.core` (`pymmcore.CMMCore`), `self.devices["camera"]` / `["scanner"]` / `["piezo"]`, `self._state_pause_counter`, `self._encode_frame_for_stream` (reused verbatim), `_safe_obtain` (rpyc unwrap, imported as in `acquisition.py`). +- Produces: SSE `GET /api/lightsheet/stream`; `POST /api/lightsheet/live/params` `{galvo, piezo, exposure}`; in-process live param state `self._ls_params`. + +> **Implementer confirmations (cannot be verified off-rig — confirm against the real code, do not guess silently):** +> 1. The device-layer core handle (`self.system.core`) and that `pymmcore.CMMCore` exposes `startContinuousSequenceAcquisition(float)`, `getLastImage()`, `stopSequenceAcquisition()`, `isSequenceRunning()` (standard CMMCore API). If `getLastImage` is unavailable, use `getLastImageMD`/`getNBeforeLastImage`. +> 2. The scanner/piezo park calls: `self.devices["scanner"].sa_offset_y.setPosition(deg)` and `self.devices["piezo"].setPosition(z)`, and `set_spim_state("Idle")` on both (from `devices/scanner.py:271`, `devices/piezo.py:226`, recon §3). +> 3. Whether brightfield live needs the scanner/beam enabled or a static park — confirm against `../micro-manager/plugins/ASIdiSPIM/src/main/java/org/micromanager/asidispim/SetupPanel.java`. Default: static park, laser "ALL OFF". + +- [ ] **Step 1: Write the failing test (fake core + fake devices)** + +```python +# tests/test_lightsheet_streamer.py +import asyncio, numpy as np, pytest +from gently.hardware.dispim.device_layer import DeviceLayer # confirm class name/import + +class FakeCore: + def __init__(self): + self.running = False; self.exposure = None; self.cam = None + self._frame = np.full((64, 64), 1000, dtype=np.uint16) + self.started = 0; self.stopped = 0 + 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 getLastImage(self): return self._frame + +class FakeAxis: + def __init__(self): self.pos = None + def setPosition(self, v): self.pos = v + +class FakeScanner: + def __init__(self): self.sa_offset_y = FakeAxis(); self.name="Scanner"; self.state=None + def set_spim_state(self, s): self.state = s + +class FakePiezo(FakeAxis): + def __init__(self): super().__init__(); self.name="Piezo"; self.state=None + def set_spim_state(self, s): self.state = s + +def _streamer(dl): + dl.system = type("S", (), {"core": FakeCore()})() + dl.devices = {"camera": type("C", (), {"name": "HamCam1"})(), + "scanner": FakeScanner(), "piezo": FakePiezo()} + return dl + +async def test_grab_parks_and_peeks(monkeypatch): + dl = _streamer(DeviceLayer.__new__(DeviceLayer)) + 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} + dl._ls_seq_started = False; dl._ls_applied = {} + img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert img is not None and img.shape == (64, 64) + assert dl.system.core.running is True # sequence started + assert dl.devices["piezo"].pos == 40.0 # piezo parked + assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked + +async def test_exposure_change_restarts_sequence(): + dl = _streamer(DeviceLayer.__new__(DeviceLayer)) + 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} + dl._ls_seq_started = False; dl._ls_applied = {} + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + starts = dl.system.core.started + dl._ls_params["exposure"] = 30.0 # exposure change + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1 + assert dl.system.core.exposure == 30.0 +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_lightsheet_streamer.py -v` +Expected: FAIL — `AttributeError: _grab_lightsheet_frame_sync` (or import). + +- [ ] **Step 3: Implement the streamer** + +Add config attrs in `__init__` (near `:169`, after the `_cam_*` block): +```python + # Lightsheet (SPIM) live stream — continuous sequence acquisition. + self._ls_subscribers: list[asyncio.Queue] = [] + self._ls_task: asyncio.Task | None = None + 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_seq_started: bool = False + self._ls_applied: dict = {} # last-applied galvo/piezo/exposure +``` + +Add the grab/park/peek (mirrors the sequence-acq calls from `acquisition.py`; uses `self.system.core`): +```python + def _park_lightsheet_sync(self) -> None: + """Park scanner galvo + imaging piezo at the current live params (static sheet).""" + p = self._ls_params + scanner = self.devices.get("scanner") + piezo = self.devices.get("piezo") + if scanner is not None: + try: scanner.set_spim_state("Idle") + except Exception: pass + scanner.sa_offset_y.setPosition(float(p["galvo"])) + if piezo is not None: + try: piezo.set_spim_state("Idle") + except Exception: pass + piezo.setPosition(float(p["piezo"])) + + def _ensure_lightsheet_sequence_sync(self) -> None: + """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" + core = self.system.core + 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"] + ) + 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"] + + def _grab_lightsheet_frame_sync(self): + """Park → ensure sequence running → peek the latest frame (never drain).""" + try: + self._park_lightsheet_sync() # galvo/piezo applied live + self._ensure_lightsheet_sequence_sync() # start / restart on exposure + from gently.hardware.dispim.devices.acquisition import _safe_obtain + core = self.system.core + img = core.getLastImage() + try: img = _safe_obtain(img) + except (ImportError, AttributeError): pass + return np.asarray(img) + except Exception as exc: + logger.debug("Lightsheet grab failed: %s", exc) + return None + + def _stop_lightsheet_sequence_sync(self) -> None: + try: + if self.system.core.isSequenceRunning(): + self.system.core.stopSequenceAcquisition() + except Exception: + logger.debug("stop lightsheet sequence failed", exc_info=True) + self._ls_seq_started = False + self._ls_applied = {} +``` + +Add the streamer loop + broadcast (mirror `_bottom_camera_streamer`/`_broadcast_camera`, reusing `_encode_frame_for_stream`): +```python + async def _lightsheet_streamer(self): + logger.info("Lightsheet streamer started") + try: + while self._ls_subscribers: + if self._state_pause_counter > 0: + # Heavy plan owns MMCore: release the sequence and back off. + if self._ls_seq_started: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + await asyncio.sleep(0.1) + continue + tick = time.monotonic() + img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) + payload = self._encode_frame_for_stream(img) if img is not None else None + if payload is not None: + await self._broadcast_lightsheet(payload) + elapsed = time.monotonic() - tick + # Pace to at least the exposure; peek-rate caps near the camera rate. + floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) + await asyncio.sleep(max(0.0, floor - elapsed)) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Lightsheet streamer crashed") + finally: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + logger.info("Lightsheet streamer exiting") + + async def _broadcast_lightsheet(self, payload): + if not self._ls_subscribers: + return + dead = [] + for q in self._ls_subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + _ = q.get_nowait(); q.put_nowait(payload) + except Exception: + dead.append(q) + for q in dead: + try: self._ls_subscribers.remove(q) + except ValueError: pass +``` + +> `_encode_frame_for_stream` uses `self._cam_target_max_dim`/`self._cam_jpeg_quality`. To get the 512/70 lightsheet settings without duplicating the encoder, add an optional override: change its signature to `_encode_frame_for_stream(self, img, max_dim=None, quality=None)` defaulting to the `_cam_*` values, and call it `self._encode_frame_for_stream(img, self._ls_target_max_dim, self._ls_jpeg_quality)` from the lightsheet loop. (One-line change to the encoder; bottom-camera behavior unchanged.) + +Add the SSE handler + params handler (mirror `handle_bottom_camera_stream`; the params handler updates `self._ls_params` — galvo/piezo apply on the next grab, exposure triggers the restart path): +```python + async def handle_lightsheet_stream(self, request): + response = web.StreamResponse(status=200, reason="OK", headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache", + "Connection": "keep-alive", "X-Accel-Buffering": "no"}) + await response.prepare(request) + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + self._ls_subscribers.append(queue) + if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): + self._ls_task = asyncio.create_task(self._lightsheet_streamer(), name="lightsheet-streamer") + try: + await response.write(b": connected\n\n") + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=10.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n"); continue + if payload is None: break + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + pass + except Exception: + logger.exception("Lightsheet SSE writer failed") + finally: + try: self._ls_subscribers.remove(queue) + except ValueError: pass + return response + + async def handle_lightsheet_params(self, request): + 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]) + return web.json_response({"params": self._ls_params}) +``` + +Register both routes near `:2806`: +```python + 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) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_lightsheet_streamer.py -v` +Expected: PASS (2 passed). If `DeviceLayer.__new__` bypassing `__init__` leaves attrs unset, the test sets the needed ones explicitly (it does). + +- [ ] **Step 5: Commit** + +```bash +git add gently/hardware/dispim/device_layer.py tests/test_lightsheet_streamer.py +git commit -m "feat(manual-mode): device-layer lightsheet live streamer (continuous sequence acquisition)" +``` + +--- + +### Task 3: Client methods — `stream_lightsheet` + `set_lightsheet_live_params` + +**Files:** +- Modify: `gently/hardware/dispim/client.py` (add two methods on `DiSPIMMicroscope`, near `stream_bottom_camera` `:913`) +- Test: `tests/test_lightsheet_client.py` + +**Interfaces:** +- Produces: `async def stream_lightsheet(self, timeout=None)` (async generator over `GET /api/lightsheet/stream`, identical SSE parse to `stream_bottom_camera`); `async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict` (`POST /api/lightsheet/live/params`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_client.py +import pytest +from gently.hardware.dispim.client import DiSPIMMicroscope + +async def test_set_params_posts_body(monkeypatch): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + async def fake_post(path, body): + sent["path"] = path; sent["body"] = body; return {"params": body} + m._api_post = fake_post # confirm the real low-level POST helper name + res = await m.set_lightsheet_live_params(galvo=1.0, piezo=42.0, exposure=15.0) + assert sent["path"] == "/api/lightsheet/live/params" + assert sent["body"] == {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0} + assert res == {"params": {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0}} + +def test_stream_lightsheet_is_async_generator(): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + import inspect + assert inspect.isasyncgenfunction(m.stream_lightsheet) +``` + +> Confirm the real low-level POST helper (the recon shows `set_led` etc. POST via an internal helper — find whether it's `self._api_post(path, body)` or an inline `self._session.post`). Match it; if `set_lightsheet_live_params` should drop `None` keys, build the body from only the provided args (the test passes all three). + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_lightsheet_client.py -v` → FAIL (no such methods). + +- [ ] **Step 3: Implement** + +```python + async def stream_lightsheet(self, timeout: float | None = None): + """Async generator yielding JPEG frames from the lightsheet live SSE stream. + + Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. + """ + self._ensure_connected() + client_timeout = aiohttp.ClientTimeout(total=None, sock_read=timeout, sock_connect=10.0) + url = f"{self.http_url}/api/lightsheet/stream" + async with self._session.get(url, timeout=client_timeout) as resp: + resp.raise_for_status() + buf = b"" + async for chunk in resp.content.iter_any(): + if not chunk: + continue + buf += chunk + while b"\n\n" in buf: + event_block, buf = buf.split(b"\n\n", 1) + data_lines = [] + for line in event_block.splitlines(): + if not line or line.startswith(b":"): + continue + if line.startswith(b"data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + raw = b"\n".join(data_lines).decode("utf-8", errors="replace") + try: + import json as _json + yield _json.loads(raw) + except Exception as exc: + logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) + + async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict: + """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + body = {} + if galvo is not None: body["galvo"] = float(galvo) + if piezo is not None: body["piezo"] = float(piezo) + if exposure is not None: body["exposure"] = float(exposure) + return await self._api_post("/api/lightsheet/live/params", body) +``` + +> If the real POST helper isn't `_api_post`, adapt this one call site (and the test) to the real helper. `stream_lightsheet` copies `stream_bottom_camera` verbatim except the URL. + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_lightsheet_client.py -v` (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/hardware/dispim/client.py tests/test_lightsheet_client.py +git commit -m "feat(manual-mode): client stream_lightsheet + set_lightsheet_live_params" +``` + +--- + +### Task 4: `LightSheetStreamMonitor` (agent Service) + agent wiring + +**Files:** +- Create: `gently/app/lightsheet_monitor.py` +- Modify: `gently/app/agent.py` (init attr; construct in `start_viz_server` near the bottom-camera monitor `:849-860`; stop in `stop_viz_server` near `:862-869`) +- Test: `tests/test_lightsheet_monitor.py` +- Reference (mirror verbatim, swapping names/event): `gently/app/bottom_camera_monitor.py` + +**Interfaces:** +- Consumes: `microscope.stream_lightsheet()` (Task 3); `EventType.LIGHTSHEET_FRAME` (Task 1). +- Produces: `LightSheetStreamMonitor(Service)` with `running` property, `on_start`/`on_stop`; publishes `LIGHTSHEET_FRAME`. `agent.lightsheet_monitor` (constructed, not started; started via proxy in Task 5). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lightsheet_monitor.py +import asyncio +from gently.core.event_bus import EventType, get_event_bus +from gently.app.lightsheet_monitor import LightSheetStreamMonitor + +class FakeScope: + async def stream_lightsheet(self): + for i in range(3): + yield {"t": float(i), "jpeg_b64": f"f{i}"} + await asyncio.sleep(0) + +async def test_monitor_publishes_frames(): + bus = get_event_bus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + mon = LightSheetStreamMonitor(FakeScope(), reconnect_delay_sec=0.01) + await mon.start() + await asyncio.sleep(0.05) + await mon.stop() + assert any(d.get("jpeg_b64") == "f0" for d in seen) + assert mon.running is False +``` + +- [ ] **Step 2: Run to verify it fails** — `pytest tests/test_lightsheet_monitor.py -v` → FAIL (no module). + +- [ ] **Step 3: Implement** — copy `gently/app/bottom_camera_monitor.py` to `gently/app/lightsheet_monitor.py` and change: class → `LightSheetStreamMonitor`; `name="lightsheet-monitor"`; the `_run` loop calls `self.microscope.stream_lightsheet()` and publishes `EventType.LIGHTSHEET_FRAME` with `source="lightsheet-monitor"`. (Everything else — Service base, on_start/on_stop, reconnect loop, `_last_frame_ts`, `running` — is identical.) + +Agent wiring in `gently/app/agent.py`: add `self.lightsheet_monitor = None` next to `self.bottom_camera_monitor = None`; in `start_viz_server` (after the bottom-camera monitor construction `:849-860`): +```python + if self.microscope is not None and self.lightsheet_monitor is None: + try: + from .lightsheet_monitor import LightSheetStreamMonitor + self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) + logger.info("Lightsheet monitor ready (not started)") + except Exception as e: + logger.warning(f"Failed to construct lightsheet monitor: {e}") + self.lightsheet_monitor = None +``` +In `stop_viz_server` (near `:862`): +```python + if self.lightsheet_monitor is not None: + try: + await self.lightsheet_monitor.stop() + except Exception: + logger.exception("Failed to stop lightsheet monitor") + self.lightsheet_monitor = None +``` + +- [ ] **Step 4: Run to verify it passes** — `pytest tests/test_lightsheet_monitor.py -v` (1 passed); `pytest -q` (no new failures). + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/lightsheet_monitor.py gently/app/agent.py tests/test_lightsheet_monitor.py +git commit -m "feat(manual-mode): LightSheetStreamMonitor bridge + agent wiring" +``` + +--- + +### Task 5: Browser proxy routes (`require_control`) + +**Files:** +- Modify: `gently/ui/web/routes/data.py` (add routes in `create_router`, mirroring the bottom-camera + room-light routes) +- Test: `tests/test_lightsheet_routes.py` +- Reference: bottom-camera start/stop/status (`data.py:260-311`), room-light proxy (`data.py:335-355`), `_resolve_client` (`:313`), `require_control` (`:10`). + +**Interfaces:** +- Consumes: `agent.lightsheet_monitor` (Task 4); `client.set_lightsheet_live_params`, `set_led`, `set_laser_power`, `set_camera_led_mode`, `move_to_position`, `acquire_burst`, `acquire_volume`. +- Produces (all `Depends(require_control)` except GET status): `POST /api/devices/lightsheet/live/{start,stop}`, `GET /api/devices/lightsheet/live/status`, `POST /api/devices/lightsheet/live/params`, `POST /api/devices/led/set`, `POST /api/devices/laser/off`, `POST /api/devices/camera/led_mode`, `POST /api/devices/stage/move`, `POST /api/devices/acquire/{burst,volume}`. + +- [ ] **Step 1: Write the failing test (TestClient + mock client/monitor)** + +```python +# tests/test_lightsheet_routes.py +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, monitor=None): + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = monitor + app = FastAPI(); app.include_router(create_router(server)) + # legacy localhost = CONTROL; TestClient client.host is "testclient" → force CONTROL: + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + +def test_live_params_forwards(): + client = MagicMock(); client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) + r = _app(client=client).post("/api/devices/lightsheet/live/params", + json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0}) + assert r.status_code == 200 + client.set_lightsheet_live_params.assert_awaited_once_with(galvo=1.0, piezo=40.0, exposure=20.0) + +def test_acquire_burst_forwards(): + client = MagicMock(); client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) + r = _app(client=client).post("/api/devices/acquire/burst", + json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}) + assert r.status_code == 200 and r.json().get("request_id") == "b1" + +def test_live_start_requires_monitor(): + r = _app(monitor=None).post("/api/devices/lightsheet/live/start") + assert r.status_code == 503 +``` + +> Confirm the `require_control` override mechanism: in legacy mode `TestClient` requests are not localhost, so override the dependency (as above) to isolate route logic. A separate test can assert the gate by NOT overriding and expecting 403. + +- [ ] **Step 2: Run to verify it fails** — `pytest tests/test_lightsheet_routes.py -v` → FAIL (routes 404). + +- [ ] **Step 3: Implement** — in `create_router`, add (mirroring the referenced routes). Live start/stop/status copy the bottom-camera versions verbatim, swapping `bottom_camera_monitor` → `lightsheet_monitor`. Then: +```python + @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) + async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 + 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")) + except Exception as exc: + logger.exception("lightsheet live params failed") + raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc + return res + + @router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) + async def led_set(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_led(str(payload.get("state", "Closed"))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc + + @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) + async def laser_off(): + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_laser_power(488, 0) # confirm signature; force off + except Exception as exc: + raise HTTPException(status_code=502, detail=f"laser off 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 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.set_camera_led_mode(bool(payload.get("use_led", False))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc + + @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) + async def stage_move(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: return await client.move_to_position(float(payload["x"]), float(payload["y"])) + except KeyError: raise HTTPException(status_code=400, detail="x and y required") + except Exception as exc: + raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + + @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) + async def acquire_burst(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_burst( + frames=int(payload.get("frames", 60)), mode=str(payload.get("mode", "1hz")), + num_slices=int(payload.get("num_slices", 1)), + exposure_ms=float(payload.get("exposure_ms", 5.0))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc + + @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) + async def acquire_volume(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_volume( + num_slices=int(payload.get("num_slices", 50)), + exposure_ms=float(payload.get("exposure_ms", 10.0))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc +``` +Add the live start/stop/status routes by copying `start_bottom_camera_stream`/`stop_bottom_camera_stream`/`get_bottom_camera_status` (`data.py:260-311`) under `/api/devices/lightsheet/live/...` with `getattr(agent, "lightsheet_monitor", None)`. + +- [ ] **Step 4: Run to verify it passes** — `pytest tests/test_lightsheet_routes.py -v` (3 passed); `pytest -q` (no new failures). + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/data.py tests/test_lightsheet_routes.py +git commit -m "feat(manual-mode): require_control proxy routes for lightsheet live + illumination + acquire" +``` + +--- + +### Task 6: Manual view (frontend) + +**Files:** +- Modify: `gently/ui/web/templates/index.html` — add a `data-view="manual"` button to `#devices-view-switcher` (`:445-449`); add a `#devices-view-manual` container with the live canvas + control rail (model the camera panel `:569-608`); load A's `temperature-graph.js` if not already loaded on this page. +- Modify: `gently/ui/web/static/js/devices.js` — add `'manual'` to `VIEWS` (`:17`); a `handleLightsheetFrame` (mirror `handleCameraFrame` `:1111`); live toggle (mirror `toggleCameraStream`); galvo/piezo/exposure controls (debounced POST `live/params`); illumination toggles; acquire buttons; FPS readout; embed `TemperatureGraph.init`; a `'v'`-style key handled only if not deferred — leave existing keys, add nothing conflicting. +- Modify: the devices stylesheet for the manual panel (reuse `.devices-camera-*` styles where possible). + +**No JS unit harness** — verified by `node --check` + a Chrome-MCP harness (like A) + a UI audit. + +- [ ] **Step 1: Markup** — add to `#devices-view-switcher`: +```html + +``` +Add the view container after `#devices-view-optical3d` (`:716`-ish), with: a live `` (or ``) + placeholder + FPS/side overlay + Start/Stop toggle (`#devices-ls-toggle`); a right rail with exposure input, galvo slider (`#devices-ls-galvo`), piezo slider (`#devices-ls-piezo`), illumination toggles (LED `#devices-ls-led`, camera-LED-mode, room light, a static "Laser: OFF" indicator), a temperature setpoint + `
`, and Snap-volume / Burst buttons + a `#devices-ls-lastcap` card. Mirror the `.devices-camera-*` class structure for the image stage so the existing zoom/pan inline machinery can be reused or replicated. + +- [ ] **Step 2: JS — frame paint + controls** + +Add `'manual'` to `VIEWS`. Add a frame handler mirroring `handleCameraFrame` (separate DOM ids `_lsImg`/`_lsMeta`, its own FPS window) and subscribe `ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame)` in `setupCameraWiring` (or a new `setupManualWiring`). Live toggle hits `/api/devices/lightsheet/live/start|stop`. Galvo/piezo/exposure inputs: on `input`, **debounce ~120 ms**, then `fetch('/api/devices/lightsheet/live/params', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({galvo, piezo, exposure})})`. Illumination toggles POST their routes. Acquire buttons POST `/api/devices/acquire/burst|volume` with the current params, show "acquiring…" then render the result ref in `#devices-ls-lastcap`. Embed A's graph: `if (window.TemperatureGraph) TemperatureGraph.init(document.getElementById('devices-ls-tempgraph'), 'current')`. FPS readout from the frame handler's window (reuse the `computeCameraFps` approach). + +- [ ] **Step 3: `node --check`** — `node --check gently/ui/web/static/js/devices.js` (exit 0). + +- [ ] **Step 4: Chrome-MCP harness verification** — build a standalone harness (like A's): copy `event-bus.js`, `temperature-graph.js`, the new manual JS, and `main.css`; stub `fetch` for `live/params` + status + acquire; feed simulated `LIGHTSHEET_FRAME` events (a moving synthetic gradient that shifts when galvo/piezo "params" change) to demonstrate the slide-and-see; screenshot; run the alignment/spacing/contrast UI audit and fix flaws. Live in-app + FPS verification is deferred to the rig. + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js gently/ui/web/static/css/main.css +git commit -m "feat(manual-mode): Manual view — SPIM live canvas, galvo/piezo/exposure, illumination, acquire, temp" +``` + +--- + +### Task 7: FPS measurement (rig) + conditional binary transport + +**Files:** +- Create: `docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md` (record the numbers) +- (Conditional) Modify: `gently/ui/web/connection_manager.py` + `agent_ws`/`server.py` + `websocket.js` for a binary frame path. + +**This task is a measurement + a gate, not unconditional code.** + +- [ ] **Step 1:** On the rig, start lightsheet live and record from the Manual-view FPS readout + device logs: **device grab rate**, **delivered rate**, **browser paint rate**, at 512 px/q70 and at a reduced 384 px/q60. Write them into the notes file with the exposure used. +- [ ] **Step 2: Diagnose.** If device grab < ~15 fps → limiter is exposure/readout/rpyc, not transport — tune exposure/size/quality, stop here. If device grab ≥ target but browser paint < target → transport is the bottleneck → do Step 3. +- [ ] **Step 3 (conditional): binary WebSocket path.** Add a `send_bytes`-based frame channel: device→agent SSE stays; on the agent→browser hop, push the raw JPEG via `websocket.send_bytes(prefix + jpeg)` (small type byte), bypassing base64 + the per-client `json.dumps` + the EventBus fan-out; browser `onmessage` binary → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`. Re-measure and record. +- [ ] **Step 4: Commit** the notes (and any binary-path code, if built). + +```bash +git add docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md +git commit -m "docs(manual-mode): lightsheet live FPS measurement + transport decision" +``` + +--- + +## Self-Review + +**Spec coverage:** §2.1 streamer → Task 2; §2.2 monitor → Task 4; §2.3 proxy routes → Task 5; §2.4 client methods → Task 3; §2.5 Manual view → Task 6; §2.6 concurrency (`_state_pause_counter` back-off) → Task 2 streamer loop; §2.7 brightfield laser-off → Tasks 2 & 5 (`laser/off`); §3 transport baseline + measurement + binary escalation → Tasks 1/4 (baseline) + Task 7 (measure/escalate); §4 error handling → Tasks 2/5 (try/except, 503/502); §5 testing → each task's tests + Task 6 harness; LIGHTSHEET_FRAME event → Task 1. Single-camera (no side A/B) reflected throughout. ✓ + +**Open confirmations (explicit, not placeholders):** pymmcore sequence-acq method availability + core handle (Task 2); scanner/piezo park method names (Task 2); the SetupPanel scanner/beam question (Task 2); the client low-level POST helper name (Task 3); the `require_control` test-override mechanism (Task 5). Each names a concrete fallback. + +**Type consistency:** frame payload `{t, shape, downsample, mime, jpeg_b64}` (reused encoder) across Tasks 2/3/4/6; live params `{galvo, piezo, exposure}` across Tasks 2/3/5/6; event `LIGHTSHEET_FRAME` across Tasks 1/4/6; `lightsheet_monitor` attr across Tasks 4/5. Consistent. diff --git a/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md new file mode 100644 index 00000000..3d846a36 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md @@ -0,0 +1,218 @@ +# Design: Manual Mode — SPIM single-slice brightfield live view (sub-project B1) + +Status: design approved in brainstorm (2026-06-28). +Base branch: off `feature/temperature-interface` (sub-project A) — B1 embeds A's now-portable +temperature graph and its acquisition temperature stamp. + +## 0. Where this sits (execution roadmap) + +Sub-project **B** of the temperature-strain experiment prep. B is split: + +- **B1 (this spec) — the hand-driven experiment surface:** a **Manual view** in the Devices + tab with a SPIM single-slice brightfield live view (one selectable side A/B), live + galvo/piezo/exposure controls, brightfield illumination (LED, no laser), temperature + setpoint + A's graph, and by-hand burst/volume triggers. +- **B2 (deferred):** laser channel-preset browser, full timelapse/volume config form, + side-by-side dual view, richer camera selection. + +This is **load-bearing for next week** — the experiments are imaged by hand first. The first +experiment round is hand-driven through this view. + +## 1. Overview + +Today the Devices tab is read-only and there is no human-driven imaging surface. The +temperature experiments image with the **SPIM cameras under LED/brightfield illumination (no +laser, DIC/BF-like)** — a *single plane*, not a scan — and the operator needs to slide the +**galvo (sheet position)** and **imaging piezo (focal plane)** and *see the effect live*, then +trigger bursts/volumes by hand around a temperature change. + +### What already exists (reused) +- The MMCore core is a real `pymmcore.CMMCore`; **continuous sequence acquisition** is already + proven in the volume path (`acquisition.py:199-231`: `startSequenceAcquisition`/`popNextImage`/ + `getRemainingImageCount`/`stopSequenceAcquisition`). +- `capture_lightsheet_image(piezo_position, galvo_position, exposure_ms)` (`client.py:493`) — + a single-plane snap primitive (but it goes through the Bluesky queue → too heavy for a live + loop; used only for one-shot snaps). +- The **bottom-camera live streamer** (`device_layer.py` `_bottom_camera_streamer`) — the + structural template (subscriber-gated SSE, drop-oldest backpressure, `_state_pause_counter` + heavy-plan back-off, JPEG/base64 encode, EventBus→WS transport). +- Direct device writes: `set_led`, `set_laser_power`, `set_camera_led_mode`, `set_room_light`, + `set_temperature`, `move_to_position`, `acquire_volume`, `acquire_burst` (client methods). +- `require_control` auth (`auth.py`) — localhost = CONTROL on the rig; remote needs a token. +- The Devices view-switcher pattern (Map/Details/3D) to host a new "Manual" view. +- A's portable temperature-graph component + per-acquisition temperature stamp. + +### What must be built +A device-layer **lightsheet live streamer** (continuous sequence + peek-latest), an agent-side +**LightsheetStreamMonitor**, **`require_control` browser proxy routes**, two new client methods, +and the **Manual view** UI. Transport reuses the JSON/base64 path first; a binary path is a +**conditional escalation gated on an FPS measurement**. + +## 2. Architecture — seven units + +### 2.1 Lightsheet live streamer (device layer, new) +`_lightsheet_live_streamer` task + `handle_lightsheet_stream` SSE handler in `device_layer.py`, +mirroring the bottom-camera streamer's skeleton (subscriber-gated lifecycle, per-subscriber +`asyncio.Queue` drop-oldest backpressure, `_state_pause_counter` back-off). + +**Grab mechanism — the MM live pattern (replacing the snap loop):** +- On first subscriber: select the SPIM side camera (`core.setCameraDevice`), set exposure, + **park** galvo + piezo at the current params, `core.startContinuousSequenceAcquisition(0)`. +- Loop: **peek the latest** frame (`getLastImage` / `getNBeforeLastTaggedImage`) — never + `popNextImage` (don't drain). Encode → broadcast. Pace to `max(exposure, ~1/30 s)` up to the + display cap (MM's clamp). +- On last subscriber: `stopSequenceAcquisition`, restore prior camera/state. + +**Live param updates** (`POST /api/lightsheet/live/params {side, galvo, piezo, exposure}`): +- **galvo / piezo → applied live, no restart** (move scanner/imaging-piezo; next frames show + the new plane — the slide-and-see response). +- **exposure / side → stop → reconfigure → restart** (camera params can't change mid-sequence). + +**Resolution/quality:** the lightsheet stream gets its **own configurable size/quality** +(default ~512 px, JPEG q≈70) — higher than the bottom-camera 360 px thumbnail, since this view +is for judging focus. Resolution↔FPS tradeoff feeds the §3 measurement. + +**rpyc caveat:** the core may be an rpyc proxy, so each `getLastImage` transfers a full frame +across that boundary *inside the device-layer process*; the streamer encodes to JPEG there, so +only a small payload leaves the process. rpyc bounds the *grab* rate, not the web payload. + +**Implementer confirmations (flagged, not guesses):** the exact device methods to park the +galvo (scanner) + imaging piezo and the SPIM side-camera device names +(`device_factory.py`/`devices/scanner.py`/`optical.py`); and whether brightfield/LED live needs +the scanner/beam enabled at all or a fully static park — confirm against +`../micro-manager/plugins/ASIdiSPIM/.../SetupPanel.java`. B1 assumes static park, laser off. + +### 2.2 Lightsheet stream monitor (agent Service, new) +Mirrors `BottomCameraStreamMonitor`: consumes the device SSE via a new client generator and +republishes frames on the EventBus as a new high-volume `EventType.LIGHTSHEET_FRAME` +(added to `_NO_HISTORY_TYPES` and the `websocket.js` events-table exclusion). Opt-in; +started/stopped via the proxy routes. + +### 2.3 Browser proxy routes (viz server, `require_control`, new) +In `data.py`'s `create_router`, each `Depends(require_control)`, each resolving the live client +via `_resolve_client()`: + +| Route | Calls | +|---|---| +| `POST /api/devices/lightsheet/live/start` | start `LightsheetStreamMonitor` | +| `POST /api/devices/lightsheet/live/stop` | stop the monitor | +| `GET /api/devices/lightsheet/live/status` | `{available, streaming, side, last_frame_ts, fps}` | +| `POST /api/devices/lightsheet/live/params` | `client.set_lightsheet_live_params(side,galvo,piezo,exposure)` | +| `POST /api/devices/led/set` | `client.set_led(state)` | +| `POST /api/devices/laser/off` | `client.set_laser_power(wl, 0)` / "ALL OFF" | +| `POST /api/devices/camera/led_mode` | `client.set_camera_led_mode(use_led)` | +| `POST /api/devices/room_light/set` | *(exists `data.py:335`)* | +| `POST /api/devices/stage/move` | `client.move_to_position(x,y)` | +| `POST /api/devices/acquire/burst` | `client.acquire_burst(...)` | +| `POST /api/devices/acquire/volume` | `client.acquire_volume(...)` | +| `POST /api/devices/temperature/set` | *(exists `data.py:381`)* | + +The device-layer aiohttp routes have no auth; the browser must go through this proxy. + +### 2.4 New client methods +`DiSPIMMicroscope.stream_lightsheet(...)` (async generator over `GET /api/lightsheet/stream`, +mirroring `stream_bottom_camera`) and `set_lightsheet_live_params(side, galvo, piezo, exposure)` +(`POST /api/lightsheet/live/params`). Everything else already exists. + +### 2.5 Manual view (frontend, new `#devices-view-manual`) +A "Manual" entry in the Devices view-switcher → two-column layout: +- **Left:** the live image canvas (reusing the bottom-camera render + `zoom-pan.js` + + crosshair), an FPS + side overlay, and a Start/Stop Live toggle; a "Last capture" card below. +- **Right control rail:** Camera (side A/B selector, exposure) · Focus & sheet (galvo + piezo + sliders with numeric fields + nudge) · Illumination (LED, camera-LED-mode, room light, laser + OFF indicator) · Temperature (setpoint + A's embedded graph) · Acquire (Snap volume / Burst + buttons with a small param summary). + +Interactions: galvo/piezo sliders → **debounced** `POST live/params` → canvas updates in a +couple of frames; acquire → "acquiring…" overlay → Last-capture card → live resumes; FPS +readout surfaces the §3 measurement. Toggle/setpoint widgets model on the existing room-light +and temp-set controls. + +### 2.6 Concurrency rule +Live holds the camera/MMCore, so a triggered burst/volume **stops the live sequence → acquires +→ resumes** (via the existing `_state_pause_counter` back-off). Only **one live stream at a +time** (lightsheet vs bottom contend for MMCore). Acquisitions land with A's temperature stamp. + +### 2.7 Brightfield safety +On entering manual/brightfield live, **laser is forced off**; `DiSPIMLightSource` power-limit +clamps already cap any laser power. + +## 3. Transport & the FPS measure→escalate path (Approach ①) + +**Baseline (build first):** lightsheet frames ride the existing path unchanged — +device base64-JPEG-in-JSON over SSE → `LightsheetStreamMonitor` → EventBus `LIGHTSHEET_FRAME` +→ `ConnectionManager.broadcast` (`json.dumps` + `send_text`) → browser canvas. Additions: the +new EventType (+ `_NO_HISTORY_TYPES` + `websocket.js` exclusion) and a browser paint handler. + +**Instrumented measurement (the gate):** three counters — **device grab rate**, **delivered +rate**, **browser paint rate** (the FPS readout). Target **≥ ~15 fps usable** (stretch 25–30), +latency < ~150 ms. Run on the rig; record the numbers in the plan. + +**Diagnosis fork:** +- device grab < target → limiter is exposure / readout / rpyc, *not* transport → tune + exposure / size / quality; binary won't help. +- device grab ≥ target but browser paint < target → transport is the bottleneck → escalate. + +**Escalation (conditional task):** a **binary WebSocket path on the agent→browser hop** — +`websocket.send_bytes(jpeg)` with a small type prefix, browser `createImageBitmap` → canvas — +bypassing base64 (+33%), the per-client `json.dumps`, and the EventBus fan-out. The device→agent +SSE stays (single consumer). Built **only if** the measurement points to transport. + +## 4. Error handling & edges +- No SPIM camera / device down → `live/status {available:false}` → empty canvas, controls + disabled. Sequence fails to start → surfaced, graceful (no crash). +- rpyc/slow grab → backpressure drops oldest; FPS readout shows reality. +- Param out of range → `DiSPIMLightSource` clamps + UI input bounds; laser forced off in + brightfield. +- Only one live stream at a time; live is subscriber-gated → stops on view-switch / page-close. +- `require_control` denied (remote, no token) → 403 → controls shown disabled. + +## 5. Testing +- **Device layer:** streamer against a fake core supporting sequence acquisition + (start/stop/`getLastImage`) — param updates (galvo/piezo live vs exposure/side restart), + heavy-plan pause/resume, subscriber lifecycle. +- **Proxy routes:** FastAPI TestClient + mock client — `require_control` 403 gate, param + forwarding. +- **Client methods:** `stream_lightsheet` SSE parsing; `set_lightsheet_live_params` POST. +- **Frontend:** `node --check` + a Chrome-MCP harness (like A) for canvas + slide-and-see + + states, plus a UI audit. (No JS unit harness in repo.) +- **FPS:** a documented **rig measurement step** (grab/deliver/paint fps); the binary path is a + **conditional follow-up task gated on those numbers**. +- **Integration (rig):** live renders; slider changes the image; burst persists with + temperature; live resumes. + +Much of B1 needs the **real rig** (rpyc core, SPIM cameras, galvo/piezo) to verify; off-rig we +cover the streamer (fake core), proxy routes, client methods, and the frontend harness, and +defer live/FPS verification to the microscope — as with A. + +## 6. Out of scope (B2) +Laser channel-preset browser; full timelapse/volume configuration form; side-by-side dual view; +richer camera selection. + +## 7. Appendix: ASIdiSPIM two-camera model (B2 reference) + +From the MM ASIdiSPIM plugin (`../micro-manager/plugins/ASIdiSPIM`), for when B2 adds side A/B: + +- **Camera roles** (`data/Devices.java:93-121`): `CAMERAA` (side-A imaging), `CAMERAB` + (side-B imaging), `CAMERALOWER` (bottom), `MULTICAMERA` (the MMCore *Utilities* "Multi Camera" + fusion adapter). `SPIM_CAMERAS = {CAMERAA, CAMERAB}`; a `Sides` enum (A/B/NONE). +- **Live / single-side select** (`data/Cameras.java:163-186`, `setCamera`): `core.setCameraDevice( + mmDevice)` bracketed by stop/start-live; `getCurrentCamera` (`:193-210`) treats the Core as the + source of truth. Live can target one side **or** `MULTICAMERA` (dual live via the Utilities + Multi-Camera device). +- **Multi-Camera discovery** (`DevicesPanel.java:167-180`): auto-detected by + `getDeviceLibrary=="Utilities"` AND `getDeviceDescription=="Combine multiple physical cameras + into a single logical camera"`. +- **Acquisition does NOT use the fusion device** (`AcquisitionPanel.java:2668-2671`): it runs + **two parallel `startSequenceAcquisition`** on the physical side cameras into one shared buffer + (equal ROI required) and **demuxes by the per-image `"Camera"` tag** (`:2736-2772`; side A → even + channel indices, side B → odd). The Utilities Multi-Camera is reserved for **dual live**. + +**Implication for gently:** B1's streamer already does `core.setCameraDevice(cam.name)` before the +continuous sequence — that IS the MM side-select point. To add side A/B in **B2**: register +`CAMERAA`/`CAMERAB` (and auto-discover the Utilities "Multi Camera") in `device_factory.py`; add a +camera-role selector that sets the device before starting the live sequence; for dual **live**, +point the sequence at the Multi-Camera device; for dual **acquisition**, either use the +Multi-Camera or replicate the two-sequence + `"Camera"`-tag demux. Correction to the working +assumption: ASIdiSPIM acquisition is **two independent sequences demuxed by tag**, not the +multicamera fusion — the fusion device is a live-only convenience. diff --git a/gently/app/agent.py b/gently/app/agent.py index afe2e649..a24a00f1 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -215,6 +215,8 @@ def __init__( # Opt-in bottom-camera stream bridge — created when viz starts, but # left unstarted until the operator clicks "Start camera" in the UI. self.bottom_camera_monitor = None + # Opt-in lightsheet stream bridge — same lifecycle as bottom_camera_monitor. + self.lightsheet_monitor = None # ===== Create delegate managers ===== @@ -636,9 +638,7 @@ def _init_timelapse_orchestrator(self): store=self.store, claude_client=self.claude, temperature_provider=lambda: ( - self.temperature_sampler.latest - if self.temperature_sampler - else None + self.temperature_sampler.latest if self.temperature_sampler else None ), ) except Exception as e: @@ -859,6 +859,16 @@ async def start_viz_server( logger.warning(f"Failed to construct bottom-camera monitor: {e}") self.bottom_camera_monitor = None + if self.microscope is not None and self.lightsheet_monitor is None: + try: + from .lightsheet_monitor import LightSheetStreamMonitor + + self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) + logger.info("Lightsheet monitor ready (not started)") + except Exception as e: + logger.warning(f"Failed to construct lightsheet monitor: {e}") + self.lightsheet_monitor = None + async def stop_viz_server(self): """Stop the visualization server if running.""" if self.bottom_camera_monitor is not None: @@ -867,6 +877,12 @@ async def stop_viz_server(self): except Exception: logger.exception("Failed to stop bottom-camera monitor") self.bottom_camera_monitor = None + if self.lightsheet_monitor is not None: + try: + await self.lightsheet_monitor.stop() + except Exception: + logger.exception("Failed to stop lightsheet monitor") + self.lightsheet_monitor = None if self.device_state_monitor is not None: try: await self.device_state_monitor.stop() diff --git a/gently/app/lightsheet_monitor.py b/gently/app/lightsheet_monitor.py new file mode 100644 index 00000000..78b7ca39 --- /dev/null +++ b/gently/app/lightsheet_monitor.py @@ -0,0 +1,98 @@ +""" +LightSheetStreamMonitor — bridges the device-layer lightsheet SSE stream +onto the EventBus as ``LIGHTSHEET_FRAME`` events. + +Modelled on :class:`gently.app.bottom_camera_monitor.BottomCameraStreamMonitor`, +with the same opt-in semantics: streaming is not started on agent boot — only +when the operator enables it explicitly from the UI. The agent's start/stop +methods are the only path that connects/disconnects this monitor. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from gently.core.event_bus import EventType, get_event_bus +from gently.core.service import Service + +if TYPE_CHECKING: + from gently.hardware.dispim.client import DiSPIMMicroscope + +logger = logging.getLogger(__name__) + + +class LightSheetStreamMonitor(Service): + """Consumes the lightsheet SSE stream and republishes frames on the bus. + + The browser receives frames via the viz server's wildcard subscription; + no additional plumbing is needed at the agent layer beyond starting the + bridge when the operator asks for it. + """ + + def __init__( + self, + microscope: DiSPIMMicroscope, + reconnect_delay_sec: float = 2.0, + ): + super().__init__(name="lightsheet-monitor", service_type="bridge") + self.microscope = microscope + self.reconnect_delay_sec = reconnect_delay_sec + self._task: asyncio.Task | None = None + self._stop_requested = False + self._last_frame_ts: float | None = None + + @property + def running(self) -> bool: + return self._task is not None and not self._task.done() + + async def on_start(self): + if self.running: + return + self._stop_requested = False + self._task = asyncio.create_task(self._run(), name="lightsheet-monitor") + logger.info("LightSheetStreamMonitor: started") + + async def on_stop(self): + self._stop_requested = True + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + logger.info("LightSheetStreamMonitor: stopped") + + async def _run(self): + bus = get_event_bus() + while not self._stop_requested: + try: + logger.info("LightSheetStreamMonitor: opening stream") + async for payload in self.microscope.stream_lightsheet(): + if self._stop_requested: + break + self._last_frame_ts = payload.get("t") + try: + bus.publish( + event_type=EventType.LIGHTSHEET_FRAME, + data=payload, + source="lightsheet-monitor", + ) + except Exception: + logger.exception("Failed to publish frame") + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "LightSheetStreamMonitor: stream ended (%s) — reconnecting in %.1fs", + exc, + self.reconnect_delay_sec, + ) + if self._stop_requested: + break + try: + await asyncio.sleep(self.reconnect_delay_sec) + except asyncio.CancelledError: + raise diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py index e762534c..0995d1f2 100644 --- a/gently/app/temperature_sampler.py +++ b/gently/app/temperature_sampler.py @@ -6,14 +6,15 @@ and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a crash; with no active session the loop idles. """ + from __future__ import annotations import asyncio import logging from datetime import datetime, timezone +from gently.core.event_bus import EventType, get_event_bus from gently.core.service import Service -from gently.core.event_bus import get_event_bus, EventType logger = logging.getLogger(__name__) diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py index 6e029ad4..bfc892ea 100644 --- a/gently/app/tools/acquisition_tools.py +++ b/gently/app/tools/acquisition_tools.py @@ -213,10 +213,9 @@ async def acquire_volume( }, } from gently.app.temperature_sampler import temperature_stamp as _ts + _temp_stamp = _ts( - getattr( - getattr(agent, "temperature_sampler", None), "latest", None - ) + getattr(getattr(agent, "temperature_sampler", None), "latest", None) ) if _temp_stamp is not None: acq_metadata["temperature"] = _temp_stamp diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index b3bd7e68..9774bdaa 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -86,6 +86,7 @@ class EventType(Enum): DEVICE_STATE_UPDATE = auto() # Periodic device-state snapshot from device layer TEMPERATURE_UPDATE = auto() # Temperature reading from device layer BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream + LIGHTSHEET_FRAME = auto() # Live JPEG frame from the SPIM lightsheet live stream EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment SCAN_GEOMETRY_UPDATE = auto() # Scan cuboid + light-sheet mode for the 3D optical-space view @@ -188,6 +189,7 @@ class EventType(Enum): EventType.DEVICE_STATE_UPDATE, EventType.TEMPERATURE_UPDATE, # High-volume telemetry from temperature controller EventType.BOTTOM_CAMERA_FRAME, # ~2 Hz JPEG frames — would crowd history out + EventType.LIGHTSHEET_FRAME, # High-volume live frames — keep out of history EventType.LOG_RECORD, # log lines can hit hundreds/min during # calibration; durable copy is in the # gently_*.log file already diff --git a/gently/core/file_store.py b/gently/core/file_store.py index e7fb2174..46c781b2 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -450,7 +450,8 @@ def append_temperature_sample(self, session_id: str, sample: dict) -> None: _append_jsonl(sd / "temperature.jsonl", sample) def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]: - """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string). + """Return temperature samples for a session, optionally filtered to + t >= since (ISO-UTC string). Reads lines tolerantly: a truncated trailing line (e.g. after a mid-append crash) is silently skipped rather than raising a JSONDecodeError. @@ -771,9 +772,7 @@ def get_volume_meta(self, session_id: str, embryo_id: str, timepoint: int) -> di sd = self._session_dir(session_id) if sd is None: return None - meta_path = ( - sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint) - ) + meta_path = sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint) return _read_yaml(meta_path) def list_volumes(self, session_id: str, embryo_id: str | None = None) -> list[VolumeInfo]: diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 56a9a33c..58746750 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -811,6 +811,29 @@ async def get_laser_power(self, wavelength: int) -> dict: except Exception as e: return {"success": False, "error": str(e)} + async def set_laser_config(self, config_name: str) -> dict: + """Apply a Laser config-group preset (e.g. "ALL OFF"). + + Hits ``POST /api/laser/config`` — direct, no Bluesky queue. + Use ``"ALL OFF"`` to gate every laser line off via the PLogic + OutputChannel; other presets from the MM config group are also + accepted (e.g. ``"488 only"``, ``"561 only"``). + + Parameters + ---------- + config_name : str + Exact preset name from the Laser config group. + """ + return await self._api_post("/api/laser/config", {"config": config_name}) + + async def get_laser_configs(self) -> dict: + """List available Laser config-group presets. + + Hits ``GET /api/laser/configs`` — returns ``{"configs": [...]}`` + with the preset names from the MM Laser config group. + """ + return await self._api_get("/api/laser/configs") + async def get_led_status(self) -> dict: """Get current LED status.""" return await self._api_get("/api/led/status") @@ -954,6 +977,54 @@ async def stream_bottom_camera(self, timeout: float | None = None): except Exception as exc: logger.warning("Malformed bottom-camera SSE payload skipped: %s", exc) + async def stream_lightsheet(self, timeout: float | None = None): + """Async generator yielding JPEG frames from the lightsheet live SSE stream. + + Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. + """ + self._ensure_connected() + client_timeout = aiohttp.ClientTimeout( + total=None, + sock_read=timeout, + sock_connect=10.0, + ) + url = f"{self.http_url}/api/lightsheet/stream" + async with self._session.get(url, timeout=client_timeout) as resp: + resp.raise_for_status() + buf = b"" + async for chunk in resp.content.iter_any(): + if not chunk: + continue + buf += chunk + while b"\n\n" in buf: + event_block, buf = buf.split(b"\n\n", 1) + data_lines = [] + for line in event_block.splitlines(): + if not line or line.startswith(b":"): + continue + if line.startswith(b"data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + raw = b"\n".join(data_lines).decode("utf-8", errors="replace") + try: + import json as _json + + yield _json.loads(raw) + except Exception as exc: + logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) + + async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict: + """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + body = {} + if galvo is not None: + body["galvo"] = float(galvo) + if piezo is not None: + body["piezo"] = float(piezo) + if exposure is not None: + body["exposure"] = float(exposure) + return await self._api_post("/api/lightsheet/live/params", body) + async def set_camera_led_mode(self, use_led: bool = False) -> dict: """Enable/disable automatic LED for bottom camera captures.""" return await self._api_post("/api/camera/led_mode", {"use_led": use_led}) diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 55e8225d..6173236f 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -168,6 +168,18 @@ def __init__( self._cam_target_max_dim: int = 360 # ~360px thumbnail self._cam_jpeg_quality: int = 55 + # Lightsheet (SPIM) live stream — continuous sequence acquisition. + self._ls_subscribers: list[asyncio.Queue] = [] + self._ls_task: asyncio.Task | None = None + 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_seq_started: bool = False + self._ls_applied: dict = {} # last-applied galvo/piezo/exposure + self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values + self._ls_spim_idle: bool = False # whether SPIM state machine was set Idle this session + # Plans that hold MMCore for long performance-critical work. # Anything in this set runs with state polling paused. self._heavy_plans = frozenset( @@ -845,7 +857,9 @@ def _capture_bottom_frame_sync(self) -> np.ndarray | None: logger.debug("Bottom-camera grab failed: %s", exc) return None - def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: + def _encode_frame_for_stream( + self, img: np.ndarray, max_dim: int | None = None, quality: int | None = None + ) -> dict[str, Any] | None: """Downsample + auto-contrast + JPEG-encode a uint16 frame for SSE. Optimised for streaming throughput: @@ -854,6 +868,10 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: full image — np.partition on the subsample is O(n) and avoids sorting ~120K pixels every frame * JPEG quality 55 (visually fine at thumbnail size) + + ``max_dim`` and ``quality`` default to the ``_cam_*`` instance values, + allowing callers (e.g. the lightsheet streamer) to pass different + settings without duplicating the encoder. """ if img is None or img.size == 0: return None @@ -865,9 +883,12 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: logger.warning("Cannot encode frame — OpenCV unavailable: %s", exc) return None + target_max_dim = max_dim if max_dim is not None else self._cam_target_max_dim + jpeg_quality = quality if quality is not None else self._cam_jpeg_quality + h, w = img.shape[:2] # Stride slicing — no interpolation, just take every Nth pixel. - factor = max(1, max(h, w) // self._cam_target_max_dim) + factor = max(1, max(h, w) // target_max_dim) small = img[::factor, ::factor] # Auto-contrast off a small random sample. Robust to hot pixels @@ -889,7 +910,7 @@ def _encode_frame_for_stream(self, img: np.ndarray) -> dict[str, Any] | None: scale = 255.0 / (hi - lo) small = np.clip((small.astype(np.float32) - lo) * scale, 0, 255).astype(np.uint8) - ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, self._cam_jpeg_quality]) + ok, jpeg = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality]) if not ok: return None b64 = base64.b64encode(jpeg.tobytes()).decode("ascii") @@ -953,6 +974,209 @@ async def _broadcast_camera(self, payload: dict[str, Any]): except ValueError: pass + # ========================================================================= + # Lightsheet (SPIM) Live Streamer — continuous sequence acquisition + # ========================================================================= + + def _park_lightsheet_sync(self) -> None: + """Park scanner galvo + imaging piezo at the current live params (static sheet). + + 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). + - setPosition fires only when the commanded value differs from the + last-applied value (no-op on steady-state frames). + """ + p = self._ls_params + scanner = self.devices.get("scanner") + 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: + try: + scanner.set_spim_state("Idle") + except Exception: + pass + if piezo is not None: + try: + piezo.set_spim_state("Idle") + except Exception: + pass + self._ls_spim_idle = True + # setPosition only when the value changed from last-applied. + if scanner is not None: + want = float(p["galvo"]) + if self._ls_parked.get("galvo") != want: + scanner.sa_offset_y.setPosition(want) + self._ls_parked["galvo"] = want + if piezo is not None: + want = float(p["piezo"]) + if self._ls_parked.get("piezo") != want: + piezo.setPosition(want) + 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.""" + core = self.system.core + 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"] + 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"] + + def _grab_lightsheet_frame_sync(self): + """Park → ensure sequence running → peek the latest frame (never drain).""" + try: + self._park_lightsheet_sync() # galvo/piezo applied live + self._ensure_lightsheet_sequence_sync() # start / restart on exposure + try: + from gently.hardware.dispim.devices.acquisition import _safe_obtain + except (ImportError, AttributeError): + _safe_obtain = None + core = self.system.core + img = core.getLastImage() + if _safe_obtain is not None: + try: + img = _safe_obtain(img) + except Exception: + pass + return np.asarray(img) + except Exception as exc: + logger.debug("Lightsheet grab failed: %s", exc) + return None + + def _stop_lightsheet_sequence_sync(self) -> None: + try: + if self.system.core.isSequenceRunning(): + self.system.core.stopSequenceAcquisition() + except Exception: + logger.debug("stop lightsheet sequence failed", exc_info=True) + self._ls_seq_started = False + self._ls_applied = {} + # Reset park guard so the next stream session re-idles the state + # machine and re-parks the axes (hardware may have moved). + self._ls_spim_idle = False + self._ls_parked = {} + + async def _lightsheet_streamer(self): + logger.info("Lightsheet streamer started") + try: + while self._ls_subscribers: + if self._state_pause_counter > 0: + # Heavy plan owns MMCore: release the sequence and back off. + if self._ls_seq_started: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + await asyncio.sleep(0.1) + continue + tick = time.monotonic() + img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) + payload = ( + self._encode_frame_for_stream( + img, self._ls_target_max_dim, self._ls_jpeg_quality + ) + if img is not None + else None + ) + if payload is not None: + await self._broadcast_lightsheet(payload) + elapsed = time.monotonic() - tick + # Pace to at least the exposure; peek-rate caps near the camera rate. + floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) + await asyncio.sleep(max(0.0, floor - elapsed)) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Lightsheet streamer crashed") + finally: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + logger.info("Lightsheet streamer exiting") + + async def _broadcast_lightsheet(self, payload: dict[str, Any]): + if not self._ls_subscribers: + return + dead: list[asyncio.Queue] = [] + for q in self._ls_subscribers: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + try: + _ = q.get_nowait() + q.put_nowait(payload) + except Exception: + dead.append(q) + for q in dead: + try: + self._ls_subscribers.remove(q) + except ValueError: + pass + + async def handle_lightsheet_stream(self, request): + """GET /api/lightsheet/stream — SSE of base64-JPEG frames from SPIM camera. + + The streamer task spins up on first connect and exits when the last + subscriber leaves. Uses continuous sequence acquisition for best FPS. + """ + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + self._ls_subscribers.append(queue) + if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): + self._ls_task = asyncio.create_task( + self._lightsheet_streamer(), name="lightsheet-streamer" + ) + try: + await response.write(b": connected\n\n") + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=10.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n") + continue + if payload is None: + break + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + pass + except Exception: + logger.exception("Lightsheet SSE writer failed") + finally: + try: + self._ls_subscribers.remove(queue) + except ValueError: + pass + return response + + async def handle_lightsheet_params(self, request): + """POST /api/lightsheet/live/params — update live galvo/piezo/exposure. + + Body: {"galvo": float, "piezo": float, "exposure": float} (all optional). + Galvo/piezo apply on the next grab; exposure 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]) + return web.json_response({"params": self._ls_params}) + # ========================================================================= # MMCore Push Callbacks # ========================================================================= @@ -1795,6 +2019,70 @@ async def handle_get_light_source_power(self, request): status=500, ) + async def handle_set_laser_config(self, request): + """POST /api/laser/config — apply a Laser config-group preset. + + Body: {"config": } + Calls light_source.set(config_name) which maps to + core.setConfig(group_name, config_name) + waitForConfig. + """ + try: + data = await request.json() + config_name = data.get("config") + if not config_name: + return web.json_response( + {"success": False, "error": "missing 'config' field"}, + status=400, + ) + light_source = self.devices.get("light_source") or self.devices.get("laser_control") + if light_source is None: + return web.json_response( + {"success": False, "error": "Light source device not found"}, + status=503, + ) + try: + light_source.set(config_name) + except Exception as e: + return web.json_response( + {"success": False, "error": str(e)}, + status=400, + ) + return web.json_response({"success": True, "config": config_name}) + except Exception as e: + import traceback + + return web.json_response( + { + "success": False, + "error": str(e), + "traceback": traceback.format_exc(), + }, + status=500, + ) + + async def handle_get_laser_configs(self, request): + """GET /api/laser/configs — list available Laser config-group presets.""" + try: + light_source = self.devices.get("light_source") or self.devices.get("laser_control") + if light_source is None: + return web.json_response( + {"success": False, "error": "Light source device not found"}, + status=503, + ) + configs = light_source._get_available_configs() + return web.json_response({"configs": configs}) + except Exception as e: + import traceback + + return web.json_response( + { + "success": False, + "error": str(e), + "traceback": traceback.format_exc(), + }, + status=500, + ) + async def handle_get_camera_exposure(self, request): """GET /api/camera/exposure - Get bottom camera exposure time""" try: @@ -2787,6 +3075,8 @@ async def on_start(self): self._app.router.add_get("/api/camera/exposure", self.handle_get_camera_exposure) self._app.router.add_post("/api/light_source/power", self.handle_set_light_source_power) self._app.router.add_get("/api/light_source/power", self.handle_get_light_source_power) + self._app.router.add_post("/api/laser/config", self.handle_set_laser_config) + self._app.router.add_get("/api/laser/configs", self.handle_get_laser_configs) self._app.router.add_get("/api/plan_log", self.handle_get_plan_log) self._app.router.add_post("/session/configure", self.handle_session_configure) @@ -2806,6 +3096,10 @@ async def on_start(self): # Bottom-camera live stream (subscriber-gated, off when nobody listens) self._app.router.add_get("/api/bottom_camera/stream", self.handle_bottom_camera_stream) + # 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) + # Start plan executor self._executor_task = asyncio.create_task(self._plan_executor()) diff --git a/gently/hardware/dispim/devices/acquisition.py b/gently/hardware/dispim/devices/acquisition.py index 9b2658ef..fda839d1 100644 --- a/gently/hardware/dispim/devices/acquisition.py +++ b/gently/hardware/dispim/devices/acquisition.py @@ -188,6 +188,14 @@ def wait(): self.core.setConfig(self.laser_control.group_name, self._laser_config) self.core.waitForConfig(self.laser_control.group_name, self._laser_config) + # Defensive stop: the lightsheet live streamer may have left the + # camera in continuous acquisition. Starting a new sequence on + # an already-running camera triggers an MMCore error (and is + # thread-unsafe). Stop cleanly before reconfiguring. + if self.core.isSequenceRunning(): + self.core.stopSequenceAcquisition() + self.core.waitForDevice(self.camera.name) + # Prepare circular buffer self.core.clearCircularBuffer() buffer_capacity = self.core.getBufferTotalCapacity() diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 66e8842c..3a5e2051 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -13,11 +13,11 @@ from .context import create_router as create_context_router from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router -from .temperature import create_router as create_temperature_router from .images import create_router as create_images_router from .notebook import create_router as create_notebook_router from .pages import create_router as create_pages_router from .sessions import create_router as create_sessions_router +from .temperature import create_router as create_temperature_router from .volumes import create_router as create_volumes_router from .websocket import create_router as create_websocket_router diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index a609526a..3844b131 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -412,6 +412,231 @@ async def set_temperature(payload: dict = Body(...)): # noqa: B008 "waited": res.get("waited", False), } + # ------------------------------------------------------------------ + # Lightsheet live stream + # ------------------------------------------------------------------ + + @router.get("/api/devices/lightsheet/live/status") + async def get_lightsheet_live_status(): + """Return whether the lightsheet live stream bridge is running.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + return { + "available": monitor is not None, + "streaming": bool(monitor and monitor.running), + "last_frame_ts": getattr(monitor, "_last_frame_ts", None) if monitor else None, + } + + @router.post( + "/api/devices/lightsheet/live/start", + dependencies=[Depends(require_control)], + ) + async def start_lightsheet_live_stream(): + """Start the lightsheet live stream bridge. + + Idempotent — calling start() while already running is a no-op. + """ + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + if monitor is None: + raise HTTPException( + status_code=503, + detail="Lightsheet monitor not initialised (agent or microscope not ready)", + ) + try: + await monitor.start() + except Exception as exc: + logger.exception("Failed to start lightsheet monitor") + raise HTTPException(status_code=500, detail=f"start failed: {exc}") from exc + return {"streaming": monitor.running} + + @router.post( + "/api/devices/lightsheet/live/stop", + dependencies=[Depends(require_control)], + ) + async def stop_lightsheet_live_stream(): + """Stop the lightsheet live stream bridge. Idempotent.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + monitor = getattr(agent, "lightsheet_monitor", None) if agent else None + if monitor is None: + return {"streaming": False} + try: + await monitor.stop() + except Exception as exc: + logger.exception("Failed to stop lightsheet monitor") + raise HTTPException(status_code=500, detail=f"stop failed: {exc}") from exc + return {"streaming": False} + + # ------------------------------------------------------------------ + # Lightsheet live params + # ------------------------------------------------------------------ + + @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.""" + 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"), + ) + except Exception as exc: + logger.exception("lightsheet live params failed") + raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc + return res + + # ------------------------------------------------------------------ + # LED / laser / camera + # ------------------------------------------------------------------ + + @router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) + async def led_set(payload: dict = Body(...)): # noqa: B008 + """Set the LED shutter state. Body: {"state": "Open"|"Closed"}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_led(str(payload.get("state", "Closed"))) + except Exception as exc: + logger.exception("LED set command failed") + raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc + + @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) + async def laser_off(): + """Gate ALL laser lines off via the Laser config group "ALL OFF" preset. + + Uses setConfig("Laser", "ALL OFF") which drives the PLogic + OutputChannel to "none of outputs 5-8" — this gates every line + (488, 561, 405, 637) off, not just the 488 nm setpoint. + Required for safe brightfield live-view (spec §2.7). + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_laser_config("ALL OFF") + except Exception as exc: + logger.exception("Laser off command failed") + raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc + + @router.get("/api/devices/laser/configs") + async def laser_configs(): + """Return the available Laser config-group presets from the device layer. + + No require_control — read-only status route, mirrors GET status + routes like room_light/status and temperature/status. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_laser_configs() + except Exception as exc: + logger.exception("Laser configs fetch failed") + raise HTTPException(status_code=502, detail=f"laser configs 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}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_camera_led_mode(bool(payload.get("use_led", False))) + except Exception as exc: + logger.exception("Camera LED mode command failed") + raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Stage + # ------------------------------------------------------------------ + + @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) + async def stage_move(payload: dict = Body(...)): # noqa: B008 + """Move the stage to an absolute XY position. Body: {"x": float, "y": float}.""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.move_to_position(float(payload["x"]), float(payload["y"])) + except KeyError: + raise HTTPException(status_code=400, detail="x and y required") from None + except Exception as exc: + logger.exception("Stage move command failed") + raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Acquisition + # ------------------------------------------------------------------ + + @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) + async def acquire_burst(payload: dict = Body(...)): # noqa: B008 + """Trigger a burst acquisition. + + Body: {frames, mode, num_slices, exposure_ms, + laser_config?, piezo_center?, galvo_center?}. + laser_config is forwarded directly to the device client so callers + can send "ALL OFF" for brightfield-safe Manual-view captures. + piezo_center and galvo_center capture at the dialled focal plane. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + kw: dict = {} + if payload.get("laser_config") is not None: + kw["laser_config"] = str(payload["laser_config"]) + if payload.get("piezo_center") is not None: + kw["piezo_center"] = float(payload["piezo_center"]) + if payload.get("galvo_center") is not None: + kw["galvo_center"] = float(payload["galvo_center"]) + return await client.acquire_burst( + frames=int(payload.get("frames", 60)), + mode=str(payload.get("mode", "1hz")), + num_slices=int(payload.get("num_slices", 1)), + exposure_ms=float(payload.get("exposure_ms", 5.0)), + **kw, + ) + except Exception as exc: + logger.exception("Burst acquisition failed") + raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc + + @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) + async def acquire_volume(payload: dict = Body(...)): # noqa: B008 + """Trigger a volume acquisition. + + Body: {num_slices, exposure_ms, + laser_config?, piezo_center?, galvo_center?}. + laser_config is forwarded directly to the device client so callers + can send "ALL OFF" for brightfield-safe Manual-view captures. + piezo_center and galvo_center capture at the dialled focal plane. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + kw: dict = {} + if payload.get("laser_config") is not None: + kw["laser_config"] = str(payload["laser_config"]) + if payload.get("piezo_center") is not None: + kw["piezo_center"] = float(payload["piezo_center"]) + if payload.get("galvo_center") is not None: + kw["galvo_center"] = float(payload["galvo_center"]) + return await client.acquire_volume( + num_slices=int(payload.get("num_slices", 50)), + exposure_ms=float(payload.get("exposure_ms", 10.0)), + **kw, + ) + except Exception as exc: + logger.exception("Volume acquisition failed") + raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc + @router.get("/api/calibration") async def list_calibration(embryo_id: str | None = None): """Get calibration images""" diff --git a/gently/ui/web/routes/temperature.py b/gently/ui/web/routes/temperature.py index 1505714b..90916181 100644 --- a/gently/ui/web/routes/temperature.py +++ b/gently/ui/web/routes/temperature.py @@ -3,6 +3,7 @@ Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. Mirrors routes/experiments.py session resolution. """ + import urllib.parse from fastapi import APIRouter, HTTPException, Request diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 36124bd9..4c04716e 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10550,3 +10550,260 @@ body.modal-open { opacity: 0.5; cursor: not-allowed; } + +/* ========================================================================= + Manual view — lightsheet live panel + control rail + ========================================================================= */ + +/* Two-column layout: image stage (flex-grow) + fixed control rail */ +.ls-layout { + display: flex; + flex: 1; + min-height: 0; + gap: 1rem; + font-family: 'Inter Tight', system-ui, sans-serif; +} + +/* Left column — live image stage */ +.ls-image-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.ls-stage-head { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +/* The stage itself reuses .devices-camera-stage aspect + overflow */ +.ls-stage { + flex: 1; + min-height: 280px; + /* override aspect-ratio: let the flex column stretch it */ + aspect-ratio: unset; +} + +/* Right column — control rail */ +.ls-rail { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.85rem; + overflow-y: auto; + padding: 0.1rem 0 0.5rem; +} + +/* Control group block */ +.ls-group { + display: flex; + flex-direction: column; + gap: 0.32rem; + padding: 0.55rem 0.65rem; + background: var(--map-overlay-bg); + border: 1px solid var(--map-overlay-edge); + border-radius: 5px; +} + +.ls-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--map-ink-mute); +} + +/* Generic inline row */ +.ls-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Slider + number inline */ +.ls-slider-row { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.ls-slider { + flex: 1; + min-width: 0; + height: 3px; + accent-color: var(--map-accent); + cursor: pointer; +} + +.ls-number { + width: 64px; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + border-radius: 4px; + padding: 0.2rem 0.35rem; + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + font-variant-numeric: tabular-nums; +} + +.ls-number-sm { + width: 50px; +} + +.ls-number:focus { + outline: none; + border-color: var(--map-accent); +} + +.ls-unit { + font-family: 'Inter Tight', system-ui, sans-serif; + font-size: 0.62rem; + color: var(--map-ink-mute); + flex-shrink: 0; +} + +/* Illumination button rows */ +.ls-btn-row { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.ls-illum-btn { + flex: 1; + min-width: 0; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.28rem 0.4rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ls-illum-btn:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} + +.ls-illum-btn--active { + background: rgba(34, 211, 238, 0.14); + border-color: var(--map-accent); + color: var(--map-accent); +} + +/* Laser-off indicator */ +.ls-laser-indicator { + font-size: 0.6rem; + color: var(--map-ink-mute); + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.1rem; +} + +.ls-laser-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--map-ink-mute); + flex-shrink: 0; +} + +/* Temperature setpoint button */ +.ls-set-btn { + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} + +.ls-set-btn:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); +} + +/* Temperature mini-graph */ +.ls-tempgraph { + min-height: 48px; + margin-top: 0.25rem; +} + +/* Acquire buttons */ +.ls-acquire-btn { + flex: 1; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.35rem 0.5rem; + border-radius: 4px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} + +.ls-acquire-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} + +.ls-acquire-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Last-captured ref card */ +.ls-lastcap { + margin-top: 0.35rem; + padding: 0.35rem 0.45rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.ls-lastcap[hidden] { display: none; } + +.ls-lastcap-label { + font-size: 0.58rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--map-ink-mute); +} + +.ls-lastcap-ref { + font-family: 'JetBrains Mono', monospace; + font-size: 0.65rem; + color: var(--map-ink); + word-break: break-all; +} diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index a56f0015..2de449e4 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -14,7 +14,7 @@ */ const DevicesManager = (function () { const STALE_AFTER_MS = 4000; - const VIEWS = ['map', 'details', 'optical3d']; + const VIEWS = ['map', 'details', 'optical3d', 'manual']; const SVG_NS = 'http://www.w3.org/2000/svg'; // Status / details DOM @@ -72,6 +72,35 @@ const DevicesManager = (function () { const _CAM_ZOOM_MAX = 8; const _CAM_ZOOM_STEP = 1.15; // multiplicative per wheel notch + // Lightsheet live panel DOM + state (Manual view) + let _lsToggle, _lsImg, _lsPlaceholder, _lsLed, _lsMeta, _lsStage; + let _lsStreaming = false; + let _lsLastFrameTs = 0; + let _lsHasFrame = false; + let _lsStaleTimer = null; + const _LS_FPS_WINDOW = 12; + let _lsFrameTimes = []; + + // Lightsheet zoom / pan (mirrors camera zoom/pan) + let _lsZoom = 1; + let _lsTx = 0; + let _lsTy = 0; + let _lsPanLast = null; + + // Lightsheet live params — debounced POST to /api/devices/lightsheet/live/params + let _lsGalvo = 0; + let _lsPiezo = 50; + let _lsExposure = 20; // matches device-layer _ls_params default (20 ms) + let _lsParamTimer = null; + + // Lightsheet control inputs (rail) + let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; + let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn; + let _lsCamLedOn = false; + let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; + let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls + let _lsTempInput, _lsTempSet; + // 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 // device layer reports the accessory is configured. @@ -151,6 +180,30 @@ const DevicesManager = (function () { _camLed = document.getElementById('devices-camera-led'); _camMeta = document.getElementById('devices-camera-meta'); + // Manual / lightsheet panel + _lsToggle = document.getElementById('devices-ls-toggle'); + _lsImg = document.getElementById('devices-ls-img'); + _lsPlaceholder = document.getElementById('devices-ls-placeholder'); + _lsStage = document.getElementById('devices-ls-stage'); + _lsLed = document.getElementById('devices-ls-led'); + _lsMeta = document.getElementById('devices-ls-meta'); + _lsGalvoSlider = document.getElementById('devices-ls-galvo-slider'); + _lsGalvoNum = document.getElementById('devices-ls-galvo'); + _lsPiezoSlider = document.getElementById('devices-ls-piezo-slider'); + _lsPiezoNum = document.getElementById('devices-ls-piezo'); + _lsExposureNum = document.getElementById('devices-ls-exposure'); + _lsLedOpen = document.getElementById('devices-ls-led-open'); + _lsLedClosed = document.getElementById('devices-ls-led-closed'); + _lsCamLed = document.getElementById('devices-ls-cam-led'); + _lsRoomLightBtn = document.getElementById('devices-ls-room-light-btn'); + _lsSnapVolBtn = document.getElementById('devices-ls-snap-volume'); + _lsBurstBtn = document.getElementById('devices-ls-burst'); + _lsLastcap = document.getElementById('devices-ls-lastcap'); + _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); + _lsLaserStatus = document.getElementById('devices-ls-laser-status'); + _lsTempInput = document.getElementById('devices-ls-temp-input'); + _lsTempSet = document.getElementById('devices-ls-temp-set'); + _roomLightToggle = document.getElementById('devices-room-light-toggle'); _roomLightLabel = document.getElementById('devices-room-light-label'); @@ -1292,6 +1345,405 @@ const DevicesManager = (function () { } } + // ===================================================================== + // Lightsheet live panel (Manual view) + // ===================================================================== + + /** Gate ALL lasers off via the Laser "ALL OFF" config-group preset. + * Updates the indicator span from the actual API result (not a static label). + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserOff() { + cacheDom(); + try { + const res = await fetch('/api/devices/laser/off', { method: 'POST' }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? 'OFF (brightfield)' : 'warning: state unknown'; + } + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser off call failed:', err); + } + } + + async function toggleLightsheetStream() { + if (!_lsToggle) return; + _lsToggle.disabled = true; + try { + const starting = !_lsStreaming; + const endpoint = _lsStreaming + ? '/api/devices/lightsheet/live/stop' + : '/api/devices/lightsheet/live/start'; + const res = await fetch(endpoint, { method: 'POST' }); + if (!res.ok) { + const detail = await res.text(); + console.error('Lightsheet toggle failed:', detail); + if (_lsMeta) _lsMeta.textContent = `error: ${res.status}`; + return; + } + const data = await res.json(); + applyLightsheetState(!!data.streaming); + // Gate lasers off whenever live starts — brightfield-safe by default. + if (starting && data.streaming) setLaserOff(); + } catch (err) { + console.error('Lightsheet toggle failed:', err); + if (_lsMeta) _lsMeta.textContent = `error: ${err}`; + } finally { + _lsToggle.disabled = false; + } + } + + function applyLightsheetState(streaming) { + _lsStreaming = streaming; + if (_lsToggle) { + _lsToggle.textContent = streaming ? 'Stop' : 'Start'; + _lsToggle.classList.toggle('active', streaming); + } + if (_lsLed) { + _lsLed.classList.toggle('live', streaming); + _lsLed.classList.remove('stale'); + } + if (!streaming) { + _lsHasFrame = false; + _lsFrameTimes = []; + if (_lsImg) _lsImg.classList.remove('has-frame'); + if (_lsPlaceholder) _lsPlaceholder.hidden = false; + if (_lsMeta) _lsMeta.textContent = 'stream off'; + if (_lsStaleTimer) { clearTimeout(_lsStaleTimer); _lsStaleTimer = null; } + resetLightsheetZoom(); + } else { + _lsFrameTimes = []; + if (_lsMeta) _lsMeta.textContent = 'waiting…'; + } + } + + function handleLightsheetFrame(payload) { + if (!payload || !payload.jpeg_b64 || !_lsImg) return; + _lsImg.src = `data:${payload.mime || 'image/jpeg'};base64,${payload.jpeg_b64}`; + if (!_lsHasFrame) { + _lsHasFrame = true; + _lsImg.classList.add('has-frame'); + if (_lsPlaceholder) _lsPlaceholder.hidden = true; + } + const now = performance.now(); + _lsLastFrameTs = Date.now(); + _lsFrameTimes.push(now); + if (_lsFrameTimes.length > _LS_FPS_WINDOW) _lsFrameTimes.shift(); + if (_lsLed) { + _lsLed.classList.add('live'); + _lsLed.classList.remove('stale'); + } + if (_lsMeta) { + const shape = payload.shape || []; + const dims = shape.length === 2 ? `${shape[1]}×${shape[0]}` : ''; + const fps = computeLightsheetFps(); + _lsMeta.textContent = dims + ? `${dims} · ${fps != null ? fps.toFixed(1) + ' fps' : '…'}` + : (fps != null ? `${fps.toFixed(1)} fps` : 'live'); + } + scheduleLightsheetStaleCheck(); + } + + function computeLightsheetFps() { + const n = _lsFrameTimes.length; + if (n < 2) return null; + const span = _lsFrameTimes[n - 1] - _lsFrameTimes[0]; + if (span <= 0) return null; + return ((n - 1) * 1000) / span; + } + + function scheduleLightsheetStaleCheck() { + if (_lsStaleTimer) clearTimeout(_lsStaleTimer); + _lsStaleTimer = setTimeout(() => { + const age = (Date.now() - _lsLastFrameTs) / 1000; + if (_lsMeta) _lsMeta.textContent = `last frame ${age.toFixed(1)}s ago`; + if (_lsLed) _lsLed.classList.add('stale'); + }, 1500); + } + + async function syncInitialLightsheetState() { + try { + const res = await fetch('/api/devices/lightsheet/live/status'); + if (!res.ok) return; + const data = await res.json(); + applyLightsheetState(!!data.streaming); + } catch (err) { + console.debug('lightsheet status check failed:', err); + } + } + + // ---- Lightsheet zoom / pan (mirrors camera zoom/pan) ---------------- + function applyLightsheetTransform() { + if (!_lsImg) return; + _lsImg.style.transform = + `translate(${_lsTx}px, ${_lsTy}px) scale(${_lsZoom})`; + } + + function resetLightsheetZoom() { + _lsZoom = 1; + _lsTx = 0; + _lsTy = 0; + applyLightsheetTransform(); + if (_lsStage) _lsStage.classList.remove('camera-zoomed', 'camera-panning'); + } + + function clampLightsheetPan() { + if (!_lsStage) return; + const rect = _lsStage.getBoundingClientRect(); + const maxX = (rect.width * (_lsZoom - 1)) / 2; + const maxY = (rect.height * (_lsZoom - 1)) / 2; + _lsTx = Math.max(-maxX, Math.min(maxX, _lsTx)); + _lsTy = Math.max(-maxY, Math.min(maxY, _lsTy)); + } + + function onLightsheetWheel(event) { + if (!_lsStage) return; + event.preventDefault(); + const rect = _lsStage.getBoundingClientRect(); + const cx = event.clientX - rect.left - rect.width / 2; + const cy = event.clientY - rect.top - rect.height / 2; + const oldZoom = _lsZoom; + const factor = event.deltaY < 0 ? _CAM_ZOOM_STEP : 1 / _CAM_ZOOM_STEP; + const newZoom = Math.max(_CAM_ZOOM_MIN, Math.min(_CAM_ZOOM_MAX, oldZoom * factor)); + if (newZoom === oldZoom) return; + const ratio = newZoom / oldZoom; + _lsTx = cx - (cx - _lsTx) * ratio; + _lsTy = cy - (cy - _lsTy) * ratio; + _lsZoom = newZoom; + if (Math.abs(_lsZoom - 1) < 0.001) { resetLightsheetZoom(); return; } + clampLightsheetPan(); + applyLightsheetTransform(); + _lsStage.classList.add('camera-zoomed'); + } + + function onLightsheetPointerDown(event) { + if (event.button !== 0) return; + if (_lsZoom <= 1) return; + _lsPanLast = { x: event.clientX, y: event.clientY }; + try { _lsStage.setPointerCapture(event.pointerId); } catch (_) {} + _lsStage.classList.add('camera-panning'); + event.preventDefault(); + } + + function onLightsheetPointerMove(event) { + if (!_lsPanLast) return; + _lsTx += event.clientX - _lsPanLast.x; + _lsTy += event.clientY - _lsPanLast.y; + _lsPanLast = { x: event.clientX, y: event.clientY }; + clampLightsheetPan(); + applyLightsheetTransform(); + } + + function onLightsheetPointerEnd(event) { + if (!_lsPanLast) return; + _lsPanLast = null; + try { _lsStage.releasePointerCapture(event.pointerId); } catch (_) {} + if (_lsStage) _lsStage.classList.remove('camera-panning'); + } + + function onLightsheetDoubleClick(event) { + if (_lsZoom !== 1 || _lsTx !== 0 || _lsTy !== 0) { + event.preventDefault(); + resetLightsheetZoom(); + } + } + + // ---- Lightsheet live params (debounced) ----------------------------- + function postLightsheetParams() { + fetch('/api/devices/lightsheet/live/params', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ galvo: _lsGalvo, piezo: _lsPiezo, exposure: _lsExposure }), + }).catch(err => console.debug('lightsheet params post failed:', err)); + } + + function scheduleLightsheetParamPost() { + if (_lsParamTimer) clearTimeout(_lsParamTimer); + _lsParamTimer = setTimeout(postLightsheetParams, 120); + } + + function onGalvoInput(src) { + const v = parseFloat(src.value); + if (isNaN(v)) return; + _lsGalvo = v; + // Sync the sibling control + if (src === _lsGalvoSlider && _lsGalvoNum) _lsGalvoNum.value = v; + if (src === _lsGalvoNum && _lsGalvoSlider) _lsGalvoSlider.value = v; + scheduleLightsheetParamPost(); + } + + function onPiezoInput(src) { + const v = parseFloat(src.value); + if (isNaN(v)) return; + _lsPiezo = v; + if (src === _lsPiezoSlider && _lsPiezoNum) _lsPiezoNum.value = v; + if (src === _lsPiezoNum && _lsPiezoSlider) _lsPiezoSlider.value = v; + scheduleLightsheetParamPost(); + } + + function onExposureInput() { + const v = parseFloat(_lsExposureNum && _lsExposureNum.value); + if (isNaN(v) || v < 1) return; + _lsExposure = v; + scheduleLightsheetParamPost(); + } + + // ---- Illumination toggles ------------------------------------------- + async function postLedPreset(preset) { + try { + await fetch('/api/devices/led/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: preset }), + }); + } catch (err) { console.debug('LED preset failed:', err); } + } + + async function toggleCamLedMode() { + _lsCamLedOn = !_lsCamLedOn; + if (_lsCamLed) { + _lsCamLed.classList.toggle('ls-illum-btn--active', _lsCamLedOn); + _lsCamLed.setAttribute('aria-pressed', _lsCamLedOn ? 'true' : 'false'); + } + try { + await fetch('/api/devices/camera/led_mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ use_led: _lsCamLedOn }), + }); + } catch (err) { console.debug('cam LED mode failed:', err); } + } + + async function toggleManualRoomLight() { + const nextState = _roomLightState === 'on' ? 'off' : 'on'; + if (_lsRoomLightBtn) { + _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', nextState === 'on'); + _lsRoomLightBtn.setAttribute('aria-pressed', nextState === 'on' ? 'true' : 'false'); + } + try { + const res = await fetch('/api/devices/room_light/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: nextState }), + }); + if (res.ok) { + const data = await res.json(); + _roomLightState = data.state || nextState; + if (_lsRoomLightBtn) { + const on = _roomLightState === 'on'; + _lsRoomLightBtn.classList.toggle('ls-illum-btn--active', on); + _lsRoomLightBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); + } + } + } catch (err) { console.debug('manual room light toggle failed:', err); } + } + + // ---- Acquire -------------------------------------------------------- + async function runLightsheetAcquire(mode) { + const btn = mode === 'burst' ? _lsBurstBtn : _lsSnapVolBtn; + if (btn) { btn.disabled = true; btn.textContent = 'acquiring…'; } + try { + let res; + if (mode === 'burst') { + res = await fetch('/api/devices/acquire/burst', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ frames: 10, mode: 'brightfield', + num_slices: 50, exposure_ms: _lsExposure, + laser_config: 'ALL OFF', + piezo_center: _lsPiezo, + galvo_center: _lsGalvo }), + }); + } else { + res = await fetch('/api/devices/acquire/volume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure, + laser_config: 'ALL OFF', + piezo_center: _lsPiezo, + galvo_center: _lsGalvo }), + }); + } + if (!res.ok) { + console.error('acquire failed:', res.status, await res.text()); + return; + } + const data = await res.json(); + if (_lsLastcap) _lsLastcap.hidden = false; + if (_lsLastcapRef) { + _lsLastcapRef.textContent = data.volume_path || data.path || data.id || 'done'; + } + } catch (err) { + console.error('acquire error:', err); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = mode === 'burst' ? 'Burst' : 'Snap Volume'; + } + } + } + + // ---- Temperature set (rail copy, delegates to same API) ------------- + async function setLightsheetTemperature() { + if (!_lsTempInput) return; + const target = parseFloat(_lsTempInput.value); + if (isNaN(target) || target < 0 || target > 99.9) return; + try { + await fetch('/api/devices/temperature/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_c: target }), + }); + } catch (err) { console.debug('ls temp set failed:', err); } + } + + function setupManualWiring() { + if (!_lsToggle) return; + _lsToggle.addEventListener('click', toggleLightsheetStream); + applyLightsheetState(false); + + // Param controls — slider ↔ number sync + debounced POST + if (_lsGalvoSlider) _lsGalvoSlider.addEventListener('input', () => onGalvoInput(_lsGalvoSlider)); + if (_lsGalvoNum) _lsGalvoNum.addEventListener('input', () => onGalvoInput(_lsGalvoNum)); + if (_lsPiezoSlider) _lsPiezoSlider.addEventListener('input', () => onPiezoInput(_lsPiezoSlider)); + if (_lsPiezoNum) _lsPiezoNum.addEventListener('input', () => onPiezoInput(_lsPiezoNum)); + if (_lsExposureNum) _lsExposureNum.addEventListener('input', onExposureInput); + + // Illumination + if (_lsLedOpen) _lsLedOpen.addEventListener('click', () => postLedPreset('Open')); + if (_lsLedClosed) _lsLedClosed.addEventListener('click', () => postLedPreset('Closed')); + if (_lsCamLed) _lsCamLed.addEventListener('click', toggleCamLedMode); + if (_lsRoomLightBtn) _lsRoomLightBtn.addEventListener('click', toggleManualRoomLight); + + // Acquire + if (_lsSnapVolBtn) _lsSnapVolBtn.addEventListener('click', () => runLightsheetAcquire('volume')); + if (_lsBurstBtn) _lsBurstBtn.addEventListener('click', () => runLightsheetAcquire('burst')); + + // Temperature + if (_lsTempSet) _lsTempSet.addEventListener('click', setLightsheetTemperature); + if (_lsTempInput) { + _lsTempInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); setLightsheetTemperature(); } + }); + } + + // Zoom / pan + if (_lsStage) { + _lsStage.addEventListener('wheel', onLightsheetWheel, { passive: false }); + _lsStage.addEventListener('pointerdown', onLightsheetPointerDown); + _lsStage.addEventListener('pointermove', onLightsheetPointerMove); + _lsStage.addEventListener('pointerup', onLightsheetPointerEnd); + _lsStage.addEventListener('pointercancel', onLightsheetPointerEnd); + _lsStage.addEventListener('dblclick', onLightsheetDoubleClick); + } + + // Subscribe to LIGHTSHEET_FRAME events + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame); + } + + syncInitialLightsheetState(); + } + // ===================================================================== // Room-light toggle // ===================================================================== @@ -1506,6 +1958,21 @@ const DevicesManager = (function () { if (viewName === 'optical3d' && typeof Occupancy3DManager !== 'undefined') { Occupancy3DManager.init(); } + // Initialize temperature graph for the active view. The TemperatureGraph + // is a singleton; reinit on view switch ensures only one graph target + // is live at a time. ClientEventBus.off/on in TemperatureGraph.init + // makes re-init safe (idempotent). + if (window.TemperatureGraph) { + if (viewName === 'manual') { + const el = document.getElementById('devices-ls-tempgraph'); + if (el) TemperatureGraph.init(el, 'current'); + } else { + const el = document.getElementById('devices-temp-graph'); + if (el) TemperatureGraph.init(el, 'current'); + } + } + // Entering Manual view — gate lasers off immediately (brightfield-safe). + if (viewName === 'manual') setLaserOff(); } function setupViewSwitcher() { @@ -1552,10 +2019,9 @@ const DevicesManager = (function () { cacheDom(); setupViewSwitcher(); setupCameraWiring(); + setupManualWiring(); setupRoomLight(); setupTemperature(); - const _tgEl = document.getElementById('devices-temp-graph'); - if (_tgEl && window.TemperatureGraph) TemperatureGraph.init(_tgEl, 'current'); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); @@ -1577,13 +2043,14 @@ const DevicesManager = (function () { document.addEventListener('keydown', onMapKeyDown); setStatus('stale', 'waiting', 'no payload yet'); syncInitialCameraState(); - // Stop the camera stream if the tab is closed while it's running, - // so MMCore isn't held by a disconnected browser. + // Stop the camera and lightsheet streams if the tab is closed while + // running, so MMCore isn't held by a disconnected browser. window.addEventListener('beforeunload', () => { if (_camStreaming) { - try { - navigator.sendBeacon('/api/devices/bottom_camera/stream/stop'); - } catch (_) {} + try { navigator.sendBeacon('/api/devices/bottom_camera/stream/stop'); } catch (_) {} + } + if (_lsStreaming) { + try { navigator.sendBeacon('/api/devices/lightsheet/live/stop'); } catch (_) {} } }); } diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index bcbef78f..cd2ba5d5 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -104,7 +104,8 @@ function handleMessage(msg) { // client event bus so the Devices tab gets the payload. if (msg.event_type !== 'DEVICE_STATE_UPDATE' && msg.event_type !== 'BOTTOM_CAMERA_FRAME' && - msg.event_type !== 'TEMPERATURE_UPDATE') { + msg.event_type !== 'TEMPERATURE_UPDATE' && + msg.event_type !== 'LIGHTSHEET_FRAME') { handleFullEvent({ event_type: msg.event_type, data: msg.data, diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 7e1f0726..1b1dba8e 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -446,6 +446,7 @@

Device Map +

@@ -714,6 +715,124 @@

Properties

+ + +
diff --git a/tests/test_lightsheet_client.py b/tests/test_lightsheet_client.py new file mode 100644 index 00000000..c02f2129 --- /dev/null +++ b/tests/test_lightsheet_client.py @@ -0,0 +1,62 @@ +from gently.hardware.dispim.client import DiSPIMMicroscope + + +async def test_set_params_posts_body(monkeypatch): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + + async def fake_post(path, body): + sent["path"] = path + sent["body"] = body + return {"params": body} + + m._api_post = fake_post # confirm the real low-level POST helper name + res = await m.set_lightsheet_live_params(galvo=1.0, piezo=42.0, exposure=15.0) + assert sent["path"] == "/api/lightsheet/live/params" + assert sent["body"] == {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0} + assert res == {"params": {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0}} + + +def test_stream_lightsheet_is_async_generator(): + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + import inspect + + assert inspect.isasyncgenfunction(m.stream_lightsheet) + + +# --------------------------------------------------------------------------- +# set_laser_config / get_laser_configs +# --------------------------------------------------------------------------- + + +async def test_set_laser_config_posts_correct_path_and_body(): + """set_laser_config posts to /api/laser/config with {"config": }.""" + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + sent = {} + + async def fake_post(path, body): + sent["path"] = path + sent["body"] = body + return {"success": True, "config": body["config"]} + + m._api_post = fake_post + res = await m.set_laser_config("ALL OFF") + assert sent["path"] == "/api/laser/config" + assert sent["body"] == {"config": "ALL OFF"} + assert res["success"] is True + assert res["config"] == "ALL OFF" + + +async def test_get_laser_configs_gets_correct_path(): + """get_laser_configs GETs /api/laser/configs.""" + m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) + fetched = {} + + async def fake_get(path): + fetched["path"] = path + return {"configs": ["488 only", "ALL OFF"]} + + m._api_get = fake_get + res = await m.get_laser_configs() + assert fetched["path"] == "/api/laser/configs" + assert res == {"configs": ["488 only", "ALL OFF"]} diff --git a/tests/test_lightsheet_event.py b/tests/test_lightsheet_event.py new file mode 100644 index 00000000..277b14f1 --- /dev/null +++ b/tests/test_lightsheet_event.py @@ -0,0 +1,22 @@ +"""Tests for LIGHTSHEET_FRAME event type""" + +from gently.core.event_bus import _NO_HISTORY_TYPES, EventBus, EventType + + +def test_lightsheet_frame_event_exists(): + """LIGHTSHEET_FRAME event type should exist with correct name""" + assert EventType.LIGHTSHEET_FRAME.name == "LIGHTSHEET_FRAME" + + +def test_lightsheet_frame_excluded_from_history(): + """LIGHTSHEET_FRAME should be excluded from event history (high-volume telemetry)""" + assert EventType.LIGHTSHEET_FRAME in _NO_HISTORY_TYPES + + +def test_lightsheet_frame_publishes_to_subscriber(): + """Publishing LIGHTSHEET_FRAME should reach subscribers""" + bus = EventBus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.LIGHTSHEET_FRAME, data={"jpeg_b64": "x"}, source="t") + assert seen == [{"jpeg_b64": "x"}] diff --git a/tests/test_lightsheet_monitor.py b/tests/test_lightsheet_monitor.py new file mode 100644 index 00000000..5f3c6bf9 --- /dev/null +++ b/tests/test_lightsheet_monitor.py @@ -0,0 +1,33 @@ +""" +Tests for LightSheetStreamMonitor. + +Mirrors test_bottom_camera_monitor.py structure; uses a FakeScope that yields +three synthetic frames so the test never touches the device layer. +""" + +import asyncio + +import pytest + +from gently.app.lightsheet_monitor import LightSheetStreamMonitor +from gently.core.event_bus import EventType, get_event_bus + + +class FakeScope: + async def stream_lightsheet(self): + for i in range(3): + yield {"t": float(i), "jpeg_b64": f"f{i}"} + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_monitor_publishes_frames(): + bus = get_event_bus() + seen = [] + bus.subscribe(EventType.LIGHTSHEET_FRAME, lambda e: seen.append(e.data)) + mon = LightSheetStreamMonitor(FakeScope(), reconnect_delay_sec=0.01) + await mon.start() + await asyncio.sleep(0.05) + await mon.stop() + assert any(d.get("jpeg_b64") == "f0" for d in seen) + assert mon.running is False diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py new file mode 100644 index 00000000..5cb9debe --- /dev/null +++ b/tests/test_lightsheet_routes.py @@ -0,0 +1,317 @@ +"""Tests for Task 5 lightsheet browser proxy routes. + +Verifies: + - live/start, live/stop, live/status — mirror bottom_camera routes + - live/params — forwards to client.set_lightsheet_live_params + - led/set, laser/off, camera/led_mode, stage/move + - acquire/burst, acquire/volume + - require_control gate (403 without override) +""" + +from unittest.mock import AsyncMock, MagicMock + +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 + + +def _app(client=None, monitor=None): + """Build a TestClient with the lightsheet routes wired up. + + Overrides require_control so that the TestClient (host="testclient") + always gets the control role — isolates route logic from auth. + """ + server = MagicMock() + server.agent_bridge.agent.client = client + server.agent_bridge.agent.lightsheet_monitor = monitor + app = FastAPI() + app.include_router(create_router(server)) + # TestClient host is "testclient" (not loopback) → override to force CONTROL + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +# --------------------------------------------------------------------------- +# live start / stop / status +# --------------------------------------------------------------------------- + + +def test_live_status_no_monitor(): + """GET status returns available=False when monitor is None.""" + r = _app(monitor=None).get("/api/devices/lightsheet/live/status") + assert r.status_code == 200 + assert r.json()["available"] is False + + +def test_live_status_with_monitor(): + monitor = MagicMock() + monitor.running = True + monitor._last_frame_ts = "2026-01-01T00:00:00" + r = _app(monitor=monitor).get("/api/devices/lightsheet/live/status") + assert r.status_code == 200 + body = r.json() + assert body["available"] is True + assert body["streaming"] is True + + +def test_live_start_requires_monitor(): + """POST start with no monitor → 503.""" + r = _app(monitor=None).post("/api/devices/lightsheet/live/start") + assert r.status_code == 503 + + +def test_live_start_calls_monitor_start(): + monitor = MagicMock() + monitor.start = AsyncMock() + monitor.running = True + r = _app(monitor=monitor).post("/api/devices/lightsheet/live/start") + assert r.status_code == 200 + monitor.start.assert_awaited_once() + + +def test_live_stop_no_monitor_returns_200(): + """POST stop with no monitor is idempotent — returns 200 streaming=False.""" + r = _app(monitor=None).post("/api/devices/lightsheet/live/stop") + assert r.status_code == 200 + assert r.json()["streaming"] is False + + +def test_live_stop_calls_monitor_stop(): + monitor = MagicMock() + monitor.stop = AsyncMock() + r = _app(monitor=monitor).post("/api/devices/lightsheet/live/stop") + assert r.status_code == 200 + monitor.stop.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# live/params +# --------------------------------------------------------------------------- + + +def test_live_params_forwards(): + client = MagicMock() + client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) + r = _app(client=client).post( + "/api/devices/lightsheet/live/params", + json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0}, + ) + assert r.status_code == 200 + client.set_lightsheet_live_params.assert_awaited_once_with(galvo=1.0, piezo=40.0, exposure=20.0) + + +def test_live_params_no_client_503(): + server = MagicMock() + server.agent_bridge.agent.client = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + r = TestClient(app).post( + "/api/devices/lightsheet/live/params", + json={"galvo": 1.0}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# led/set +# --------------------------------------------------------------------------- + + +def test_led_set_forwards(): + client = MagicMock() + client.set_led = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/led/set", json={"state": "Open"}) + assert r.status_code == 200 + client.set_led.assert_awaited_once_with("Open") + + +# --------------------------------------------------------------------------- +# laser/off — spec §2.7: must use Laser ALL OFF config, not power setpoint +# --------------------------------------------------------------------------- + + +def test_laser_off_calls_set_laser_config_all_off(): + """laser/off must call set_laser_config("ALL OFF") to gate every line off.""" + client = MagicMock() + client.set_laser_config = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/laser/off") + assert r.status_code == 200 + client.set_laser_config.assert_awaited_once_with("ALL OFF") + + +# --------------------------------------------------------------------------- +# laser/configs — unguarded GET, no require_control +# --------------------------------------------------------------------------- + + +def test_laser_configs_forwards_to_client(): + """GET laser/configs must forward to client.get_laser_configs.""" + client = MagicMock() + client.get_laser_configs = AsyncMock( + return_value={"configs": ["488 only", "561 only", "ALL OFF", "ALL ON"]} + ) + r = _app(client=client).get("/api/devices/laser/configs") + assert r.status_code == 200 + assert r.json()["configs"] == ["488 only", "561 only", "ALL OFF", "ALL ON"] + client.get_laser_configs.assert_awaited_once() + + +def test_laser_configs_no_require_control(): + """GET laser/configs is unguarded — available even without control override.""" + from fastapi import FastAPI + + from gently.ui.web.routes.data import create_router + + server = MagicMock() + client_mock = MagicMock() + client_mock.get_laser_configs = AsyncMock(return_value={"configs": ["ALL OFF"]}) + server.agent_bridge.agent.client = client_mock + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # Deliberately do NOT override require_control — route must not need it + from fastapi.testclient import TestClient + + r = TestClient(app).get("/api/devices/laser/configs") + assert r.status_code == 200 + + +# --------------------------------------------------------------------------- +# camera/led_mode +# --------------------------------------------------------------------------- + + +def test_camera_led_mode_forwards(): + client = MagicMock() + client.set_camera_led_mode = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/camera/led_mode", json={"use_led": True}) + assert r.status_code == 200 + client.set_camera_led_mode.assert_awaited_once_with(True) + + +# --------------------------------------------------------------------------- +# stage/move +# --------------------------------------------------------------------------- + + +def test_stage_move_forwards(): + client = MagicMock() + client.move_to_position = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/stage/move", json={"x": 100.0, "y": 200.0}) + assert r.status_code == 200 + client.move_to_position.assert_awaited_once_with(100.0, 200.0) + + +def test_stage_move_missing_xy_400(): + client = MagicMock() + client.move_to_position = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/stage/move", json={"x": 1.0}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# acquire/burst +# --------------------------------------------------------------------------- + + +def test_acquire_burst_forwards(): + """Basic burst forwards without optional params — no laser_config/piezo/galvo in body.""" + client = MagicMock() + client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) + r = _app(client=client).post( + "/api/devices/acquire/burst", + json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}, + ) + assert r.status_code == 200 and r.json().get("request_id") == "b1" + client.acquire_burst.assert_awaited_once_with( + frames=60, mode="1hz", num_slices=1, exposure_ms=5.0 + ) + + +def test_acquire_burst_forwards_laser_config_and_focal_plane(): + """acquire/burst forwards laser_config, piezo_center, galvo_center when present.""" + client = MagicMock() + client.acquire_burst = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/acquire/burst", + json={ + "frames": 10, + "mode": "brightfield", + "num_slices": 50, + "exposure_ms": 20.0, + "laser_config": "ALL OFF", + "piezo_center": 55.0, + "galvo_center": 1.5, + }, + ) + assert r.status_code == 200 + client.acquire_burst.assert_awaited_once_with( + frames=10, + mode="brightfield", + num_slices=50, + exposure_ms=20.0, + laser_config="ALL OFF", + piezo_center=55.0, + galvo_center=1.5, + ) + + +# --------------------------------------------------------------------------- +# acquire/volume +# --------------------------------------------------------------------------- + + +def test_acquire_volume_forwards(): + """Basic volume forwards without optional params.""" + client = MagicMock() + client.acquire_volume = AsyncMock(return_value={"success": True, "request_id": "v1"}) + r = _app(client=client).post( + "/api/devices/acquire/volume", + json={"num_slices": 50, "exposure_ms": 10.0}, + ) + assert r.status_code == 200 + client.acquire_volume.assert_awaited_once_with(num_slices=50, exposure_ms=10.0) + + +def test_acquire_volume_forwards_laser_config_and_focal_plane(): + """acquire/volume forwards laser_config, piezo_center, galvo_center when present.""" + client = MagicMock() + client.acquire_volume = AsyncMock(return_value={"success": True}) + r = _app(client=client).post( + "/api/devices/acquire/volume", + json={ + "num_slices": 50, + "exposure_ms": 20.0, + "laser_config": "ALL OFF", + "piezo_center": 55.0, + "galvo_center": 1.5, + }, + ) + assert r.status_code == 200 + client.acquire_volume.assert_awaited_once_with( + num_slices=50, + exposure_ms=20.0, + laser_config="ALL OFF", + piezo_center=55.0, + galvo_center=1.5, + ) + + +# --------------------------------------------------------------------------- +# require_control gate — 403 WITHOUT override +# --------------------------------------------------------------------------- + + +def test_require_control_gate_403(): + """Without the dependency override, TestClient host is not loopback → 403.""" + server = MagicMock() + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # Do NOT override require_control here + r = TestClient(app).post("/api/devices/lightsheet/live/start") + assert r.status_code == 403 diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py new file mode 100644 index 00000000..14b320f9 --- /dev/null +++ b/tests/test_lightsheet_streamer.py @@ -0,0 +1,137 @@ +# tests/test_lightsheet_streamer.py +import sys +from unittest.mock import MagicMock + +# 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() + +# Patch bluesky.RunEngine specifically +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +import asyncio # noqa: E402 + +import numpy as np # noqa: E402 +import pytest # noqa: E402 + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +class FakeCore: + def __init__(self): + self.running = False + self.exposure = None + self.cam = None + self._frame = np.full((64, 64), 1000, dtype=np.uint16) + self.started = 0 + self.stopped = 0 + + 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 getLastImage(self): + return self._frame + + +class FakeAxis: + def __init__(self): + self.pos = None + + def setPosition(self, v): + self.pos = v + + +class FakeScanner: + def __init__(self): + self.sa_offset_y = FakeAxis() + self.name = "Scanner" + self.state = None + + def set_spim_state(self, s): + self.state = s + + +class FakePiezo(FakeAxis): + def __init__(self): + super().__init__() + self.name = "Piezo" + self.state = None + + def set_spim_state(self, s): + self.state = s + + +def _make_dl(): + dl = DeviceLayerServer.__new__(DeviceLayerServer) + dl.system = type("S", (), {"core": FakeCore()})() + dl.devices = { + "camera": type("C", (), {"name": "HamCam1"})(), + "scanner": FakeScanner(), + "piezo": FakePiezo(), + } + # Park-guard state (park-guard fix: these are checked by _park_lightsheet_sync) + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +@pytest.mark.asyncio +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} + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert img is not None and img.shape == (64, 64) + assert dl.system.core.running is True # sequence started + assert dl.devices["piezo"].pos == 40.0 # piezo parked + assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked + + +@pytest.mark.asyncio +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} + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + starts = dl.system.core.started + dl._ls_params["exposure"] = 30.0 # exposure change + await asyncio.to_thread(dl._grab_lightsheet_frame_sync) + assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1 + assert dl.system.core.exposure == 30.0 diff --git a/tests/test_temperature_event.py b/tests/test_temperature_event.py index 388976aa..80e6601f 100644 --- a/tests/test_temperature_event.py +++ b/tests/test_temperature_event.py @@ -2,12 +2,12 @@ Test suite for TEMPERATURE_UPDATE event type """ -from gently.core.event_bus import EventType, EventBus +from gently.core.event_bus import EventBus, EventType def test_temperature_update_event_exists(): """Verify TEMPERATURE_UPDATE enum member exists with correct name""" - assert hasattr(EventType, 'TEMPERATURE_UPDATE') + assert hasattr(EventType, "TEMPERATURE_UPDATE") event_type = EventType.TEMPERATURE_UPDATE assert event_type.name == "TEMPERATURE_UPDATE" diff --git a/tests/test_temperature_route.py b/tests/test_temperature_route.py index 4cd02cf3..5e71a9ea 100644 --- a/tests/test_temperature_route.py +++ b/tests/test_temperature_route.py @@ -1,26 +1,41 @@ -from unittest.mock import MagicMock from pathlib import Path +from unittest.mock import MagicMock + from fastapi import FastAPI from fastapi.testclient import TestClient + from gently.ui.web.routes.temperature import create_router def _server(samples, sessions=(("sess-1", True),)): store = MagicMock() store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] - store._session_dir.side_effect = lambda sid: Path("/x") if any(sid == s for s, _ in sessions) else None + store._session_dir.side_effect = lambda sid: ( + Path("/x") if any(sid == s for s, _ in sessions) else None + ) store.read_temperature_log.return_value = samples - srv = MagicMock(); srv.gently_store = store + srv = MagicMock() + srv.gently_store = store return srv, store def _client(server): - app = FastAPI(); app.include_router(create_router(server)) + app = FastAPI() + app.include_router(create_router(server)) return TestClient(app) def test_history_returns_samples(): - srv, store = _server([{"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}]) + srv, store = _server( + [ + { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.0, + "setpoint_c": 32.0, + "state": "heating", + } + ] + ) r = _client(srv).get("/api/temperature/sess-1/history") assert r.status_code == 200 body = r.json() diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py index 4c79bf76..8a221aef 100644 --- a/tests/test_temperature_sampler.py +++ b/tests/test_temperature_sampler.py @@ -5,11 +5,11 @@ `file_store.create_session(name="s")` is adapted to `file_store.create_session(str(uuid.uuid4()), name="s")` to match the real API. """ -import asyncio + import uuid -from gently.core.event_bus import EventBus, EventType from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp +from gently.core.event_bus import EventBus, EventType class FakeScope: @@ -32,7 +32,9 @@ def _capture(bus): async def test_tick_appends_emits_and_sets_latest(file_store): sid = file_store.create_session(str(uuid.uuid4()), name="s") - scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) bus = EventBus() seen = _capture(bus) s = TemperatureSampler(scope, file_store, lambda: sid) @@ -68,15 +70,22 @@ async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): def test_temperature_stamp_shapes(): assert temperature_stamp(None) is None - assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == { - "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00", + assert temperature_stamp( + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) == { + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + "sampled_at": "2026-06-27T10:00:00+00:00", } async def test_stale_latest_cleared_when_no_active_session(file_store): """After a successful tick, a subsequent tick with no active session resets latest to None.""" sid = file_store.create_session(str(uuid.uuid4()), name="s") - scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) bus = EventBus() # Successful first tick. @@ -96,7 +105,9 @@ async def test_stale_latest_cleared_on_poll_failure(file_store): bus = EventBus() # Successful first tick. - good_scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + good_scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) s = TemperatureSampler(good_scope, file_store, lambda: sid) await s._tick(bus) assert s.latest is not None diff --git a/tests/test_temperature_sampler_wiring.py b/tests/test_temperature_sampler_wiring.py index 9564d775..3eec8148 100644 --- a/tests/test_temperature_sampler_wiring.py +++ b/tests/test_temperature_sampler_wiring.py @@ -4,7 +4,6 @@ heavy integration concern verified end-to-end in a later task. DeviceStateMonitor's own wiring is likewise not unit-tested at this level. """ -from gently.app.temperature_sampler import TemperatureSampler def test_agent_initializes_temperature_sampler_attribute(): @@ -22,9 +21,5 @@ def test_agent_initializes_temperature_sampler_attribute(): "Could not locate gently/app/agent.py via importlib" ) text = Path(spec.origin).read_text(encoding="utf-8") - assert "temperature_sampler" in text, ( - "agent.py has no 'temperature_sampler' attribute" - ) - assert "TemperatureSampler(" in text, ( - "agent.py never constructs a TemperatureSampler" - ) + assert "temperature_sampler" in text, "agent.py has no 'temperature_sampler' attribute" + assert "TemperatureSampler(" in text, "agent.py never constructs a TemperatureSampler" diff --git a/tests/test_temperature_stamp.py b/tests/test_temperature_stamp.py index 4784880b..4b88fcdb 100644 --- a/tests/test_temperature_stamp.py +++ b/tests/test_temperature_stamp.py @@ -52,7 +52,9 @@ def test_volume_metadata_carries_temperature(file_store): tifffile_mock = MagicMock() with patch.dict(sys.modules, {"tifffile": tifffile_mock}): with patch.object(file_store, "_generate_projection", return_value=None): - file_store.put_volume(sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp}) + file_store.put_volume( + sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp} + ) meta = file_store.get_volume_meta(sid, emb, 0) assert meta["metadata"]["temperature"]["water_c"] == 28.4 @@ -77,9 +79,7 @@ def test_burst_stamp_writes_temperature(file_store): embryo.num_slices = 2 embryo.exposure_ms = 50.0 - frames_data = [ - {"volume": np.zeros((2, 4, 4), dtype="uint16"), "acquired_at_epoch": None} - ] + frames_data = [{"volume": np.zeros((2, 4, 4), dtype="uint16"), "acquired_at_epoch": None}] tifffile_mock = MagicMock() with patch.dict(sys.modules, {"tifffile": tifffile_mock}): diff --git a/tests/test_temperature_store.py b/tests/test_temperature_store.py index 9e5d5fbe..dfc36fd9 100644 --- a/tests/test_temperature_store.py +++ b/tests/test_temperature_store.py @@ -1,12 +1,11 @@ """Tests for FileStore temperature log methods.""" -import pytest - def _new_session(file_store): """Create a test session and return its ID.""" # Use UUID-based session ID for uniqueness import uuid + session_id = str(uuid.uuid4()) return file_store.create_session(session_id, name="temp-test") @@ -14,8 +13,14 @@ def _new_session(file_store): def test_append_and_read_roundtrip(file_store): """Test appending and reading temperature samples.""" sid = _new_session(file_store) - file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}) - file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}, + ) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}, + ) rows = file_store.read_temperature_log(sid) assert [r["water_c"] for r in rows] == [28.0, 28.3] @@ -23,8 +28,12 @@ def test_append_and_read_roundtrip(file_store): def test_read_since_filters(file_store): """Test that read_temperature_log filters by since parameter.""" sid = _new_session(file_store) - for i, t in enumerate(["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]): - file_store.append_temperature_sample(sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"}) + for i, t in enumerate( + ["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"] + ): + file_store.append_temperature_sample( + sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"} + ) rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") assert [r["water_c"] for r in rows] == [29.0, 30.0] @@ -36,16 +45,20 @@ def test_read_unknown_session_is_empty(file_store): def test_truncated_trailing_line_is_skipped(file_store, tmp_path): """A truncated trailing line (e.g. after a crash mid-append) is skipped gracefully.""" - import uuid sid = _new_session(file_store) # Append two valid samples via the normal API. - file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.1, "setpoint_c": 32.0, "state": "heating"}) - file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:02+00:00", "water_c": 28.2, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.1, "setpoint_c": 32.0, "state": "heating"}, + ) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:02+00:00", "water_c": 28.2, "setpoint_c": 32.0, "state": "heating"}, + ) # Append a raw truncated line directly to the JSONL file. - from gently.core.file_store import FileStore # Locate the temperature.jsonl via the store's internal path. sd = file_store._session_dir(sid) log_path = sd / "temperature.jsonl"