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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Manual mode B2 — dual-camera + laser-preset browser + timelapse form Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.

**Goal:** Extend B1's single-camera manual mode with a laser-preset browser, dual-camera (side A/B) config, and a manual timelapse-config form — building the headless parts now (live dual-view + real acquisition are rig-deferred).

**Architecture:** New `require_control` proxy routes wrapping existing client/device-layer + agent-tool paths; device_factory registers a second camera defensively; UI additions to `#devices-view-manual` / `devices.js`.

## Global Constraints
- HEADLESS-buildable parts only; mark RIG-DEFERRED parts (live dual view, real acquisition/timelapse) as noted in the spec — don't fake hardware.
- Backward compatible: single-camera rigs must still start (defensive HamCam2 registration); the manual-view-entry laser-off safety (B1 I3) stays intact.
- Laser preset list already exists: `GET /api/devices/laser/configs` (data.py:526). Reuse it; add only the set proxy + UI.
- Proxy routes mirror the existing `routes/data.py` `require_control` pattern (e.g. `/api/devices/laser/off` :508). Device-layer/client calls mirror existing ones.
- UI extends `#devices-view-manual` (index.html ~:720-835) + `DevicesManager` in `devices.js`.
- Git hygiene: stage only your files by explicit path; never `git add -A`.

---

### Task 1: Laser-preset browser
**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/laser/config` `require_control` → `client.set_laser_config(name)`, mirror `/laser/off` :508); `gently/ui/web/templates/index.html` (the Illumination group ~:800 — replace the static `#devices-ls-laser-status` indicator with a `<select id="devices-laser-preset">` + keep a status line); `gently/ui/web/static/js/devices.js` (populate the select from `GET /api/devices/laser/configs` on manual-view entry; on change `POST /api/devices/laser/config {config}`; default/show "ALL OFF"). Test: `tests/test_laser_preset_route.py`.
- [ ] Confirm `client.set_laser_config` + the proxy pattern (`/laser/off`) + how devices.js fetches/posts + caches DOM. Add the set proxy + the dropdown; keep the I3 laser-off-on-entry safety (selecting a preset is an explicit override).
- [ ] TDD: the POST proxy calls `set_laser_config(name)` (TestClient + mock client); bad/missing config → 400. `node --check devices.js`. Build a Chrome harness (stub `/configs` + `/config`) for the controller to audit the dropdown. `pytest tests/test_laser_preset_route.py -v`; `pytest -q` clean. Commit `feat(b2): laser-preset browser (set proxy + manual-mode dropdown)`.

### Task 2: Dual-camera config
**Files:** Modify `gently/hardware/dispim/device_factory.py` (register `DiSPIMCamera("HamCam2")` as `devices["camera_b"]` DEFENSIVELY — only if in the core's loaded devices; `default_config["camera_b_name"]="HamCam2"`); `gently/hardware/dispim/device_layer.py` (add `side` to `_ls_params` (default 'A') + `handle_lightsheet_params`; `_ensure_lightsheet_sequence_sync` selects `camera`/`camera_b` by side, restart on side change like exposure; a `handle_get_cameras` + route `GET /api/cameras`); `gently/ui/web/routes/data.py` (`GET /api/devices/cameras` proxy + thread `side` through `POST /api/devices/lightsheet/live/params`); `index.html` + `devices.js` (a Side A/B selector). Test: `tests/test_dual_camera_factory.py`, `tests/test_lightsheet_side_param.py`.
- [ ] Confirm device_factory camera creation (:103-107) + `_ls_params`/`_ensure_lightsheet_sequence_sync` (~:177, :1018-1037) + `handle_lightsheet_params` (:1169). Register camera_b only if present in `core` (skip+log otherwise). Thread `side`; restart sequence on side change.
- [ ] TDD (fake core with HamCam2 → camera_b created; fake core without → skipped, no crash; side param selects the camera + triggers restart). `node --check`. Chrome harness for the side selector. `pytest tests/test_dual_camera_factory.py tests/test_lightsheet_side_param.py -v`; `pytest -q` clean. Commit `feat(b2): dual-camera config (HamCam2 + side selector)`.

### Task 3: Timelapse config form
**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/timelapse/start` `require_control` → resolve the orchestrator/agent path and call `start_adaptive_timelapse(...)` with validated params: interval_seconds/stop_condition/embryo_ids/condition_value/monitoring_mode + the volume geometry passed through; mirror how the agent tool resolves the orchestrator); `index.html` (a collapsible "Timelapse" panel in the manual rail — cadence/stop/embryos/monitoring_mode + num_slices/exposure/galvo±/piezo±/laser_config); `devices.js` (read `GET /api/devices/scan_geometry` + `/api/devices/laser/configs` for defaults; submit → the new proxy). Test: `tests/test_timelapse_start_route.py`.
- [ ] Confirm `start_adaptive_timelapse` signature (timelapse_tools.py:61) + how the route can reach the orchestrator (the app server holds it — confirm the proxy's access, or call the device/agent path). Validate params; RIG-DEFERRED real execution — the route wires + validates, returns the orchestrator's result/string.
- [ ] TDD: the start proxy validates + calls the orchestrator path (mock) with the right params; bad params → 400. `node --check`. Chrome harness for the form. `pytest tests/test_timelapse_start_route.py -v`; `pytest -q` clean. Commit `feat(b2): manual timelapse-config form (start proxy + rail panel)`.

## Self-Review
- Laser-preset→T1; dual-camera→T2; timelapse-form→T3. ✓
- Open confirmations: set_laser_config proxy + devices.js patterns (T1); device_factory + _ls_params + side restart (T2); start_adaptive_timelapse reachability from a route (T3).
- Type consistency: the laser config name string across the proxy + UI; the `side` 'A'/'B' across _ls_params/params/UI; the timelapse param set across the proxy + form.
- Rig-deferred (explicit): live Multi-Camera dual view, dual-side acquisition, real timelapse execution + galvo/piezo motion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Design: Manual mode B2 — dual-camera + laser-preset browser + timelapse form

Status: design 2026-06-29 (after recon). Branch `feature/manual-mode-dual-camera` (off F). Extends B1's
single-camera manual mode with three independent enhancements. Each has a HEADLESS-buildable part (built
+ tested now against a fake MMCore) and a RIG-DEFERRED part (live hardware — verified on the scope).

## 0. What B1 gives us (recon)
- Single-camera (HamCam1) lightsheet live streamer (`device_layer.py` `_ensure_lightsheet_sequence_sync`
→ `core.setCameraDevice(cam.name)`, the side-select point), the `#devices-view-manual` UI rail, the
`require_control` proxy routes (`routes/data.py`), client methods.
- **Laser presets already enumerated end-to-end:** `DiSPIMLightSource._get_available_configs` → device-layer
`GET /api/laser/configs` → `client.get_laser_configs()` → proxy `GET /api/devices/laser/configs`
(returns the 7 `Laser`-group presets). Only the UI consumer + a set-preset proxy are missing.
- Volume/burst acquisition with full geometry params (`acquire_volume(num_slices, exposure_ms,
galvo_amplitude/center, piezo_amplitude/center, laser_config, laser_power_*)`).
- ASIdiSPIM dual-camera reference: B1 spec §7 (CAMERAA/CAMERAB, Multi-Camera fusion is live-only;
dual-acquire = two parallel sequences + `"Camera"`-tag demux).

## 1. Laser-preset browser (smallest — list already exists)
- **Backend (headless):** new `POST /api/devices/laser/config` (`require_control`) proxy → `client.set_laser_config(name)`.
(`/laser/off` today only hardcodes "ALL OFF".)
- **UI (headless):** replace the static `#devices-ls-laser-status` "OFF (brightfield)" indicator in the
Illumination group with a `<select>` populated from `GET /api/devices/laser/configs`; on change POST the
chosen preset. Keep "ALL OFF" the safe default + the existing manual-view-entry laser-off safety (don't
remove the I3 guard — selecting a preset is an explicit user action).
- **Rig-deferred:** the actual laser firing (the preset just calls `setConfig` on the rig).

## 2. Dual-camera config
- **Backend (headless):** register a second `DiSPIMCamera("HamCam2")` as `devices["camera_b"]` in
`device_factory.py` — DEFENSIVELY (only if the camera is in the core's loaded devices; skip + log
otherwise, so single-camera rigs still start). Add a `side` field ('A'|'B') to `_ls_params` +
`handle_lightsheet_params`; `_ensure_lightsheet_sequence_sync` picks `camera` vs `camera_b` by side and
restarts the sequence on side change (reuse the exposure-change restart path). New `GET /api/devices/cameras`
endpoint listing available camera roles (A always; B if registered).
- **UI (headless):** a "Side A / B" selector in the manual rail → carries `side` on the live/params POST.
- **Rig-deferred:** live DUAL view via the "Multi Camera" fusion device (live-only) + dual-side acquisition
(two parallel `startSequenceAcquisition` + tag demux). v1 = single live stream, switchable side.

## 3. Timelapse config form
- **Backend (headless):** new `POST /api/devices/timelapse/start` (`require_control`) proxy wrapping the
agent path `start_adaptive_timelapse(embryo_ids, stop_condition, interval_seconds, condition_value,
monitoring_mode)` (validate params; resolve the orchestrator like the agent tool does). The volume
geometry (num_slices/exposure/galvo±/piezo±/laser_config/power) is captured in the form + passed through
/ persisted to per-embryo calibration where applicable.
- **UI (headless):** a collapsible "Timelapse" panel in the manual rail gathering cadence/stop/embryos/
monitoring_mode + the volume geometry, reading `GET /api/devices/scan_geometry` + `/api/devices/laser/configs`
for defaults. A "Start timelapse" submit → the new proxy.
- **Rig-deferred:** the actual timelapse run + galvo/piezo motion.

## 4. Out of scope / deferred
- Live Multi-Camera dual view + dual-side acquisition demux (rig).
- Saving timelapse configs as reusable presets (could reuse the tactic-library/plan-template later).
- Per-line laser power UI beyond the preset (the clamps in `optical.py` still apply).

## 5. Testing
- Laser-preset: the POST proxy (TestClient + mock client asserts `set_laser_config(name)`); `node --check`
+ Chrome audit of the dropdown populated from a stubbed configs endpoint.
- Dual-camera: device_factory registers camera_b against a FAKE core that has HamCam2 (and skips when
absent); the `side` param threads into `_ls_params` + selects the camera; `/api/devices/cameras` lists
roles; `node --check` + Chrome audit of the side selector.
- Timelapse: the start proxy validates + calls the orchestrator path (mock); the form gathers + posts the
params; `node --check` + Chrome audit of the form.
- All three: backward compatible (single-camera rig unaffected; the laser-off safety intact).
5 changes: 2 additions & 3 deletions gently/app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ def exit_plan_mode(self) -> str:
from gently.app.tools.operation_plan_seed import (
seed_operation_plan_from_plan_item,
)

seed_operation_plan_from_plan_item(self.context_store, self.session_id)
except Exception:
logger.exception("operation-plan seeding failed")
Expand Down Expand Up @@ -650,9 +651,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:
Expand Down
5 changes: 3 additions & 2 deletions gently/app/operation_plan_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
`tactic_id` or session → skip. Handler exceptions are caught so a bad event
never crashes the bus.
"""

from __future__ import annotations

import logging
from typing import Callable
from collections.abc import Callable

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__)

Expand Down
4 changes: 1 addition & 3 deletions gently/app/orchestration/role_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ def resolve_scope_embryos(scope: dict | None, embryos: list[dict]) -> list[str]:
if mode == "role":
target_role = scope.get("role")
return [
e["embryo_id"]
for e in embryos
if "embryo_id" in e and e.get("role") == target_role
e["embryo_id"] for e in embryos if "embryo_id" in e and e.get("role") == target_role
]

return []
92 changes: 69 additions & 23 deletions gently/app/orchestration/temperature_protocol.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import asyncio, logging
import asyncio
import logging

from gently.app.orchestration.exclusive import BurstAcquisition
from gently.core.event_bus import EventType

logger = logging.getLogger(__name__)


async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool:
"""Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'."""
loop = asyncio.get_event_loop()
Expand All @@ -11,61 +15,103 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.
try:
resp = await client.get_temperature()
except Exception as exc:
logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {}
logger.warning("wait_for_temperature_lock poll failed: %s", exc)
resp = {}
if "LOCKED" in str(resp.get("state", "")):
return True
if loop.time() - t0 >= timeout_s:
return False
await asyncio.sleep(poll_s)


async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *,
frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1,
lock_timeout_s=600.0, poll_s=2.0, burst_runner=None, tactic_id=None):
async def run_temp_change_burst_protocol(
orchestrator,
embryo_id,
target_setpoint_c,
*,
frames=60,
mode="1hz",
num_slices=1,
bursts_before=1,
bursts_after=1,
lock_timeout_s=600.0,
poll_s=2.0,
burst_runner=None,
tactic_id=None,
):
"""Scripted temp-change burst protocol: brightfield before/during/after a setpoint change."""
client = orchestrator.client
if burst_runner is None:
async def burst_runner(b): await b.run(orchestrator)

async def burst_runner(b):
await b.run(orchestrator)

async def one_burst(phase):
b = BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices,
temperature_provider=getattr(orchestrator, "_temperature_provider", None),
laser_config="ALL OFF")
b = BurstAcquisition(
embryo_id,
frames=frames,
mode=mode,
num_slices=num_slices,
temperature_provider=getattr(orchestrator, "_temperature_provider", None),
laser_config="ALL OFF",
)
b._phase = phase
await burst_runner(b)

locked = False; error = None; cancelled = False
locked = False
error = None
cancelled = False
try:
await client.set_laser_config("ALL OFF")
await client.set_led("Open")
orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED,
{"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c,
"frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after,
"tactic_id": tactic_id})
orchestrator._emit_event(
EventType.TEMP_PROTOCOL_STARTED,
{
"embryo_id": embryo_id,
"target_setpoint_c": target_setpoint_c,
"frames": frames,
"bursts_before": bursts_before,
"bursts_after": bursts_after,
"tactic_id": tactic_id,
},
)
for _ in range(bursts_before):
await one_burst("before")
await client.set_temperature(target_setpoint_c)
orchestrator._emit_event(EventType.TEMPERATURE_SETPOINT_CHANGED,
{"embryo_id": embryo_id, "to": target_setpoint_c})
loop = asyncio.get_event_loop(); t0 = loop.time()
orchestrator._emit_event(
EventType.TEMPERATURE_SETPOINT_CHANGED,
{"embryo_id": embryo_id, "to": target_setpoint_c},
)
loop = asyncio.get_event_loop()
t0 = loop.time()
while True:
await one_burst("during")
try:
st = str((await client.get_temperature()).get("state", ""))
except Exception:
st = ""
if "LOCKED" in st:
locked = True; break
locked = True
break
if loop.time() - t0 >= lock_timeout_s:
break
for _ in range(bursts_after):
await one_burst("after")
except asyncio.CancelledError:
cancelled = True; raise
cancelled = True
raise
except Exception as exc:
error = str(exc); logger.exception("temp-change burst protocol failed")
error = str(exc)
logger.exception("temp-change burst protocol failed")
finally:
orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED,
{"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error,
"tactic_id": tactic_id})
orchestrator._emit_event(
EventType.TEMP_PROTOCOL_COMPLETED,
{
"embryo_id": embryo_id,
"locked": locked,
"cancelled": cancelled,
"error": error,
"tactic_id": tactic_id,
},
)
return {"locked": locked, "cancelled": cancelled, "error": error}
3 changes: 2 additions & 1 deletion gently/app/temperature_sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
5 changes: 2 additions & 3 deletions gently/app/tools/acquisition_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions gently/app/tools/operation_plan_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ def _resolve_plan_item_with_tactics(context_store, session_id: str):
items = context_store.get_plan_items(campaign_id=cid)
for item in items:
# Match items that list this session
session_ids = item.session_ids or (
[item.session_id] if item.session_id else []
)
session_ids = item.session_ids or ([item.session_id] if item.session_id else [])
if session_id not in session_ids and item.session_id != session_id:
continue
# Must carry an imaging_spec with at least one tactic outline entry
Expand Down
4 changes: 3 additions & 1 deletion gently/app/tools/tactic_library_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ async def list_tactics(

lines = [f"Tactic Library ({len(tactics)} template{'s' if len(tactics) != 1 else ''}):"]
for t in tactics:
lines.append(f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}")
lines.append(
f" [{t.get('id', '?')}] {t.get('name', '(unnamed)')} — kind: {t.get('kind', '?')}"
)
return "\n".join(lines)


Expand Down
Loading