From 0ba4ada19486722619ec919f22faaf23129a5b17 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 11:50:36 -0400 Subject: [PATCH 001/314] embryos: split XY position into coarse (bottom-cam) and fine (SPIM) stages EmbryoState now carries position_coarse (bottom-camera detection or manual map placement) and position_fine (future SPIM-objective alignment) as separate fields; stage_position becomes a derived property (fine ?? coarse) so every existing call site keeps working. FileStore round-trips both stages; legacy position_x/position_y on disk backfill into coarse on read. Phase 1 of the Map-as-embryo-home arc -- schema only, no UI changes yet. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 3e4105812544ecc579a7c8ccd1a32809defe417f) --- gently/app/agent.py | 28 ++++++++---- gently/core/file_store.py | 76 +++++++++++++++++++++++++++---- gently/core/store_types.py | 10 +++- gently/harness/session/manager.py | 8 +++- gently/harness/state.py | 48 +++++++++++++++++-- 5 files changed, 146 insertions(+), 24 deletions(-) diff --git a/gently/app/agent.py b/gently/app/agent.py index 1bd8c64a..15eed57d 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -847,11 +847,16 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa eid, ) src_role = "unassigned" + coarse = row.get("position_coarse") or {} + fine = row.get("position_fine") or {} embryo_states[eid] = { - "stage_position": { - "x": row.get("position_x"), - "y": row.get("position_y"), - }, + # stage_position remains for legacy consumers of this snapshot. + # It carries the resolved (fine ?? coarse) view; add_embryo() + # downstream treats it as coarse, but the explicit + # position_fine field below will override that on restore. + "stage_position": dict(fine) if fine else dict(coarse), + "position_coarse": dict(coarse), + "position_fine": dict(fine), "calibration": row.get("calibration") or {}, "uid": row.get("embryo_uid"), "user_label": row.get("nickname"), @@ -885,13 +890,20 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa continue try: - position = embryo_data.get('stage_position', {}) + # Prefer explicit coarse/fine when the snapshot has them + # (FileStore path); fall back to flat stage_position for the + # legacy JSON-snapshot path which only carries the resolved view. + position_coarse = embryo_data.get('position_coarse') + position_fine = embryo_data.get('position_fine') + if position_coarse is None and position_fine is None: + position_coarse = embryo_data.get('stage_position', {}) calibration = embryo_data.get('calibration', {}) source_uid = embryo_data.get('uid') or f"{session_id}_{embryo_id}" self.experiment.add_embryo( embryo_id=embryo_id, - position=position, + position=position_coarse or {}, + position_fine=position_fine or {}, calibration=calibration, user_label=embryo_data.get('user_label'), uid=source_uid, @@ -1037,8 +1049,8 @@ async def on_volume_acquired(self, embryo_id: str, timepoint: int, try: self.store.register_embryo( self.session_id, embryo_id, - position_x=embryo.stage_position.get('x') if embryo.stage_position else None, - position_y=embryo.stage_position.get('y') if embryo.stage_position else None, + position_coarse=embryo.position_coarse or None, + position_fine=embryo.position_fine or None, calibration=embryo.calibration, role=embryo.role, ) diff --git a/gently/core/file_store.py b/gently/core/file_store.py index 8ca383ea..1abbc971 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -108,6 +108,43 @@ def _sanitize_for_yaml(obj): return obj +def _coarse_from_legacy(record: dict) -> Optional[dict]: + """Extract coarse XY from an embryo.yaml record, accepting either the new + `position_coarse` dict or the legacy flat `position_x` / `position_y` keys. + Returns None if neither shape carries usable values. + """ + coarse = record.get("position_coarse") + if isinstance(coarse, dict) and coarse: + return coarse + px, py = record.get("position_x"), record.get("position_y") + if px is None and py is None: + return None + out = {} + if px is not None: + out["x"] = px + if py is not None: + out["y"] = py + return out or None + + +def _normalize_embryo_record(record: Optional[dict]) -> Optional[dict]: + """Backfill an embryo.yaml dict so callers always see the new schema. + + Adds `position_coarse` derived from legacy `position_x` / `position_y` if + only the legacy fields are present, and ensures `position_fine` exists + (as None) for forward-compat. The original record is not mutated. + """ + if record is None: + return None + out = dict(record) + if out.get("position_coarse") is None: + backfill = _coarse_from_legacy(out) + if backfill is not None: + out["position_coarse"] = backfill + out.setdefault("position_fine", None) + return out + + def _write_yaml(path: Path, data: Any) -> None: """Write YAML atomically: write to a temp file, then rename.""" path.parent.mkdir(parents=True, exist_ok=True) @@ -432,6 +469,8 @@ def register_embryo( nickname: str = None, position_x: float = None, position_y: float = None, + position_coarse: dict = None, + position_fine: dict = None, calibration: dict = None, role: str = None, ) -> None: @@ -440,23 +479,38 @@ def register_embryo( ``role`` is the experimental role key from gently.harness.roles.REGISTRY (e.g. ``"test"``, ``"calibration"``, ``"unassigned"``). Persisted in embryo.yaml. None preserves the existing value on update. + + Position has two stages: coarse (bottom-camera / manual map placement) + and fine (future SPIM-objective alignment). New callers should pass + position_coarse / position_fine as dicts of shape {"x": float, "y": + float}. Legacy callers passing position_x / position_y get folded into + coarse automatically. """ ed = self._embryo_dir(session_id, embryo_id) ed.mkdir(parents=True, exist_ok=True) + # Fold legacy position_x / position_y into coarse if caller used the + # old kwargs and didn't pass coarse explicitly. + if position_coarse is None and (position_x is not None or position_y is not None): + position_coarse = {} + if position_x is not None: + position_coarse["x"] = position_x + if position_y is not None: + position_coarse["y"] = position_y + yaml_path = ed / "embryo.yaml" existing = _read_yaml(yaml_path) if existing is not None: - # Update: COALESCE behaviour -- keep existing values when new ones - # are None, matching the old ON CONFLICT DO UPDATE SET logic. + # COALESCE update — keep existing values when new ones are None. + existing_coarse = _coarse_from_legacy(existing) embryo_data = { "embryo_id": embryo_id, "session_id": session_id, "embryo_uid": embryo_uid if embryo_uid is not None else existing.get("embryo_uid"), "nickname": nickname if nickname is not None else existing.get("nickname"), - "position_x": position_x if position_x is not None else existing.get("position_x"), - "position_y": position_y if position_y is not None else existing.get("position_y"), + "position_coarse": position_coarse if position_coarse is not None else existing_coarse, + "position_fine": position_fine if position_fine is not None else existing.get("position_fine"), "calibration": calibration if calibration is not None else existing.get("calibration"), "role": role if role is not None else existing.get("role", "test"), "created_at": existing.get("created_at", _now()), @@ -467,8 +521,8 @@ def register_embryo( "session_id": session_id, "embryo_uid": embryo_uid, "nickname": nickname, - "position_x": position_x, - "position_y": position_y, + "position_coarse": position_coarse, + "position_fine": position_fine, "calibration": calibration, "role": role if role is not None else "test", "created_at": _now(), @@ -477,13 +531,17 @@ def register_embryo( _write_yaml(yaml_path, embryo_data) def get_embryo(self, session_id: str, embryo_id: str) -> Optional[EmbryoInfo]: - """Read embryo.yaml. Returns None if not found.""" + """Read embryo.yaml. Returns None if not found. + + Backfills position_coarse from legacy position_x / position_y so + callers don't need to know about the old schema. + """ sd = self._session_dir(session_id) if sd is None: return None yaml_path = sd / "embryos" / embryo_id / "embryo.yaml" data = _read_yaml(yaml_path) - return data + return _normalize_embryo_record(data) def list_embryos(self, session_id: str) -> List[EmbryoInfo]: """List all embryos for a session, sorted by embryo_id.""" @@ -500,7 +558,7 @@ def list_embryos(self, session_id: str) -> List[EmbryoInfo]: yaml_path = entry / "embryo.yaml" data = _read_yaml(yaml_path) if data is not None: - result.append(data) + result.append(_normalize_embryo_record(data)) return result # ================================================================== diff --git a/gently/core/store_types.py b/gently/core/store_types.py index 34a466c4..d3cc45d1 100644 --- a/gently/core/store_types.py +++ b/gently/core/store_types.py @@ -14,11 +14,19 @@ class SessionInfo(TypedDict): metadata: Optional[dict] -class EmbryoInfo(TypedDict): +class EmbryoInfo(TypedDict, total=False): embryo_id: str session_id: str embryo_uid: Optional[str] nickname: Optional[str] + # Coarse XY (µm) from bottom-camera detection or manual map placement. + # Shape: {"x": float, "y": float}. Always present once the embryo exists. + position_coarse: Optional[dict] + # Fine XY (µm) from SPIM-objective alignment. None until that workflow + # refines the coarse position. Shape: {"x": float, "y": float}. + position_fine: Optional[dict] + # Legacy flat fields. Still accepted on write and surfaced on read for + # callers that haven't migrated; new code should use position_coarse. position_x: Optional[float] position_y: Optional[float] calibration: Optional[dict] diff --git a/gently/harness/session/manager.py b/gently/harness/session/manager.py index 9985b324..47ea38d3 100644 --- a/gently/harness/session/manager.py +++ b/gently/harness/session/manager.py @@ -100,14 +100,18 @@ def _resume_session(self, session_id: str, experiment): embryo.should_skip = embryo_data.get('should_skip', False) embryo.skip_reason = embryo_data.get('skip_reason') - # Also load embryos from store's embryo table + # Also load embryos from store's embryo table. FileStore returns + # position_coarse / position_fine (with legacy position_x / position_y + # backfilled into coarse on read), so both calibration stages survive + # the resume. store_embryos = self.store.list_embryos(session_id) for e in store_embryos: eid = e['embryo_id'] if eid not in experiment.embryos: experiment.add_embryo( embryo_id=eid, - position={'x': e.get('position_x'), 'y': e.get('position_y')}, + position=e.get('position_coarse') or {}, + position_fine=e.get('position_fine') or {}, calibration=json.loads(e['calibration']) if e.get('calibration') else {}, ) diff --git a/gently/harness/state.py b/gently/harness/state.py index a6113480..cb02af74 100644 --- a/gently/harness/state.py +++ b/gently/harness/state.py @@ -131,8 +131,13 @@ class EmbryoState: # accidental Test→Calibration would burn extra dose on the precious sample. role: str = "test" - # Position - stage_position: Dict[str, float] = field(default_factory=dict) # {'x': 1234.5, 'y': 5678.9} + # Position — two-stage: coarse (bottom-camera detection or manual map + # placement, always present once an embryo exists) and fine (populated + # later by SPIM-objective alignment). Resolved value is exposed by the + # `stage_position` property so downstream motion/perception can stay + # agnostic about which stage we're in. + position_coarse: Dict[str, float] = field(default_factory=dict) # {'x': ..., 'y': ...} + position_fine: Dict[str, float] = field(default_factory=dict) # empty until SPIM head alignment calibration: Dict = field(default_factory=dict) # Galvo/piezo parameters detection_confidence: float = 0.0 # SAM/detection confidence score (0-1) @@ -789,6 +794,33 @@ def get_exposure_summary(self) -> str: return f"{self.exposure_count} exposures, {time_str} total" + @property + def stage_position(self) -> Dict[str, float]: + """Resolved XY position — fine if SPIM-aligned, else coarse. + + Coarse comes from the bottom-camera detection / manual map placement. + Fine comes from the SPIM-objective alignment workflow (not built yet). + Callers that just want "where is this embryo" read this; callers that + care about calibration state read position_coarse / position_fine + directly. + """ + return self.position_fine if self.position_fine else self.position_coarse + + @stage_position.setter + def stage_position(self, value: Dict[str, float]) -> None: + """Back-compat setter — writes to coarse. + + Legacy callers that assigned `embryo.stage_position = {...}` were + writing a bottom-camera / manual position; that's the coarse stage. + New code should set position_coarse or position_fine explicitly. + """ + self.position_coarse = value or {} + + @property + def has_fine_position(self) -> bool: + """True once SPIM-objective alignment has refined the coarse position.""" + return bool(self.position_fine) + def to_dict(self) -> Dict: """Serialize for API responses""" return { @@ -798,6 +830,9 @@ def to_dict(self) -> Dict: 'user_label': self.user_label, 'role': self.role, 'stage_position': self.stage_position, + 'position_coarse': self.position_coarse, + 'position_fine': self.position_fine, + 'has_fine_position': self.has_fine_position, 'calibration': self.calibration, 'detection_confidence': self.detection_confidence, 'interval_seconds': self.interval_seconds, @@ -848,13 +883,17 @@ def __init__(self): def add_embryo(self, embryo_id: str, position: Dict = None, calibration: Dict = None, user_label: Optional[str] = None, confidence: float = 0.0, uid: Optional[str] = None, - role: str = "test"): + role: str = "test", position_fine: Dict = None): """Register new embryo. ``role`` must be a key in :data:`gently.harness.roles.REGISTRY` (e.g. ``"test"``, ``"calibration"``, ``"unassigned"``). Unknown roles raise KeyError. + `position` is the coarse XY (bottom-camera detection or manual map + placement). `position_fine` is reserved for the future SPIM-objective + alignment workflow and defaults to empty. + Emits an ``EMBRYO_DETECTED`` event so listeners (e.g. the viz server's TimelapseStateTracker, which feeds the device map) learn about marked embryos immediately — not just after the @@ -871,7 +910,8 @@ def add_embryo(self, embryo_id: str, position: Dict = None, self.embryos[embryo_id] = EmbryoState( id=embryo_id, uid=uid, - stage_position=pos, + position_coarse=position or {}, + position_fine=position_fine or {}, calibration=calibration or {}, user_label=user_label, detection_confidence=confidence, From 6f28365b682fb96d3568228f38e0f92a6a684e7d Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 13:33:47 -0400 Subject: [PATCH 002/314] embryos: broadcast EMBRYOS_UPDATE on mutation Adds an EMBRYOS_UPDATE event type and wires ExperimentState's mutations (add_embryo / remove_embryo / assign_nickname / batch clear / editor finish) to publish a full embryo-list snapshot through the agent. The viz server's existing wildcard subscription forwards it to all browser clients, so Phase 3 can render embryos on the Map without polling. ExperimentState stays bus-agnostic via an on_embryos_changed observer hook; the agent wires the publisher at init. Phase 2 of the Map-as-embryo-home arc. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 617e54c95f715cf949bc2b4a02fca65314da21ca) --- gently/app/agent.py | 36 +++++++++++++++++++++++++++++ gently/app/tools/detection_tools.py | 3 ++- gently/core/event_bus.py | 1 + gently/harness/state.py | 29 ++++++++++++++++++++++- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/gently/app/agent.py b/gently/app/agent.py index 15eed57d..da7d7b11 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -125,6 +125,11 @@ def __init__( # Event bus for async messaging (must be before perception manager) self._event_bus = get_event_bus() + # Broadcast the embryo list whenever it mutates. Hooked through the + # state object's observer so add/remove/nickname/restore all publish + # without each call site having to remember. + self.experiment.on_embryos_changed = self._publish_embryos_update + # Perception system (gently-perception harness) self.perceiver = Perceiver() @@ -678,6 +683,36 @@ def _emit_event(self, event_type: EventType, data: Optional[Dict] = None): source="agent", ) + def _publish_embryos_update(self) -> None: + """Broadcast the current embryo list as an EMBRYOS_UPDATE event. + + Wired into ExperimentState.on_embryos_changed at agent init so every + add / remove / restore / nickname change snaps a fresh full-list + snapshot onto the bus. The viz server's wildcard subscription forwards + it straight to connected browsers — that's how the Devices > Map page + learns about embryos without a poll loop. + """ + if self._event_bus is None: + return + try: + embryos = [e.to_dict() for e in self.experiment.embryos.values()] + except Exception: + logger.exception("Failed to serialise embryos for EMBRYOS_UPDATE") + return + payload = { + "embryos": embryos, + "count": len(embryos), + "session_id": getattr(self, "session_id", None), + } + try: + self._event_bus.publish( + event_type=EventType.EMBRYOS_UPDATE, + data=payload, + source="agent.experiment", + ) + except Exception: + logger.exception("Failed to publish EMBRYOS_UPDATE") + def _mark_significant_action(self, action_type: str): """Mark that a significant action occurred (triggers auto-save).""" self._auto_save() @@ -879,6 +914,7 @@ def import_embryos_from_session(self, session_id: str, clear_existing: bool = Fa if clear_existing: self.experiment.embryos.clear() + self.experiment.notify_embryos_changed() imported = [] skipped = [] diff --git a/gently/app/tools/detection_tools.py b/gently/app/tools/detection_tools.py index eda06d81..08cf58fd 100644 --- a/gently/app/tools/detection_tools.py +++ b/gently/app/tools/detection_tools.py @@ -373,7 +373,8 @@ async def edit_embryos( return "No embryos to edit. Run detect_embryos or manual_mark_embryos first." # Same flow as manual_mark_embryos: pre-populate with existing markers, - # let user edit, reconcile. + # let user edit, reconcile. notify_embryos_changed is fired by + # manual_mark_embryos / add_embryo internally. return await manual_mark_embryos( exposure_ms=exposure_ms, default_role=default_role, diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index b086f172..0b7d57b2 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -82,6 +82,7 @@ class EventType(Enum): LASER_CHANGED = auto() DEVICE_STATE_UPDATE = auto() # Periodic device-state snapshot from device layer BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream + EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment # System events ERROR_OCCURRED = auto() diff --git a/gently/harness/state.py b/gently/harness/state.py index cb02af74..a52f7b79 100644 --- a/gently/harness/state.py +++ b/gently/harness/state.py @@ -25,13 +25,16 @@ fields are now on ``EmbryoState`` directly. """ +import logging import re from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from pathlib import Path import numpy as np +logger = logging.getLogger(__name__) + # Re-export CalibrationPrior from its hardware-specific home for backward compat. # CalibrationPrior is diSPIM-specific (piezo-galvo linear fit). Other hardware # modules will define their own calibration models. @@ -880,6 +883,27 @@ def __init__(self): # Updated after each successful calibration, used to initialize subsequent embryos self.calibration_prior: CalibrationPrior = CalibrationPrior() + # Observer hook — agent wires this at startup to publish EMBRYOS_UPDATE + # over the event bus. Kept as a plain callback so this module stays + # bus-agnostic. + self.on_embryos_changed: Optional[Callable[[], None]] = None + + def notify_embryos_changed(self) -> None: + """Fire the on_embryos_changed observer if one is wired. + + Call this after any mutation the agent can't intercept through + add_embryo / remove_embryo (e.g. a direct write to + embryo.position_coarse). UI hooks must not raise — failures here are + swallowed so state mutations stay durable. + """ + cb = self.on_embryos_changed + if cb is None: + return + try: + cb() + except Exception: + logger.exception("ExperimentState.on_embryos_changed callback failed") + def add_embryo(self, embryo_id: str, position: Dict = None, calibration: Dict = None, user_label: Optional[str] = None, confidence: float = 0.0, uid: Optional[str] = None, @@ -917,6 +941,7 @@ def add_embryo(self, embryo_id: str, position: Dict = None, detection_confidence=confidence, role=role, ) + self.notify_embryos_changed() # Fire the registration event. Late-bound import keeps this module # decoupled from the event bus until first use. @@ -943,6 +968,7 @@ def remove_embryo(self, embryo_id: str) -> bool: """Remove embryo from experiment (e.g., false detection)""" if embryo_id in self.embryos: del self.embryos[embryo_id] + self.notify_embryos_changed() return True return False @@ -950,6 +976,7 @@ def assign_nickname(self, embryo_id: str, nickname: str): """Agent assigns intuitive name""" if embryo_id in self.embryos: self.embryos[embryo_id].nickname = nickname + self.notify_embryos_changed() def get_embryo_by_any_name(self, name: str) -> Optional[EmbryoState]: """Get embryo by ID, nickname, or user label""" From 4db06d8fbde3b1542afcc4d0cd44338e97751f3f Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 13:40:55 -0400 Subject: [PATCH 003/314] Devices > Map: render embryo waypoints as coarse rings / fine discs Adds an embryo layer to the Map between the axes and the live stage marker. Each embryo renders at its resolved XY (fine if SPIM-aligned, else coarse): coarse-only as an outlined lavender ring, fine-calibrated as a filled disc, both labelled with the embryo number. The layer is a pure read of EMBRYOS_UPDATE events plus an initial /api/embryos/current snapshot so a Map page opened mid-session shows existing embryos without waiting for the next mutation. Read-only at this phase; click / drag / delete will land in Phase 5. Phase 3 of the Map-as-embryo-home arc. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 144d9fc9db6511aff3604c7313cc7305ba1cf645) --- gently/ui/web/routes/data.py | 25 +++++++ gently/ui/web/static/css/main.css | 32 +++++++++ gently/ui/web/static/js/devices.js | 105 ++++++++++++++++++++++++++--- 3 files changed, 153 insertions(+), 9 deletions(-) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 3e66763f..2d401199 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -63,6 +63,31 @@ async def get_device_status(): "microscope": microscope_up, } + @router.get("/api/embryos/current") + async def get_current_embryos(): + """Return the agent's current embryo list as an EMBRYOS_UPDATE payload. + + EMBRYOS_UPDATE is published only on mutation, so a Map page opened + mid-session would otherwise see an empty embryo layer until the next + add/remove/edit. This endpoint serves the same payload shape as the + event so clients can bootstrap and then switch to the live stream. + """ + empty = {"embryos": [], "count": 0, "session_id": None} + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + if agent is None or not hasattr(agent, "experiment"): + return empty + try: + embryos = [e.to_dict() for e in agent.experiment.embryos.values()] + except Exception: + logger.exception("Failed to serialise embryos for snapshot") + return empty + return { + "embryos": embryos, + "count": len(embryos), + "session_id": getattr(agent, "session_id", None), + } + @router.get("/api/devices/coverslip") async def get_coverslip(): """Return the coverslip outline metadata for the Map view. diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 4d19c79d..44405acd 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -9172,6 +9172,7 @@ body.modal-open { --map-zone-green: 90, 168, 122; /* RGB triples for compositing */ --map-zone-orange: 215, 152, 84; --map-zone-red: 220, 96, 88; + --map-embryo: 156, 120, 220; /* lavender — distinct from zones and marker */ --map-overlay-bg: rgba(11, 14, 19, 0.78); --map-overlay-bg-2: rgba(11, 14, 19, 0.92); --map-overlay-edge: rgba(212, 221, 232, 0.18); @@ -9189,6 +9190,7 @@ body.modal-open { --map-accent: #0e7490; --map-accent-2: #155e75; --map-warm: #a16207; + --map-embryo: 100, 60, 180; /* deeper purple for cream paper */ --map-overlay-bg: rgba(246, 243, 236, 0.82); --map-overlay-bg-2: rgba(246, 243, 236, 0.96); --map-overlay-edge: rgba(29, 43, 58, 0.18); @@ -9429,6 +9431,36 @@ body.modal-open { 100% { opacity: 0; r: 28; } } +/* --- Embryo waypoints ------------------------------------------------ */ +/* Coarse = bottom-camera / manual placement; fine = SPIM-objective + alignment. Coarse reads as an outlined ring (provisional), fine as a + filled disc (committed). Same hue so the row of embryos still reads as + one cohort, but visual weight signals calibration state at a glance. */ +.devices-embryo-ring { + fill: rgba(var(--map-embryo), 0.08); + stroke: rgba(var(--map-embryo), 0.85); + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} +.devices-embryo-disc { + fill: rgba(var(--map-embryo), 0.65); + stroke: rgba(var(--map-embryo), 0.95); + stroke-width: 1.4; + vector-effect: non-scaling-stroke; +} +.devices-embryo-label { + fill: var(--map-ink); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-weight: 600; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; + paint-order: stroke; + stroke: var(--map-paper); + stroke-width: 2; + stroke-linejoin: round; +} + /* --- Overlay panels (compass, readout, scalebar, legend) ------------- */ .devices-compass, .devices-map-readout, diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 0f2f5316..c1d5e1c8 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -32,11 +32,14 @@ const DevicesManager = (function () { let _mapWrap; let _scalebarLabel; - // Embryos overlay state: list of {embryo_id, x, y, role, ...}. - // Populated by /api/embryos/positions on init + EMBRYO_DETECTED / - // STATUS_CHANGED WS pushes thereafter. Roles drive the marker color + // Embryo waypoints — driven by EMBRYOS_UPDATE events (the canonical bulk + // mutation broadcast added by the embryos-broadcast commit) and the + // initial /api/embryos/current snapshot. Each entry mirrors + // EmbryoState.to_dict() (id, position_coarse, position_fine, + // has_fine_position, nickname, role, ...). Role drives marker color // (mirrors the marking-window legend: magenta=test, cyan=calibration, - // grey=unassigned). + // grey=unassigned). EMBRYO_DETECTED / STATUS_CHANGED listeners stay + // hooked as a belt-and-braces refresh path. let _embryos = []; const _ROLE_COLOR = { test: '#ff66cc', @@ -257,6 +260,30 @@ const DevicesManager = (function () { } } + // Initial embryo snapshot — closes the gap for clients that connect + // mid-session, after the last EMBRYOS_UPDATE has already been broadcast + // and aged out of history. Subsequent updates arrive over the event bus. + async function loadEmbryosSnapshot() { + try { + const res = await fetch('/api/embryos/current'); + if (!res.ok) return; + const data = await res.json(); + handleEmbryosUpdate(data); + } catch (err) { + console.debug('embryos snapshot fetch failed:', err); + } + } + + function handleEmbryosUpdate(payload) { + _embryos = (payload && Array.isArray(payload.embryos)) ? payload.embryos : []; + if (!_viewBox) { + computeViewBox(); + renderMap(); + } else { + renderEmbryos(); + } + } + // ===================================================================== // Properties table (Details view) // ===================================================================== @@ -744,6 +771,67 @@ const DevicesManager = (function () { return Math.round(v).toString(); } + // ===================================================================== + // Embryo waypoints + // ===================================================================== + + // "embryo_007" / "embryo_7" -> 7. Falls back to a 1-based index from the + // caller so the label always shows *something*, even for stray ids. + function embryoLabelText(id, fallbackIndex) { + const m = id && String(id).match(/(\d+)/); + if (m) { + const n = parseInt(m[1], 10); + if (Number.isFinite(n)) return String(n); + } + return String(fallbackIndex + 1); + } + + // Resolve XY for rendering — fine if SPIM-aligned, else coarse. Returns + // null when neither stage carries usable values so the entry is skipped + // (e.g. an embryo whose detection record came in malformed). + function embryoResolvedXY(emb) { + const f = emb && emb.position_fine; + if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) return { x: f.x, y: f.y }; + const c = emb && emb.position_coarse; + if (c && Number.isFinite(c.x) && Number.isFinite(c.y)) return { x: c.x, y: c.y }; + return null; + } + + function renderEmbryos() { + if (!_mapEmbryos || !_viewBox) return; + _mapEmbryos.innerHTML = ''; + if (!_embryos || !_embryos.length) return; + const span = Math.max(_viewBox.xMax - _viewBox.xMin, + _viewBox.yMax - _viewBox.yMin); + const radius = span * 0.012; + const fontSize = span * 0.015; + + _embryos.forEach((emb, i) => { + const xy = embryoResolvedXY(emb); + if (!xy) return; + + const isFine = !!emb.has_fine_position; + const circle = document.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('cx', xy.x); + circle.setAttribute('cy', svgY(xy.y)); + circle.setAttribute('r', radius); + circle.setAttribute('class', + isFine ? 'devices-embryo-disc' : 'devices-embryo-ring'); + // Identifiers for inspection / future click handlers — not used yet. + circle.setAttribute('data-embryo-id', emb.id || ''); + circle.setAttribute('data-embryo-stage', isFine ? 'fine' : 'coarse'); + _mapEmbryos.appendChild(circle); + + const label = document.createElementNS(SVG_NS, 'text'); + label.setAttribute('x', xy.x); + label.setAttribute('y', svgY(xy.y)); + label.setAttribute('class', 'devices-embryo-label'); + label.setAttribute('font-size', fontSize); + label.textContent = embryoLabelText(emb.id, i); + _mapEmbryos.appendChild(label); + }); + } + function updateMapMarker() { if (!_mapMarker || !_lastXY) return; const sx = _lastXY.X; @@ -950,14 +1038,13 @@ const DevicesManager = (function () { setupViewSwitcher(); setupCameraWiring(); loadCoverslip(); - loadEmbryos(); + loadEmbryosSnapshot(); switchView(_currentView); if (typeof ClientEventBus !== 'undefined') { ClientEventBus.on('DEVICE_STATE_UPDATE', handlePayload); - // Embryo events: a fresh marking session emits one - // EMBRYO_DETECTED per registered embryo (via - // ExperimentState.add_embryo). assign_embryo_roles emits - // STATUS_CHANGED with change=role_assigned per change. + ClientEventBus.on('EMBRYOS_UPDATE', handleEmbryosUpdate); + // Belt-and-braces: also listen for the fine-grained events that + // existed before EMBRYOS_UPDATE so direct emitters still refresh. ClientEventBus.on('EMBRYO_DETECTED', handleEmbryoDetected); ClientEventBus.on('STATUS_CHANGED', handleStatusChanged); } From 1ed5fc4da9ecb6d748f6f342d874e459f8c0b5ea Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 14:04:53 -0400 Subject: [PATCH 004/314] detect_embryos: web Marking canvas replaces napari editor, gate control routes Adds Phase 4 of the Map-as-embryo-home arc. Auth (4a) --------- New gently/ui/web/auth.py introduces a two-role model: localhost is always control, remote callers default to view and need X-Gently-Token matching GENTLY_CONTROL_TOKEN to upgrade. Bottom-camera stream start/stop POST routes now Depends(require_control), so a remote browser can watch the stage but cannot drive hardware until an operator provisions the shared token. Marking canvas seeded (4b) -------------------------- VisualizationServer.start_marking_session takes initial_markers (pixel positions from SAM); they're seeded into the session state and the marking_image broadcast so the canvas opens with SAM detections pre- placed. wait_for_marking now also computes stage_x_um / stage_y_um from the operator-confirmed pixel positions, so callers can drop the result straight into agent.experiment.add_embryo. marking.js renders the seeded markers immediately and adapts the instruction string. detect_embryos -> web (4c) -------------------------- The agent tool now SAM-detects with open_editor=False (napari path bypassed), then hands off to the web Marking canvas via agent.viz_server.start_marking_session(initial_markers=...) and awaits wait_for_marking. Confirmed embryos land in agent.experiment, which broadcasts EMBRYOS_UPDATE -> Devices > Map shows them as coarse rings. Falls back gracefully if viz_server is unavailable or the operator never confirms. edit_embryos / manual_mark_embryos still use napari; deferred to a later phase. gently/ui/napari_viewer.py kept intact for offline use. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 4fbb9edf71b4faaef18aa7f4369d5bbc1556ca81) --- gently/ui/web/auth.py | 95 ++++++++++++++++++++++++++++++++++++ gently/ui/web/routes/data.py | 10 ++-- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 gently/ui/web/auth.py diff --git a/gently/ui/web/auth.py b/gently/ui/web/auth.py new file mode 100644 index 00000000..7933c515 --- /dev/null +++ b/gently/ui/web/auth.py @@ -0,0 +1,95 @@ +"""Web-UI authorization roles. + +Two roles: + view -- read-only. GET endpoints, SSE / WebSocket event streams. + control -- can drive hardware (POST/PUT/DELETE). Localhost is always + control; remote callers must present a matching token in the + X-Gently-Token header (token read from GENTLY_CONTROL_TOKEN). + +Routes that move hardware or mutate persistent state declare a dependency: + + from gently.ui.web.auth import require_control + + @router.post("/api/devices/foo") + async def foo(_=Depends(require_control)): + ... + +Default-deny on control: if the token env var is unset, remote callers get +view-only access until the operator provisions a token. That matches the +"diSPIM computer alone gives control directions" intent while leaving room +for authenticated remote operators later. +""" + +from __future__ import annotations + +import logging +import os +from enum import Enum + +from fastapi import HTTPException, Request + +logger = logging.getLogger(__name__) + + +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) + +# Header name used to upgrade a remote session to control role. Single shared +# token for now; per-user identities can be layered on later without changing +# this module's public surface. +_TOKEN_HEADER = "X-Gently-Token" +_TOKEN_ENV = "GENTLY_CONTROL_TOKEN" + + +class Role(str, Enum): + VIEW = "view" + CONTROL = "control" + + +def _configured_token() -> str | None: + """Return the shared control token, or None if no token is provisioned. + + Read fresh each request so the operator can rotate the token without + restarting the web server. + """ + tok = os.environ.get(_TOKEN_ENV, "").strip() + return tok or None + + +def resolve_role(request: Request) -> Role: + """Determine the effective role for a request. + + Localhost is always control (the diSPIM box). Remote callers need to + present X-Gently-Token matching GENTLY_CONTROL_TOKEN. + """ + client = request.client + host = client.host if client else None + if host in _LOOPBACK_HOSTS: + return Role.CONTROL + + token = _configured_token() + if token is not None: + supplied = request.headers.get(_TOKEN_HEADER, "").strip() + if supplied and supplied == token: + return Role.CONTROL + + return Role.VIEW + + +def require_control(request: Request) -> Role: + """FastAPI dependency — 403 unless the caller has the control role. + + Logs the denied client host (without leaking the token) so the operator + can spot if a remote browser is trying to drive hardware. + """ + role = resolve_role(request) + if role is Role.CONTROL: + return role + host = request.client.host if request.client else "unknown" + logger.warning("control-route 403 for %s -> %s %s", + host, request.method, request.url.path) + raise HTTPException( + status_code=403, + detail="control role required (this endpoint moves hardware or " + "mutates persistent state; localhost has it by default, " + "remote callers need X-Gently-Token)", + ) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 2d401199..6b9c2434 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -6,7 +6,9 @@ from typing import Optional import yaml -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException + +from gently.ui.web.auth import require_control logger = logging.getLogger(__name__) @@ -123,7 +125,8 @@ async def get_bottom_camera_status(): "last_frame_ts": getattr(monitor, "_last_frame_ts", None) if monitor else None, } - @router.post("/api/devices/bottom_camera/stream/start") + @router.post("/api/devices/bottom_camera/stream/start", + dependencies=[Depends(require_control)]) async def start_bottom_camera_stream(): """Start the bottom-camera stream bridge. @@ -144,7 +147,8 @@ async def start_bottom_camera_stream(): raise HTTPException(status_code=500, detail=f"start failed: {exc}") return {"streaming": monitor.running} - @router.post("/api/devices/bottom_camera/stream/stop") + @router.post("/api/devices/bottom_camera/stream/stop", + dependencies=[Depends(require_control)]) async def stop_bottom_camera_stream(): """Stop the bottom-camera stream bridge. Idempotent.""" bridge = getattr(server, "agent_bridge", None) From 5b15797648c07afac03a9cbbff383bb87e042600 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 15:34:29 -0400 Subject: [PATCH 005/314] Devices > Map: pick-up / drop / delete to edit embryos in-place Map becomes the home for embryos rather than a viewer. Backend (data.py) ----------------- PUT /api/embryos/{id}/position {x, y} updates position_coarse and CLEARS position_fine -- the operator overriding the sighting invalidates any prior SPIM-objective alignment derived from the old coarse, so it must be re-run. DELETE /api/embryos/{id} removes via ExperimentState. Both endpoints Depends(require_control), so only the diSPIM box (or a remote session with X-Gently-Token) can mutate the embryo list. Both fire EMBRYOS_UPDATE through the observer hook for live Map refresh. Frontend (devices.js + main.css) -------------------------------- First click on an embryo selects it (dashed lavender ring, brighter label -- the "picked up" state). Click on empty map space drops it there with a confirm prompt; Delete/Backspace removes with confirm; Escape clears the selection. New embryos still go through the bottom- camera Marking canvas -- the Map is a schematic, not a satellite, so adding without a visual reference would be guessing. Keyboard handler is tab-aware (Devices tab + Map view only) and ignores keystrokes while an input/textarea/select has focus so it doesn't hijack the chat composer. Smoke-tested end-to-end via ASGI: PUT clears fine correctly, DELETE fires notify, error paths return 400 / 404 / 503. Phase 5 of the Map-as-embryo-home arc. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 8f6553e12b02ff070589d729cb8e320c330f2817) --- gently/ui/web/routes/data.py | 54 ++++++++- gently/ui/web/static/css/main.css | 17 +++ gently/ui/web/static/js/devices.js | 171 ++++++++++++++++++++++++++++- 3 files changed, 236 insertions(+), 6 deletions(-) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 6b9c2434..6a3e8ef5 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -6,7 +6,7 @@ from typing import Optional import yaml -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Body, Depends, HTTPException from gently.ui.web.auth import require_control @@ -65,6 +65,58 @@ async def get_device_status(): "microscope": microscope_up, } + def _require_agent_with_experiment(): + """Resolve the live agent from the server bridge, or 503. + + Edit endpoints write through ExperimentState so the notify hook fires + EMBRYOS_UPDATE and the Map re-renders without a follow-up fetch. + """ + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + if agent is None or not hasattr(agent, "experiment"): + raise HTTPException(status_code=503, detail="Agent not ready") + return agent + + @router.put("/api/embryos/{embryo_id}/position", + dependencies=[Depends(require_control)]) + async def update_embryo_position( + embryo_id: str, + body: dict = Body(...), + ): + """Update an embryo's coarse XY position. + + Map-side edits write to the coarse stage and CLEAR any prior fine + position — the operator is overriding the sighting, so any + SPIM-objective fine alignment derived from the old coarse is no + longer trustworthy and must be re-run. + """ + agent = _require_agent_with_experiment() + emb = agent.experiment.embryos.get(embryo_id) + if emb is None: + raise HTTPException(status_code=404, detail=f"Embryo {embryo_id} not found") + try: + x = float(body.get("x")) + y = float(body.get("y")) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Body needs numeric x and y") + emb.position_coarse = {"x": x, "y": y} + emb.position_fine = {} + agent.experiment.notify_embryos_changed() + return emb.to_dict() + + @router.delete("/api/embryos/{embryo_id}", + dependencies=[Depends(require_control)]) + async def delete_embryo(embryo_id: str): + """Remove an embryo from the experiment. + + Goes through ExperimentState.remove_embryo so the observer hook + fires EMBRYOS_UPDATE automatically. + """ + agent = _require_agent_with_experiment() + if not agent.experiment.remove_embryo(embryo_id): + raise HTTPException(status_code=404, detail=f"Embryo {embryo_id} not found") + return {"ok": True, "embryo_id": embryo_id} + @router.get("/api/embryos/current") async def get_current_embryos(): """Return the agent's current embryo list as an EMBRYOS_UPDATE payload. diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 44405acd..9e975176 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -9436,6 +9436,9 @@ body.modal-open { alignment. Coarse reads as an outlined ring (provisional), fine as a filled disc (committed). Same hue so the row of embryos still reads as one cohort, but visual weight signals calibration state at a glance. */ +.devices-embryo-group { + cursor: pointer; +} .devices-embryo-ring { fill: rgba(var(--map-embryo), 0.08); stroke: rgba(var(--map-embryo), 0.85); @@ -9461,6 +9464,20 @@ body.modal-open { stroke-linejoin: round; } +/* Selected = "picked up" — outlined dashed, hollow fill, brighter label. + Click on empty map drops the picked-up embryo at that XY; Delete / + Backspace removes it; Escape deselects. */ +.devices-embryo-group.devices-embryo-selected .devices-embryo-ring, +.devices-embryo-group.devices-embryo-selected .devices-embryo-disc { + fill: rgba(var(--map-embryo), 0.12); + stroke: rgba(var(--map-embryo), 1); + stroke-width: 2; + stroke-dasharray: 4 3; +} +.devices-embryo-group.devices-embryo-selected .devices-embryo-label { + fill: rgba(var(--map-embryo), 1); +} + /* --- Overlay panels (compass, readout, scalebar, legend) ------------- */ .devices-compass, .devices-map-readout, diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index c1d5e1c8..03d5fbb3 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -47,6 +47,11 @@ const DevicesManager = (function () { unassigned: '#888888', }; + // Map-side edit state. _selectedEmbryoId means "picked up": the next + // click on empty map space drops it there (with a confirm), Delete / + // Backspace removes it (with a confirm), Escape clears the selection. + let _selectedEmbryoId = null; + // Bottom-camera panel DOM + state let _camPanel, _camToggle, _camImg, _camPlaceholder, _camLed, _camMeta; let _camStreaming = false; @@ -811,16 +816,24 @@ const DevicesManager = (function () { if (!xy) return; const isFine = !!emb.has_fine_position; + const isSelected = _selectedEmbryoId !== null + && emb.id === _selectedEmbryoId; + + // Wrap circle + label in a group so a single closest() lookup + // finds the embryo regardless of which child the click hit. + const group = document.createElementNS(SVG_NS, 'g'); + group.setAttribute('class', + 'devices-embryo-group' + (isSelected ? ' devices-embryo-selected' : '')); + group.setAttribute('data-embryo-id', emb.id || ''); + group.setAttribute('data-embryo-stage', isFine ? 'fine' : 'coarse'); + const circle = document.createElementNS(SVG_NS, 'circle'); circle.setAttribute('cx', xy.x); circle.setAttribute('cy', svgY(xy.y)); circle.setAttribute('r', radius); circle.setAttribute('class', isFine ? 'devices-embryo-disc' : 'devices-embryo-ring'); - // Identifiers for inspection / future click handlers — not used yet. - circle.setAttribute('data-embryo-id', emb.id || ''); - circle.setAttribute('data-embryo-stage', isFine ? 'fine' : 'coarse'); - _mapEmbryos.appendChild(circle); + group.appendChild(circle); const label = document.createElementNS(SVG_NS, 'text'); label.setAttribute('x', xy.x); @@ -828,10 +841,150 @@ const DevicesManager = (function () { label.setAttribute('class', 'devices-embryo-label'); label.setAttribute('font-size', fontSize); label.textContent = embryoLabelText(emb.id, i); - _mapEmbryos.appendChild(label); + group.appendChild(label); + + _mapEmbryos.appendChild(group); }); } + // ---- Map-side edit interactions ------------------------------------ + // Convert a pointer event's client coords into stage µm. SVG y axis is + // positive-down and stage y is positive-up, so the y component is + // negated to match the convention used elsewhere in this module. + function eventToStageXY(event) { + if (!_mapSvg || !_mapSvg.getScreenCTM) return null; + const ctm = _mapSvg.getScreenCTM(); + if (!ctm) return null; + const pt = _mapSvg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const local = pt.matrixTransform(ctm.inverse()); + return { x: local.x, y: -local.y }; + } + + function findEmbryoIdAt(target) { + if (!target) return null; + const node = target.closest && target.closest('[data-embryo-id]'); + return node ? node.getAttribute('data-embryo-id') : null; + } + + function embryoById(id) { + return _embryos.find(e => e.id === id) || null; + } + + function embryoNumberFor(emb) { + return embryoLabelText(emb.id, _embryos.indexOf(emb)); + } + + function setSelectedEmbryo(id) { + if (_selectedEmbryoId === id) return; + _selectedEmbryoId = id; + renderEmbryos(); + } + + function clearSelection() { + if (_selectedEmbryoId === null) return; + _selectedEmbryoId = null; + renderEmbryos(); + } + + async function attemptMoveSelected(targetStage) { + const id = _selectedEmbryoId; + if (!id) return; + const emb = embryoById(id); + if (!emb) { clearSelection(); return; } + const cur = embryoResolvedXY(emb); + const num = embryoNumberFor(emb); + const oldStr = cur ? `(${cur.x.toFixed(1)}, ${cur.y.toFixed(1)})` : '(unknown)'; + const newStr = `(${targetStage.x.toFixed(1)}, ${targetStage.y.toFixed(1)})`; + if (!window.confirm(`Move embryo ${num} from ${oldStr} to ${newStr}?`)) { + return; // keep the embryo picked up so they can try again + } + try { + const res = await fetch(`/api/embryos/${encodeURIComponent(id)}/position`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x: targetStage.x, y: targetStage.y }), + }); + if (!res.ok) { + window.alert(`Move failed (${res.status}): ${await res.text()}`); + return; + } + // EMBRYOS_UPDATE will arrive over the bus and refresh the layer; + // dropping clears the picked-up state regardless. + clearSelection(); + } catch (err) { + console.error('move embryo:', err); + window.alert(`Move failed: ${err.message}`); + } + } + + async function attemptDeleteSelected() { + const id = _selectedEmbryoId; + if (!id) return; + const emb = embryoById(id); + const num = emb ? embryoNumberFor(emb) : id; + if (!window.confirm(`Remove embryo ${num}?`)) return; + try { + const res = await fetch(`/api/embryos/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + if (!res.ok) { + window.alert(`Delete failed (${res.status}): ${await res.text()}`); + return; + } + // The embryo is gone from the server snapshot; EMBRYOS_UPDATE + // will arrive and drop it from _embryos. Clear locally too. + _selectedEmbryoId = null; + } catch (err) { + console.error('delete embryo:', err); + window.alert(`Delete failed: ${err.message}`); + } + } + + function onMapPointerDown(event) { + // Ignore non-primary buttons so right-clicks etc. don't trigger UI. + if (event.button !== undefined && event.button !== 0) return; + const id = findEmbryoIdAt(event.target); + if (id) { + setSelectedEmbryo(id); + return; + } + // Empty-space click: drop the picked-up embryo here. + if (_selectedEmbryoId !== null) { + const stage = eventToStageXY(event); + if (stage) attemptMoveSelected(stage); + } + } + + function onMapKeyDown(event) { + // Only honour keys when the operator is actually looking at the Map: + // not on another top-level tab, not on the Details subview, and not + // typing into an input / textarea / select / contenteditable. + if (typeof state !== 'undefined' && typeof TABS !== 'undefined' + && state.tab !== TABS.DEVICES) { + return; + } + if (_currentView !== 'map') return; + const a = document.activeElement; + if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || + a.tagName === 'SELECT' || a.isContentEditable)) { + return; + } + if (event.key === 'Escape') { + if (_selectedEmbryoId !== null) { + clearSelection(); + event.preventDefault(); + } + return; + } + if (_selectedEmbryoId === null) return; + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); // Backspace would otherwise navigate back + attemptDeleteSelected(); + } + } + function updateMapMarker() { if (!_mapMarker || !_lastXY) return; const sx = _lastXY.X; @@ -1048,6 +1201,14 @@ const DevicesManager = (function () { ClientEventBus.on('EMBRYO_DETECTED', handleEmbryoDetected); ClientEventBus.on('STATUS_CHANGED', handleStatusChanged); } + // Map-side edit handlers. Pointer events on the SVG cover both + // "click an embryo" (selects it) and "click empty map" (drops the + // selected embryo). Keyboard listener is document-wide but guards + // against firing while an input is focused. + if (_mapSvg) { + _mapSvg.addEventListener('pointerdown', onMapPointerDown); + } + document.addEventListener('keydown', onMapKeyDown); setStatus('stale', 'waiting', 'no payload yet'); syncInitialCameraState(); // Stop the camera stream if the tab is closed while it's running, From 7995cbd852219a1ae8be2c4001af22c49a3a4fb9 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 17:39:27 -0400 Subject: [PATCH 006/314] device_layer: re-enable XY joystick at boot Tiger persists JoystickEnabled in non-volatile card settings. If a prior session ever ran SaveCardSettings while the joystick happened to be off, every subsequent boot inherits that state and the physical controller is dead. We don't run SaveCardSettings ourselves, so the only way to recover the joystick was a manual property write -- and there was no way to know the state had drifted until the operator tried to use it. DiSPIMXYStage gains enable_joystick(True) that writes JoystickEnabled + verifies read-back (same pattern as set_firmware_limits). device_layer calls it at boot right after the firmware soft limits are applied. Failure is non-fatal: agent can still drive the stage; we just log loud so the operator knows the joystick is unavailable. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 808fe8130d9e2066927a7d464e951d7f9d88af99) --- gently/hardware/dispim/device_layer.py | 12 +++++++++ gently/hardware/dispim/devices/stage.py | 34 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 1172ba1d..48e19746 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -274,6 +274,18 @@ async def initialize(self): logger.error("Could not apply ASI firmware soft limits: %s", exc) raise + # Tiger persists JoystickEnabled in non-volatile card settings — + # if a prior session ever called SaveCardSettings with the + # joystick off, every subsequent boot inherits that state and the + # physical controller is dead. Force it on at boot so the + # operator's joystick always works regardless of card history. + try: + xy_stage.enable_joystick(True) + except Exception as exc: + # Not fatal — the agent can still drive the stage. Log loudly + # so the operator knows the joystick is unavailable. + logger.error("Could not enable XY joystick: %s", exc) + # [4/5] Initialize RunEngine logger.info("[4/5] Initializing RunEngine...") self.RE = RunEngine({}) diff --git a/gently/hardware/dispim/devices/stage.py b/gently/hardware/dispim/devices/stage.py index a179135a..3c922d14 100644 --- a/gently/hardware/dispim/devices/stage.py +++ b/gently/hardware/dispim/devices/stage.py @@ -332,6 +332,40 @@ def set_firmware_limits( ) logger.info("ASI firmware limit %s = %.4f mm (verified)", prop, got) + def enable_joystick(self, enabled: bool = True) -> None: + """Set the ASI Tiger 'JoystickEnabled' property on the XY stage. + + Tiger firmware persists this flag in its non-volatile card settings + (touched whenever someone calls SaveCardSettings — we don't, but + previous sessions may have). If it persisted as 'No', the physical + joystick is dead on boot until something writes 'Yes'. This method + is the boot-time fix; it's called from device_layer.initialize right + after the firmware soft limits are applied. + + Read-back verified so a silent rejection by the adapter doesn't + leave the operator wondering why the controller still doesn't move. + """ + target = "Yes" if enabled else "No" + prop = "JoystickEnabled" + try: + self.core.setProperty(self.name, prop, target) + except RuntimeError as exc: + raise HardwareError( + f"setProperty {prop}={target} failed on {self.name}: {exc}" + ) + try: + got = self.core.getProperty(self.name, prop) + except RuntimeError as exc: + raise HardwareError( + f"getProperty {prop} read-back failed on {self.name}: {exc}" + ) + if str(got).strip() != target: + raise HardwareError( + f"{prop} read-back mismatch on {self.name}: " + f"wrote '{target}', controller reports '{got}'." + ) + logger.info("ASI %s.%s = %s (verified)", self.name, prop, got) + # Synchronous convenience methods (usable outside RunEngine) def get_position(self) -> np.ndarray: """ From feca96aa30cc6d36ac60bdacee75417edb2265c6 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 17:39:39 -0400 Subject: [PATCH 007/314] Devices > Map: image-anchored crosshair + scroll-to-zoom in camera panel Two improvements to the bottom-camera live thumbnail on the Map. Crosshair (FOV reticle) ----------------------- A centre crosshair anchored to the image, not the viewer rect. SVG sibling of ; an inner receives the same translate/scale as the image (in viewBox units, via the SVG transform attribute), so the lines track the FOV centre through zoom/pan instead of staying pinned to the container centre. Transform sits on rather than the SVG element so the renderer re-rasterises at each zoom step -- otherwise 1px strokes get bitmap-scaled and go blurry. vector-effect: non- scaling-stroke keeps them 1px at any zoom. Default colour amber (var(--map-warm)). Zoom / pan ---------- Scroll-wheel over the camera stage zooms in/out (1x to 8x, ~15% per notch) centred under the cursor. Click and drag pans when zoomed. Double-click resets to 1x. Pan is clamped so the image centre stays inside the visible window. Stream stop also resets the transform so the next session starts at 1x. wheel listener is passive:false so the page doesn't scroll under the operator's hand. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit f7a13d694f157f50813ecec9ac115859d2a5b295) --- gently/ui/web/static/css/main.css | 35 ++++++++ gently/ui/web/static/js/devices.js | 137 +++++++++++++++++++++++++++++ gently/ui/web/templates/index.html | 14 +++ 3 files changed, 186 insertions(+) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 9e975176..ef4dac96 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -9684,9 +9684,44 @@ body.modal-open { display: block; opacity: 0; transition: opacity 0.25s; + /* Zoom anchored at frame centre; scroll-wheel + cursor adjust translate + so the point under the cursor stays under the cursor. */ + transform-origin: center center; + will-change: transform; } .devices-camera-img.has-frame { opacity: 1; } +/* Cursor hints for zoom/pan mode. Default cursor stays untouched at zoom 1 + so the operator can still interact with overlays under the camera. */ +.devices-camera-stage.camera-zoomed { cursor: grab; } +.devices-camera-stage.camera-panning { cursor: grabbing; } + +/* Centre reticle — full-span horizontal + vertical hairline marking the + FOV centre IN the image. SVG is a sibling of ; the inner + receives the same translate/scale (in viewBox units) so the lines + track the camera image through zoom/pan instead of staying pinned to + the viewer rect. Transform lives on the , not the SVG element, so + the renderer re-rasterises at each zoom step — otherwise the strokes + get bitmap-scaled and go blurry. */ +.devices-camera-crosshair { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + opacity: 0; + transition: opacity 0.25s; +} +.devices-camera-stage:has(.devices-camera-img.has-frame) .devices-camera-crosshair { + opacity: 1; +} +.devices-camera-crosshair line { + stroke: var(--map-warm); + stroke-width: 1; + vector-effect: non-scaling-stroke; + stroke-opacity: 0.85; +} + .devices-camera-placeholder { position: absolute; inset: 0; diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 03d5fbb3..b4cb30d2 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -54,6 +54,7 @@ const DevicesManager = (function () { // Bottom-camera panel DOM + state let _camPanel, _camToggle, _camImg, _camPlaceholder, _camLed, _camMeta; + let _camStage, _camCrosshair, _camCrosshairGroup; let _camStreaming = false; let _camLastFrameTs = 0; let _camHasFrame = false; @@ -61,6 +62,16 @@ const DevicesManager = (function () { const _CAM_FPS_WINDOW = 12; let _camFrameTimes = []; + // Camera zoom / pan. Identity transform = (zoom 1, tx 0, ty 0); pan only + // engages once zoom > 1. Reset on double-click and on stream-off. + let _camZoom = 1; + let _camTx = 0; + let _camTy = 0; + let _camPanLast = null; // {x, y} clientX/Y of last pointermove during pan + const _CAM_ZOOM_MIN = 1; + const _CAM_ZOOM_MAX = 8; + const _CAM_ZOOM_STEP = 1.15; // multiplicative per wheel notch + let _lastTs = 0; let _previousTs = 0; let _lastWallTs = 0; @@ -118,6 +129,9 @@ const DevicesManager = (function () { _camToggle = document.getElementById('devices-camera-toggle'); _camImg = document.getElementById('devices-camera-img'); _camPlaceholder = document.getElementById('devices-camera-placeholder'); + _camStage = _camPanel ? _camPanel.querySelector('.devices-camera-stage') : null; + _camCrosshair = document.getElementById('devices-camera-crosshair'); + _camCrosshairGroup = document.getElementById('devices-camera-crosshair-group'); _camLed = document.getElementById('devices-camera-led'); _camMeta = document.getElementById('devices-camera-meta'); @@ -1061,6 +1075,9 @@ const DevicesManager = (function () { if (_camPlaceholder) _camPlaceholder.hidden = false; if (_camMeta) _camMeta.textContent = 'stream off'; if (_camStaleTimer) { clearTimeout(_camStaleTimer); _camStaleTimer = null; } + // Operator may have zoomed in; reset so the next stream session + // starts at 1× rather than inheriting a stale view. + resetCameraZoom(); } else { _camFrameTimes = []; if (_camMeta) _camMeta.textContent = 'waiting…'; @@ -1122,6 +1139,116 @@ const DevicesManager = (function () { } } + // ---- Camera zoom / pan --------------------------------------------- + function applyCameraTransform() { + if (!_camImg) return; + _camImg.style.transform = + `translate(${_camTx}px, ${_camTy}px) scale(${_camZoom})`; + // Reticle uses an SVG transform attribute on the inner instead + // of a CSS transform on the SVG element — same geometric effect, + // but the SVG renderer re-rasterises at the new zoom so the 1px + // strokes stay crisp instead of getting bitmap-scaled. + if (_camCrosshairGroup && _camStage) { + const rect = _camStage.getBoundingClientRect(); + // Convert pixel-space translation to viewBox units (viewBox is + // 0..100 in both axes, preserveAspectRatio=none). + const txV = rect.width > 0 ? (_camTx * 100) / rect.width : 0; + const tyV = rect.height > 0 ? (_camTy * 100) / rect.height : 0; + // translate(50+tx, 50+ty) scale(zoom) translate(-50, -50) keeps + // the viewBox centre (50, 50) as the zoom anchor and offsets by + // the converted pixel translation. + _camCrosshairGroup.setAttribute( + 'transform', + `translate(${50 + txV} ${50 + tyV}) ` + + `scale(${_camZoom}) ` + + `translate(-50 -50)` + ); + } + } + + function resetCameraZoom() { + _camZoom = 1; + _camTx = 0; + _camTy = 0; + applyCameraTransform(); + if (_camStage) _camStage.classList.remove('camera-zoomed', 'camera-panning'); + } + + // Keep at least the image centre within the visible window so the + // operator can't accidentally pan the entire frame off-screen. At + // zoom 1 this collapses to (0, 0). + function clampCameraPan() { + if (!_camStage) return; + const rect = _camStage.getBoundingClientRect(); + const maxX = (rect.width * (_camZoom - 1)) / 2; + const maxY = (rect.height * (_camZoom - 1)) / 2; + _camTx = Math.max(-maxX, Math.min(maxX, _camTx)); + _camTy = Math.max(-maxY, Math.min(maxY, _camTy)); + } + + function onCameraWheel(event) { + if (!_camStage) return; + // Always preventDefault so the page doesn't scroll under the + // operator while they're framing a sample. + event.preventDefault(); + const rect = _camStage.getBoundingClientRect(); + const cx = event.clientX - rect.left - rect.width / 2; + const cy = event.clientY - rect.top - rect.height / 2; + const oldZoom = _camZoom; + 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; + + // Keep the image point under the cursor anchored under the cursor + // across the zoom: cursor_new = cursor_old after the transform + // change, which means newT = cursor - (cursor - oldT) * (new/old). + const ratio = newZoom / oldZoom; + _camTx = cx - (cx - _camTx) * ratio; + _camTy = cy - (cy - _camTy) * ratio; + _camZoom = newZoom; + + if (Math.abs(_camZoom - 1) < 0.001) { + resetCameraZoom(); + return; + } + clampCameraPan(); + applyCameraTransform(); + _camStage.classList.add('camera-zoomed'); + } + + function onCameraPointerDown(event) { + if (event.button !== 0) return; + if (_camZoom <= 1) return; + _camPanLast = { x: event.clientX, y: event.clientY }; + try { _camStage.setPointerCapture(event.pointerId); } catch (_) {} + _camStage.classList.add('camera-panning'); + event.preventDefault(); + } + + function onCameraPointerMove(event) { + if (!_camPanLast) return; + _camTx += event.clientX - _camPanLast.x; + _camTy += event.clientY - _camPanLast.y; + _camPanLast = { x: event.clientX, y: event.clientY }; + clampCameraPan(); + applyCameraTransform(); + } + + function onCameraPointerEnd(event) { + if (!_camPanLast) return; + _camPanLast = null; + try { _camStage.releasePointerCapture(event.pointerId); } catch (_) {} + if (_camStage) _camStage.classList.remove('camera-panning'); + } + + function onCameraDoubleClick(event) { + if (_camZoom !== 1 || _camTx !== 0 || _camTy !== 0) { + event.preventDefault(); + resetCameraZoom(); + } + } + function setupCameraWiring() { if (!_camToggle) return; _camToggle.addEventListener('click', toggleCameraStream); @@ -1129,6 +1256,16 @@ const DevicesManager = (function () { if (typeof ClientEventBus !== 'undefined') { ClientEventBus.on('BOTTOM_CAMERA_FRAME', handleCameraFrame); } + // Camera zoom/pan. wheel needs passive:false so we can preventDefault + // and stop the page from scrolling beneath the FOV. + if (_camStage) { + _camStage.addEventListener('wheel', onCameraWheel, { passive: false }); + _camStage.addEventListener('pointerdown', onCameraPointerDown); + _camStage.addEventListener('pointermove', onCameraPointerMove); + _camStage.addEventListener('pointerup', onCameraPointerEnd); + _camStage.addEventListener('pointercancel', onCameraPointerEnd); + _camStage.addEventListener('dblclick', onCameraDoubleClick); + } } // ===================================================================== diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index dd3bbfb0..64da673c 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -359,6 +359,20 @@

Device
bottom camera live frame + +
From 294f7dc4f8ccfd76bcbac23f7a3e1211a7138705 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 17:40:00 -0400 Subject: [PATCH 008/314] eval: event capture / replay / shadow scaffolding for orchestrator A/B Substrate for testing candidate orchestrator architectures without running real hardware. Three layers, all under gently/eval/: EventCapture Wildcard-subscribes to an EventBus; appends every Event to a per- session events.jsonl (D:/Gently3/sessions/{id}/events.jsonl). High-volume telemetry (DEVICE_STATE_UPDATE, BOTTOM_CAMERA_FRAME) is filtered out by default so 12-hour sessions don't drown the meaningful events under polling noise. Auto-starts in agent init. Handles non-JSON-native payloads (numpy, Path, datetime, set, Enum, dataclass, bytes) via a fallback serialiser. EventReplay Reads events.jsonl back; publishes via EventBus.publish_event() so original timestamps survive (candidates can reason about historical cadence). Fast mode (no sleep) and real-time mode with optional time_scale. event_types() for cheap pre-flight histogramming. DecisionLog + Decision + DecisionTrigger Per-session decisions.jsonl record. Each Decision captures WHY the agent woke up (trigger + detail), WHAT it saw (context summary, recent event ids, prompt hash), WHAT it did (tool calls, response text), and HOW it went (duration, error). Substrate for diffing candidate decisions later. ShadowRunner + OrchestratorCandidate + NoOpCandidate Candidates subscribe to an EventBus alongside production but their decisions are LOGGED, not enacted -- never permitted to touch hardware by construction. ShadowRunner hosts a set of candidates, isolates candidate failures from each other and from the live bus. NoOpCandidate ships as worked-example and proof-of-life. scripts/replay_session.py CLI: replay a session by id-prefix, with optional --candidate attachment, --real-time + --time-scale, and --histogram pre-flight. 15 unit tests in tests/test_eval.py covering capture filter, non-JSON payloads, thread safety, replay round-trip (event_type / source / data / correlation_id / timestamp all preserved), real-time cadence, time_scale, malformed-line tolerance, decision-log round-trip, shadow forwarding to multiple candidates, candidate-failure isolation, and event-type whitelisting. Phase 6 of the Map-as-embryo-home arc, unlocking offline iteration on the world-model + decision-moment work (operator-action events, wake triggers, tiered context). Co-Authored-By: Claude Opus 4.7 (cherry picked from commit d69cc219be8606c669b5d9891316fc6f142e8bf9) --- gently/app/agent.py | 45 +++++ gently/eval/__init__.py | 32 +++ gently/eval/decision_log.py | 170 ++++++++++++++++ gently/eval/event_capture.py | 162 ++++++++++++++++ gently/eval/event_replay.py | 127 ++++++++++++ gently/eval/shadow.py | 229 ++++++++++++++++++++++ scripts/replay_session.py | 150 +++++++++++++++ tests/test_eval.py | 363 +++++++++++++++++++++++++++++++++++ 8 files changed, 1278 insertions(+) create mode 100644 gently/eval/__init__.py create mode 100644 gently/eval/decision_log.py create mode 100644 gently/eval/event_capture.py create mode 100644 gently/eval/event_replay.py create mode 100644 gently/eval/shadow.py create mode 100644 scripts/replay_session.py create mode 100644 tests/test_eval.py diff --git a/gently/app/agent.py b/gently/app/agent.py index da7d7b11..b549e0f2 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -146,6 +146,11 @@ def __init__( # Interaction logger for structured logging (research data collection) self.interaction_logger: Optional[InteractionLogger] = None + # Event capture — durable log of every EventBus event during this + # session. Substrate for offline replay / shadow-mode A/B of + # candidate orchestrator architectures. + self.event_capture = None + # Timelapse orchestrator (initialized when microscope connected) self.timelapse_orchestrator: Optional[TimelapseOrchestrator] = None @@ -208,6 +213,12 @@ def __init__( # Initialize interaction logger (for research data collection) self._init_interaction_logger() + # Start event capture into the session folder so offline replay / + # shadow-mode testing has a durable input stream. Filters out the + # high-volume telemetry types (DEVICE_STATE_UPDATE / BOTTOM_CAMERA_FRAME) + # by default so a long timelapse doesn't bury the meaningful events. + self._init_event_capture() + # Wire interaction logger and choice handler to conversation manager self.conversation.interaction_logger = self.interaction_logger self.conversation.choice_handler = self.choice_handler @@ -457,6 +468,40 @@ def _init_interaction_logger(self): logging.getLogger(__name__).warning(f"Failed to init interaction logger: {e}") self.interaction_logger = None + def _init_event_capture(self): + """Open the per-session events.jsonl capture. + + Resolves the session folder via FileStore._session_dir so the log + sits next to session.yaml / interaction_log.jsonl. Silent no-op + when the session folder can't be resolved (e.g. test harness with + a stripped-down agent) — replay just won't have a log to read. + """ + from gently.eval import EventCapture + try: + session_dir = None + sid = self.session_id + if self.store is not None and sid: + session_dir = self.store._session_dir(sid) + if session_dir is None: + logging.getLogger(__name__).debug( + "EventCapture: no session dir for %s — skipping", sid) + return + path = session_dir / "events.jsonl" + self.event_capture = EventCapture(path) + self.event_capture.start(self._event_bus) + except Exception: + logging.getLogger(__name__).exception("Failed to init event capture") + self.event_capture = None + + def stop_event_capture(self): + """Flush + close the events.jsonl. Idempotent; safe at shutdown.""" + if self.event_capture is not None: + try: + self.event_capture.stop() + except Exception: + logging.getLogger(__name__).exception("EventCapture stop failed") + self.event_capture = None + def _init_timelapse_orchestrator(self): """Initialize the timelapse orchestrator if microscope is connected.""" if not self._has_microscope(): diff --git a/gently/eval/__init__.py b/gently/eval/__init__.py new file mode 100644 index 00000000..3c4ed22b --- /dev/null +++ b/gently/eval/__init__.py @@ -0,0 +1,32 @@ +"""Eval / replay / shadow primitives. + +Substrate for testing orchestrator architectures without running real +hardware. The three layers: + + EventCapture — records every EventBus event to a per-session jsonl + file so the agent's input stream is durable. + EventReplay — reads a captured jsonl and republishes events to a + target bus, preserving original timestamps. + ShadowRunner — hosts candidate orchestrators that subscribe to the + live (or replayed) bus, log their decisions, and + never touch hardware. Diff their decision logs to + compare architectures. + +See docs/EVAL.md (TODO) for usage. +""" + +from .event_capture import EventCapture +from .event_replay import EventReplay +from .decision_log import Decision, DecisionLog, DecisionTrigger +from .shadow import OrchestratorCandidate, ShadowRunner, NoOpCandidate + +__all__ = [ + "EventCapture", + "EventReplay", + "Decision", + "DecisionLog", + "DecisionTrigger", + "OrchestratorCandidate", + "ShadowRunner", + "NoOpCandidate", +] diff --git a/gently/eval/decision_log.py b/gently/eval/decision_log.py new file mode 100644 index 00000000..e6d713e9 --- /dev/null +++ b/gently/eval/decision_log.py @@ -0,0 +1,170 @@ +"""DecisionLog — records each "decision moment" the orchestrator (or a +shadow candidate) acts on. + +A "decision moment" is whenever the agent wakes up and produces an output: +a Claude tool call, a refusal, a chat reply, or even an explicit no-op +("I see what happened, nothing to do"). Capturing these gives us the diff +substrate for shadow-mode A/B: same input event stream, different +candidates, compare what each decided. + +File format: one JSON object per line, written to +D:/Gently3/sessions/{id}/decisions.jsonl (or wherever the caller points +it). Lossless enough to reconstruct what the agent saw + chose, terse +enough to skim across sessions. +""" + +from __future__ import annotations + +import json +import logging +import threading +from dataclasses import dataclass, field, asdict +from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .event_capture import _json_default + +logger = logging.getLogger(__name__) + + +class DecisionTrigger(str, Enum): + """What woke the agent up for this decision moment.""" + USER_MESSAGE = "user_message" + EVENT = "event" # event-driven (perception, error, etc.) + TICK = "tick" # scheduled / periodic checkpoint + PHASE = "phase" # plan phase boundary (between embryos / timepoints) + STARTUP = "startup" # initial session bring-up + UNKNOWN = "unknown" + + +@dataclass +class Decision: + """A single decision moment. + + The fields try to capture three things: + WHY the agent woke up: trigger, trigger_detail + WHAT it saw: context_summary, recent_event_ids + WHAT it did: tool_calls, response_text + + `prompt_hash` is a stable fingerprint of the actual prompt+context + sent to Claude so two candidates with byte-identical input but + different decisions can be told apart by a single field. + """ + timestamp: datetime + agent: str # "production" or candidate name + trigger: DecisionTrigger + trigger_detail: Optional[str] = None # event_id, user message excerpt, tick name + + tool_calls: List[Dict[str, Any]] = field(default_factory=list) + response_text: Optional[str] = None + prompt_hash: Optional[str] = None + + context_summary: Optional[str] = None # one-line description of state + recent_event_ids: List[str] = field(default_factory=list) + + duration_ms: Optional[float] = None # how long the decision took + error: Optional[str] = None # if the decision moment errored + + def to_dict(self) -> Dict[str, Any]: + return { + "timestamp": self.timestamp.isoformat(), + "agent": self.agent, + "trigger": self.trigger.value, + "trigger_detail": self.trigger_detail, + "tool_calls": self.tool_calls, + "response_text": self.response_text, + "prompt_hash": self.prompt_hash, + "context_summary": self.context_summary, + "recent_event_ids": self.recent_event_ids, + "duration_ms": self.duration_ms, + "error": self.error, + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Decision": + return cls( + timestamp=datetime.fromisoformat(d["timestamp"]), + agent=d.get("agent", "unknown"), + trigger=DecisionTrigger(d.get("trigger", "unknown")), + trigger_detail=d.get("trigger_detail"), + tool_calls=d.get("tool_calls") or [], + response_text=d.get("response_text"), + prompt_hash=d.get("prompt_hash"), + context_summary=d.get("context_summary"), + recent_event_ids=d.get("recent_event_ids") or [], + duration_ms=d.get("duration_ms"), + error=d.get("error"), + ) + + +class DecisionLog: + """Append-only jsonl sink for Decisions. Thread-safe.""" + + def __init__(self, path: Path): + self.path = Path(path) + self._fp = None + self._lock = threading.Lock() + self._count = 0 + + def open(self) -> None: + if self._fp is not None: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + self._fp = self.path.open("a", encoding="utf-8") + logger.info("DecisionLog: writing to %s", self.path) + + def close(self) -> None: + with self._lock: + if self._fp is not None: + try: + self._fp.close() + except Exception: + logger.exception("DecisionLog: close failed") + self._fp = None + logger.info("DecisionLog: closed (%d decisions written)", self._count) + + def append(self, decision: Decision) -> None: + try: + line = json.dumps(decision.to_dict(), default=_json_default) + except Exception: + logger.exception("DecisionLog: failed to serialise %s", decision) + return + with self._lock: + if self._fp is None: + self.open() + try: + self._fp.write(line + "\n") + self._fp.flush() + self._count += 1 + except Exception: + logger.exception("DecisionLog: write failed") + + @property + def count(self) -> int: + return self._count + + def __enter__(self): + self.open() + return self + + def __exit__(self, exc_type, exc, tb): + self.close() + return False + + def read(self) -> List[Decision]: + """Read every decision back from disk. Quick + dirty diff substrate.""" + if not self.path.exists(): + return [] + out: List[Decision] = [] + with self.path.open("r", encoding="utf-8") as f: + for line_no, raw in enumerate(f, start=1): + raw = raw.strip() + if not raw: + continue + try: + out.append(Decision.from_dict(json.loads(raw))) + except Exception: + logger.exception("DecisionLog: parse failure on line %d", line_no) + return out diff --git a/gently/eval/event_capture.py b/gently/eval/event_capture.py new file mode 100644 index 00000000..c92c9fcd --- /dev/null +++ b/gently/eval/event_capture.py @@ -0,0 +1,162 @@ +"""EventCapture — wildcard-subscribe to an EventBus and append every event +to a per-session jsonl file. + +The captured file is the substrate for replay and shadow-mode testing of +candidate orchestrators. High-volume telemetry types (DEVICE_STATE_UPDATE, +BOTTOM_CAMERA_FRAME) are filtered out by default — a 12-hour timelapse +would otherwise produce ~250 MB of polling noise and drown the meaningful +events (perception completions, operator actions, errors, plan boundaries). +Replay can reconstruct world state from the meaningful events plus the +state-snapshot model; it doesn't need the raw telemetry frames. + +File format: one JSON object per line, mirroring Event.to_dict(): + { + "event_type": "EMBRYOS_UPDATE", + "data": {...}, + "source": "agent.experiment", + "timestamp": "2026-05-15T15:32:55.123456", + "event_id": "abc12345", + "correlation_id": null + } +""" + +from __future__ import annotations + +import json +import logging +import threading +from dataclasses import asdict, is_dataclass +from datetime import date, datetime +from enum import Enum +from pathlib import Path +from typing import Optional, Set + +from gently.core.event_bus import Event, EventBus, EventType, _NO_HISTORY_TYPES + +logger = logging.getLogger(__name__) + + +class EventCapture: + """Append-only jsonl sink for an EventBus. + + Lifecycle: + capture = EventCapture(path) + capture.start(bus) # opens file, subscribes + ... + capture.stop() # unsubscribes, closes file + + Thread-safe — bus dispatch can come from any thread; writes are + serialised through a lock. + """ + + # By default the same set of high-volume telemetry types the EventBus + # itself skips for its history deque. The rationale carries over: at + # 5 Hz over hours these would dominate the log without adding signal + # that replay / diff can use. + DEFAULT_SKIP: Set[EventType] = frozenset(_NO_HISTORY_TYPES) + + def __init__(self, path: Path, *, + skip: Optional[Set[EventType]] = None): + self.path = Path(path) + self._skip = self.DEFAULT_SKIP if skip is None else frozenset(skip) + self._fp = None + self._unsub = None + self._lock = threading.Lock() + self._count = 0 + self._skipped = 0 + + def start(self, bus: EventBus) -> None: + """Open the capture file and subscribe to the bus (idempotent).""" + if self._fp is not None: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + self._fp = self.path.open("a", encoding="utf-8") + # Sync subscription on purpose — capture is fast (single file write) + # and we want capture order to match dispatch order without async + # scheduling ambiguity. + self._unsub = bus.subscribe("*", self._on_event) + logger.info("EventCapture: writing to %s", self.path) + + def stop(self) -> None: + """Unsubscribe and close the file (idempotent).""" + if self._unsub is not None: + try: + self._unsub() + except Exception: + logger.exception("EventCapture: unsubscribe failed") + self._unsub = None + with self._lock: + if self._fp is not None: + try: + self._fp.close() + except Exception: + logger.exception("EventCapture: file close failed") + self._fp = None + logger.info("EventCapture: closed (%d captured, %d skipped)", + self._count, self._skipped) + + def __del__(self): + # Best-effort safety net for cases where the owner forgets to call + # stop() — never let a forgotten file handle outlive the process' + # capture object. We can't rely on this for correctness (GC timing + # is undefined), but it makes tests and dev sessions tidier. + try: + self.stop() + except Exception: + pass + + @property + def count(self) -> int: + return self._count + + def _on_event(self, event: Event) -> None: + if event.event_type in self._skip: + self._skipped += 1 + return + try: + line = json.dumps(event.to_dict(), default=_json_default) + except Exception: + logger.exception("EventCapture: failed to serialise %s", event) + return + with self._lock: + if self._fp is None: + return + try: + self._fp.write(line + "\n") + self._fp.flush() + self._count += 1 + except Exception: + logger.exception("EventCapture: write failed for %s", event) + + +def _json_default(obj): + """Last-resort serialiser for types json.dumps can't natively handle. + + Designed to be lossy-but-useful: numpy arrays become lists, datetimes + become ISO strings, dataclasses become dicts, anything else falls back + to repr() so the line is at least valid JSON. + """ + if isinstance(obj, (datetime, date)): + return obj.isoformat() + if isinstance(obj, Path): + return str(obj) + if isinstance(obj, Enum): + return obj.name + if is_dataclass(obj): + try: + return asdict(obj) + except Exception: + pass + try: + import numpy as np + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, np.ndarray): + return obj.tolist() + except ImportError: + pass + if isinstance(obj, set): + return sorted(obj, key=str) + if isinstance(obj, bytes): + return obj.decode("utf-8", errors="replace") + return repr(obj) diff --git a/gently/eval/event_replay.py b/gently/eval/event_replay.py new file mode 100644 index 00000000..092ec719 --- /dev/null +++ b/gently/eval/event_replay.py @@ -0,0 +1,127 @@ +"""EventReplay — reads a captured events jsonl and republishes events to a +target EventBus. + +Two modes: + fast events as fast as the bus can dispatch (default) + real-time inserts sleep delays between events to preserve the original + cadence — useful when a candidate's behaviour depends on + time-since-last-event + +Original Event timestamps are preserved by going through +EventBus.publish_event() (which keeps the dataclass instance untouched) +rather than EventBus.publish() (which constructs a fresh Event with +datetime.now()). Candidates can therefore reason about historical timing +as if they were live. +""" + +from __future__ import annotations + +import json +import logging +import time +from datetime import datetime +from pathlib import Path +from typing import Callable, Iterator, Optional + +from gently.core.event_bus import Event, EventBus + +logger = logging.getLogger(__name__) + + +class EventReplay: + """Stream-replays an events.jsonl into a target bus.""" + + def __init__(self, path: Path): + self.path = Path(path) + if not self.path.exists(): + raise FileNotFoundError(f"event log not found: {self.path}") + + def events(self) -> Iterator[Event]: + """Yield each Event from the captured log, in order. + + Lines that don't parse are skipped with a warning rather than + aborting the whole replay — a partial log is better than no log. + """ + with self.path.open("r", encoding="utf-8") as f: + for line_no, raw in enumerate(f, start=1): + raw = raw.strip() + if not raw: + continue + try: + record = json.loads(raw) + except json.JSONDecodeError: + logger.warning("EventReplay: malformed line %d in %s", + line_no, self.path) + continue + try: + yield Event.from_dict(record) + except KeyError: + # Unknown EventType — could be a newer enum the + # capturing process knew about. Skip rather than abort. + logger.warning("EventReplay: unknown event_type on line %d", + line_no) + except Exception: + logger.exception("EventReplay: parse failure on line %d", + line_no) + + def replay( + self, + target: EventBus, + *, + real_time: bool = False, + time_scale: float = 1.0, + on_event: Optional[Callable[[Event], None]] = None, + ) -> int: + """Replay the captured events to ``target``. Returns count emitted. + + Parameters + ---------- + target: + EventBus to publish into. The bus's existing subscribers (and + any shadow candidates registered on it) will see the events. + real_time: + If True, sleep between events to reproduce the original + cadence. If False, dispatch as fast as the bus can handle. + time_scale: + Only meaningful in real-time mode. ``time_scale=4`` runs the + replay at 4× speed (sleep delays divided by 4). Must be > 0. + on_event: + Optional callback invoked after each event is published, for + instrumentation / progress reporting. Exceptions are caught + and logged. + """ + if time_scale <= 0: + raise ValueError("time_scale must be > 0") + + emitted = 0 + prev_ts: Optional[datetime] = None + wall_start = time.monotonic() + for ev in self.events(): + if real_time and prev_ts is not None: + delta = (ev.timestamp - prev_ts).total_seconds() / time_scale + if delta > 0: + time.sleep(delta) + target.publish_event(ev) + emitted += 1 + if on_event is not None: + try: + on_event(ev) + except Exception: + logger.exception("EventReplay: on_event callback failed") + prev_ts = ev.timestamp + wall = time.monotonic() - wall_start + logger.info( + "EventReplay: emitted %d events in %.2fs (real_time=%s, time_scale=%g)", + emitted, wall, real_time, time_scale, + ) + return emitted + + def event_types(self) -> dict: + """Return a {EventType.name: count} histogram of the log. + + Cheap pre-flight diagnostic before running an expensive replay. + """ + counts: dict = {} + for ev in self.events(): + counts[ev.event_type.name] = counts.get(ev.event_type.name, 0) + 1 + return counts diff --git a/gently/eval/shadow.py b/gently/eval/shadow.py new file mode 100644 index 00000000..05335c96 --- /dev/null +++ b/gently/eval/shadow.py @@ -0,0 +1,229 @@ +"""Shadow orchestrator scaffolding. + +A candidate orchestrator runs alongside production: it sees the same +events but its decisions are LOGGED, not enacted. Diff the decision logs +between production and a candidate (or between two candidates) to compare +architectures on identical input streams. + +Two entry points: + + OrchestratorCandidate + Protocol that any candidate must satisfy. Receives events via + on_event() and ticks via on_tick(); is given a DecisionLog to write + into. Never gets to call tools that touch hardware — by construction + its only output is the log. + + ShadowRunner + Hosts a set of candidates against a single EventBus. Wildcards onto + the bus and forwards each event to every registered candidate. + Lifecycle (start / stop) keeps subscriptions tidy. + +The simplest candidate is NoOpCandidate, included as a worked example +and as proof-of-life for the wiring (events visible? decision log +writeable? shutdown clean?). +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from abc import ABC, abstractmethod +from datetime import datetime +from pathlib import Path +from typing import Awaitable, Callable, Dict, List, Optional + +from gently.core.event_bus import Event, EventBus + +from .decision_log import Decision, DecisionLog, DecisionTrigger + +logger = logging.getLogger(__name__) + + +class OrchestratorCandidate(ABC): + """Base class for a shadow orchestrator candidate. + + A candidate is given: + - its name (e.g. "reactive-v1", "haiku-summariser") + - a DecisionLog to write decisions into + + It receives events synchronously via ``on_event``. If it needs to + do heavy work (LLM call, long compute), it should hand off to its + own task / thread and write into the log asynchronously. + + Candidates MUST NOT touch hardware. They have no access to the + device-layer client, no permission to publish events back onto the + bus, no MMCore handle. The only side effect they're allowed is + writing to their decision log. + """ + + def __init__(self, name: str, decisions: DecisionLog): + self.name = name + self.decisions = decisions + + @abstractmethod + def on_event(self, event: Event) -> None: + """Handle one event from the bus. Synchronous, must not block long.""" + + def on_start(self) -> None: + """Called once when the shadow runner attaches this candidate.""" + + def on_stop(self) -> None: + """Called once when the shadow runner detaches this candidate.""" + + # ---- helpers candidates can use --------------------------------------- + + def log_decision( + self, + *, + trigger: DecisionTrigger, + trigger_detail: Optional[str] = None, + tool_calls: Optional[List[Dict]] = None, + response_text: Optional[str] = None, + context_summary: Optional[str] = None, + recent_event_ids: Optional[List[str]] = None, + prompt_hash: Optional[str] = None, + duration_ms: Optional[float] = None, + error: Optional[str] = None, + ) -> None: + self.decisions.append(Decision( + timestamp=datetime.now(), + agent=self.name, + trigger=trigger, + trigger_detail=trigger_detail, + tool_calls=tool_calls or [], + response_text=response_text, + context_summary=context_summary, + recent_event_ids=recent_event_ids or [], + prompt_hash=prompt_hash, + duration_ms=duration_ms, + error=error, + )) + + +class NoOpCandidate(OrchestratorCandidate): + """Trivial candidate: logs every event it sees as a decision marker. + + Useful as the smoke test for the wiring (events visible? decision + log writeable? shutdown clean?) and as the template every real + candidate evolves from. + """ + + def __init__(self, name: str, decisions: DecisionLog, + *, watch: Optional[List[str]] = None): + super().__init__(name, decisions) + # Optional whitelist of event_type names to react to. None = all. + self._watch = set(watch) if watch else None + self._seen = 0 + + def on_event(self, event: Event) -> None: + if self._watch is not None and event.event_type.name not in self._watch: + return + self._seen += 1 + self.log_decision( + trigger=DecisionTrigger.EVENT, + trigger_detail=event.event_type.name, + response_text=f"(noop) seen {event.event_type.name} from {event.source}", + recent_event_ids=[event.event_id], + context_summary=f"noop candidate; events seen so far: {self._seen}", + ) + + +class ShadowRunner: + """Hosts a set of OrchestratorCandidates against an EventBus. + + Wildcards onto the bus, dispatches each event to every registered + candidate. Candidates' exceptions are caught and logged so one + bad candidate doesn't take down the others or affect the live bus. + + The runner itself never enacts decisions — it only forwards events + and lets candidates write their own logs. + """ + + def __init__(self, bus: EventBus): + self.bus = bus + self._candidates: List[OrchestratorCandidate] = [] + self._unsub: Optional[Callable[[], None]] = None + self._lock = threading.RLock() + self._running = False + + def add(self, candidate: OrchestratorCandidate) -> None: + with self._lock: + self._candidates.append(candidate) + if self._running: + try: + candidate.on_start() + except Exception: + logger.exception( + "ShadowRunner: on_start failed for %s", candidate.name + ) + + def remove(self, candidate: OrchestratorCandidate) -> None: + with self._lock: + try: + self._candidates.remove(candidate) + except ValueError: + return + try: + candidate.on_stop() + except Exception: + logger.exception( + "ShadowRunner: on_stop failed for %s", candidate.name + ) + + def start(self) -> None: + """Subscribe to the bus and notify every candidate. Idempotent.""" + with self._lock: + if self._running: + return + self._unsub = self.bus.subscribe("*", self._on_event) + for c in self._candidates: + try: + c.on_start() + except Exception: + logger.exception( + "ShadowRunner: on_start failed for %s", c.name + ) + self._running = True + logger.info( + "ShadowRunner: started with %d candidate(s)", len(self._candidates) + ) + + def stop(self) -> None: + """Unsubscribe from the bus and notify every candidate. Idempotent.""" + with self._lock: + if not self._running: + return + if self._unsub is not None: + try: + self._unsub() + except Exception: + logger.exception("ShadowRunner: unsubscribe failed") + self._unsub = None + for c in self._candidates: + try: + c.on_stop() + except Exception: + logger.exception( + "ShadowRunner: on_stop failed for %s", c.name + ) + self._running = False + logger.info("ShadowRunner: stopped") + + @property + def candidates(self) -> List[OrchestratorCandidate]: + with self._lock: + return list(self._candidates) + + def _on_event(self, event: Event) -> None: + # Snapshot under the lock so a remove() mid-dispatch doesn't break us. + with self._lock: + candidates = list(self._candidates) + for c in candidates: + try: + c.on_event(event) + except Exception: + logger.exception( + "ShadowRunner: candidate %s raised on %s", + c.name, event, + ) diff --git a/scripts/replay_session.py b/scripts/replay_session.py new file mode 100644 index 00000000..f8ffe208 --- /dev/null +++ b/scripts/replay_session.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +"""Replay a captured session's events into a fresh EventBus. + +Useful for: + - Diffing what a candidate orchestrator would have decided from the + same input stream the production agent saw. + - Inspecting the event histogram of a session before deciding what to + investigate ("did this session even fire any ERROR_OCCURRED?"). + - Re-running a session offline with a different filter / candidate + set without touching hardware. + +Examples +-------- +List the events recorded in a session: + python scripts/replay_session.py 2e0e0356 --histogram + +Replay as fast as possible: + python scripts/replay_session.py 2e0e0356 + +Replay with original cadence, 4x speed, and a NoOpCandidate writing a +decision log into the current directory: + python scripts/replay_session.py 2e0e0356 --real-time --time-scale 4 --candidate noop-test + +Custom root (default: $GENTLY_STORAGE_PATH or D:/Gently3): + python scripts/replay_session.py 2e0e0356 --root /path/to/sessions +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +from pathlib import Path + +# Allow `python scripts/replay_session.py …` from the repo root without +# requiring PYTHONPATH=.; the project root is one level up from this file. +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "session_id", + help="Session id (full or prefix) to replay", + ) + parser.add_argument( + "--root", + default=None, + help="Storage root (default: $GENTLY_STORAGE_PATH or D:/Gently3)", + ) + parser.add_argument( + "--real-time", + action="store_true", + help="Preserve original cadence between events (default: fast)", + ) + parser.add_argument( + "--time-scale", + type=float, + default=1.0, + help="Real-time replay speed multiplier (default: 1.0)", + ) + parser.add_argument( + "--candidate", + default=None, + help="Attach a NoOpCandidate; decisions written to replay-decisions-.jsonl", + ) + parser.add_argument( + "--histogram", + action="store_true", + help="Print event-type histogram, don't replay", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Verbose logging", + ) + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + from gently.core.event_bus import EventBus + from gently.core.file_store import FileStore + from gently.eval import EventReplay, DecisionLog, ShadowRunner, NoOpCandidate + + root = args.root or os.environ.get("GENTLY_STORAGE_PATH", "D:/Gently3") + store = FileStore(root=Path(root)) + + sessions = store.list_sessions() + matches = [s for s in sessions if s["session_id"].startswith(args.session_id)] + if not matches: + print(f"No session matching '{args.session_id}'", file=sys.stderr) + return 1 + if len(matches) > 1: + print(f"Multiple sessions match '{args.session_id}':", file=sys.stderr) + for s in matches: + print(f" {s['session_id']}", file=sys.stderr) + return 1 + + session = matches[0] + session_dir = store._session_dir(session["session_id"]) + log_path = session_dir / "events.jsonl" + if not log_path.exists(): + print(f"No events.jsonl in {session_dir}", file=sys.stderr) + return 1 + + rep = EventReplay(log_path) + + if args.histogram: + hist = rep.event_types() + total = sum(hist.values()) + print(f"{total} events in {log_path}:") + for ev, n in sorted(hist.items(), key=lambda kv: -kv[1]): + print(f" {n:>6} {ev}") + return 0 + + bus = EventBus() + runner = None + dlog = None + if args.candidate: + out = Path.cwd() / f"replay-decisions-{session['session_id'][:8]}.jsonl" + dlog = DecisionLog(out) + dlog.open() + runner = ShadowRunner(bus) + runner.add(NoOpCandidate(args.candidate, dlog)) + runner.start() + print(f"Candidate '{args.candidate}' attached; decisions -> {out}") + + try: + emitted = rep.replay( + bus, real_time=args.real_time, time_scale=args.time_scale, + ) + print(f"Replayed {emitted} events from session {session['session_id']}") + finally: + if runner is not None: + runner.stop() + if dlog is not None: + dlog.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_eval.py b/tests/test_eval.py new file mode 100644 index 00000000..8bc97064 --- /dev/null +++ b/tests/test_eval.py @@ -0,0 +1,363 @@ +"""Tests for the gently.eval package: capture / replay / shadow.""" + +from __future__ import annotations + +import json +import threading +import time +from datetime import datetime +from pathlib import Path + +import numpy as np +import pytest + +from gently.core.event_bus import EventBus, EventType, Event +from gently.eval import ( + Decision, + DecisionLog, + DecisionTrigger, + EventCapture, + EventReplay, + NoOpCandidate, + ShadowRunner, +) + + +# ============================================================================= +# EventCapture +# ============================================================================= + +def test_capture_writes_meaningful_events_skips_telemetry(tmp_path: Path): + """Capture skips high-volume telemetry by default but keeps the rest.""" + bus = EventBus() + log = tmp_path / "events.jsonl" + cap = EventCapture(log) + cap.start(bus) + + bus.publish(EventType.EMBRYOS_UPDATE, {"a": 1}, source="t") + bus.publish(EventType.DEVICE_STATE_UPDATE, {"x": 0}, source="t") # skipped + bus.publish(EventType.STAGE_MOVED, {"x": 100.0}, source="t") + bus.publish(EventType.BOTTOM_CAMERA_FRAME, {"jpeg": ""}, source="t") # skipped + cap.stop() + + assert cap.count == 2 # the two non-telemetry events + lines = log.read_text(encoding="utf-8").strip().split("\n") + assert len(lines) == 2 + parsed = [json.loads(ln) for ln in lines] + assert {p["event_type"] for p in parsed} == {"EMBRYOS_UPDATE", "STAGE_MOVED"} + + +def test_capture_handles_non_json_native_payloads(tmp_path: Path): + """numpy scalars / arrays / Paths / datetimes / sets serialise cleanly.""" + bus = EventBus() + log = tmp_path / "events.jsonl" + cap = EventCapture(log) + cap.start(bus) + + bus.publish(EventType.STAGE_MOVED, { + "np_scalar": np.float64(1.5), + "np_array": np.array([1, 2, 3]), + "path": Path("/tmp/foo.tif"), + "now": datetime(2026, 5, 15, 12, 0, 0), + "as_set": {"a", "b"}, + }, source="t") + cap.stop() + + record = json.loads(log.read_text().strip()) + data = record["data"] + assert data["np_scalar"] == pytest.approx(1.5) + assert data["np_array"] == [1, 2, 3] + assert "tmp" in data["path"] and "foo.tif" in data["path"] + assert data["now"] == "2026-05-15T12:00:00" + assert sorted(data["as_set"]) == ["a", "b"] + + +def test_capture_start_stop_idempotent(tmp_path: Path): + """Repeated start / stop don't error or duplicate subscribers.""" + bus = EventBus() + cap = EventCapture(tmp_path / "events.jsonl") + cap.start(bus) + cap.start(bus) # second start = no-op + bus.publish(EventType.STAGE_MOVED, {}, source="t") + cap.stop() + cap.stop() # second stop = no-op + # Even with two start() calls the single subscription captures the event + # exactly once. + assert cap.count == 1 + + +def test_capture_thread_safety(tmp_path: Path): + """Concurrent publishers from many threads all land in the log.""" + bus = EventBus() + cap = EventCapture(tmp_path / "events.jsonl") + cap.start(bus) + + N_THREADS = 8 + N_EVENTS_PER_THREAD = 50 + + def worker(idx: int): + for i in range(N_EVENTS_PER_THREAD): + bus.publish(EventType.STAGE_MOVED, + {"t": idx, "i": i}, source=f"thread-{idx}") + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(N_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + cap.stop() + # All events visible, jsonl is well-formed (each line a valid JSON object). + assert cap.count == N_THREADS * N_EVENTS_PER_THREAD + lines = (tmp_path / "events.jsonl").read_text().strip().split("\n") + assert len(lines) == N_THREADS * N_EVENTS_PER_THREAD + for ln in lines: + json.loads(ln) + + +# ============================================================================= +# EventReplay +# ============================================================================= + +def test_replay_preserves_event_fields(tmp_path: Path): + """Round-trip: capture, then replay, then verify Event field identity.""" + src_bus = EventBus() + cap = EventCapture(tmp_path / "events.jsonl") + cap.start(src_bus) + + e1 = src_bus.publish(EventType.EMBRYOS_UPDATE, + {"embryos": [{"id": "e1"}], "count": 1}, + source="capture-test", correlation_id="corr-A") + e2 = src_bus.publish(EventType.ERROR_OCCURRED, + {"msg": "bang"}, source="capture-test") + cap.stop() + + rep = EventReplay(tmp_path / "events.jsonl") + dest = EventBus() + received: list[Event] = [] + dest.subscribe("*", lambda ev: received.append(ev)) + rep.replay(dest) + + assert len(received) == 2 + + # event_type, source, correlation_id, event_id, timestamp preserved + by_id = {r.event_id: r for r in received} + r1 = by_id[e1.event_id] + r2 = by_id[e2.event_id] + assert r1.event_type == EventType.EMBRYOS_UPDATE + assert r1.source == "capture-test" + assert r1.correlation_id == "corr-A" + assert r1.data == e1.data + assert r1.timestamp == e1.timestamp + assert r2.event_type == EventType.ERROR_OCCURRED + assert r2.timestamp == e2.timestamp + + +def test_replay_histogram(tmp_path: Path): + bus = EventBus() + cap = EventCapture(tmp_path / "events.jsonl") + cap.start(bus) + bus.publish(EventType.STAGE_MOVED, {}, source="t") + bus.publish(EventType.STAGE_MOVED, {}, source="t") + bus.publish(EventType.EMBRYOS_UPDATE, {}, source="t") + cap.stop() + + rep = EventReplay(tmp_path / "events.jsonl") + hist = rep.event_types() + assert hist == {"STAGE_MOVED": 2, "EMBRYOS_UPDATE": 1} + + +def test_replay_real_time_respects_cadence(tmp_path: Path): + """Two events 200 ms apart replay in ~200 ms in real-time mode.""" + bus = EventBus() + cap = EventCapture(tmp_path / "events.jsonl") + cap.start(bus) + bus.publish(EventType.STAGE_MOVED, {"i": 0}, source="t") + time.sleep(0.2) + bus.publish(EventType.STAGE_MOVED, {"i": 1}, source="t") + cap.stop() + + dest = EventBus() + dest.subscribe("*", lambda ev: None) + + t0 = time.monotonic() + EventReplay(tmp_path / "events.jsonl").replay(dest, real_time=True) + elapsed = time.monotonic() - t0 + assert 0.10 < elapsed < 0.40, f"real-time elapsed={elapsed}" + + +def test_replay_time_scale_speeds_up(tmp_path: Path): + """time_scale=4 should approximately quarter the real-time wall delay.""" + bus = EventBus() + cap = EventCapture(tmp_path / "events.jsonl") + cap.start(bus) + bus.publish(EventType.STAGE_MOVED, {"i": 0}, source="t") + time.sleep(0.4) + bus.publish(EventType.STAGE_MOVED, {"i": 1}, source="t") + cap.stop() + + dest = EventBus() + dest.subscribe("*", lambda ev: None) + t0 = time.monotonic() + EventReplay(tmp_path / "events.jsonl").replay( + dest, real_time=True, time_scale=4.0, + ) + elapsed = time.monotonic() - t0 + # 0.4s scaled by 4 -> ~0.1s, with generous slack for scheduling. + assert 0.03 < elapsed < 0.30, f"scaled elapsed={elapsed}" + + +def test_replay_skips_malformed_lines(tmp_path: Path): + """A garbage line in the log doesn't abort the whole replay.""" + log = tmp_path / "events.jsonl" + log.write_text( + json.dumps({ + "event_type": "STAGE_MOVED", "data": {}, "source": "t", + "timestamp": "2026-01-01T00:00:00", "event_id": "abc", + "correlation_id": None, + }) + "\n" + "not valid json garbage\n" + + json.dumps({ + "event_type": "EMBRYOS_UPDATE", "data": {}, "source": "t", + "timestamp": "2026-01-01T00:00:01", "event_id": "def", + "correlation_id": None, + }) + "\n", + encoding="utf-8", + ) + rep = EventReplay(log) + seen = list(rep.events()) + assert [s.event_type.name for s in seen] == ["STAGE_MOVED", "EMBRYOS_UPDATE"] + + +def test_replay_missing_file_raises(tmp_path: Path): + with pytest.raises(FileNotFoundError): + EventReplay(tmp_path / "nope.jsonl") + + +# ============================================================================= +# DecisionLog +# ============================================================================= + +def test_decision_log_round_trip(tmp_path: Path): + log_path = tmp_path / "decisions.jsonl" + dlog = DecisionLog(log_path) + dlog.open() + + d1 = Decision( + timestamp=datetime(2026, 5, 15, 12, 0, 0), + agent="prod", + trigger=DecisionTrigger.USER_MESSAGE, + trigger_detail="detect embryos", + tool_calls=[{"name": "detect_embryos", "input": {}}], + response_text="Detected 4 embryos.", + context_summary="2 embryos active", + recent_event_ids=["abc12345"], + prompt_hash="deadbeef", + duration_ms=820.5, + ) + dlog.append(d1) + dlog.append(Decision( + timestamp=datetime(2026, 5, 15, 12, 0, 5), + agent="prod", + trigger=DecisionTrigger.EVENT, + trigger_detail="EMBRYOS_UPDATE", + error=None, + )) + dlog.close() + + back = dlog.read() + assert len(back) == 2 + assert back[0].agent == "prod" + assert back[0].trigger is DecisionTrigger.USER_MESSAGE + assert back[0].tool_calls == [{"name": "detect_embryos", "input": {}}] + assert back[0].duration_ms == pytest.approx(820.5) + assert back[1].trigger is DecisionTrigger.EVENT + assert back[1].trigger_detail == "EMBRYOS_UPDATE" + + +def test_decision_log_context_manager(tmp_path: Path): + log_path = tmp_path / "decisions.jsonl" + with DecisionLog(log_path) as dlog: + dlog.append(Decision( + timestamp=datetime.now(), + agent="t", + trigger=DecisionTrigger.TICK, + )) + assert log_path.exists() + assert len(log_path.read_text().splitlines()) == 1 + + +# ============================================================================= +# ShadowRunner + NoOpCandidate +# ============================================================================= + +def test_shadow_runner_forwards_to_all_candidates(tmp_path: Path): + bus = EventBus() + log_a = DecisionLog(tmp_path / "a.jsonl") + log_b = DecisionLog(tmp_path / "b.jsonl") + log_a.open() + log_b.open() + + cand_a = NoOpCandidate("cand-a", log_a) + cand_b = NoOpCandidate("cand-b", log_b) + runner = ShadowRunner(bus) + runner.add(cand_a) + runner.add(cand_b) + runner.start() + + bus.publish(EventType.STAGE_MOVED, {"x": 1}, source="t") + bus.publish(EventType.EMBRYOS_UPDATE, {"count": 1}, source="t") + runner.stop() + log_a.close() + log_b.close() + + decisions_a = log_a.read() + decisions_b = log_b.read() + assert len(decisions_a) == 2 + assert len(decisions_b) == 2 + assert [d.trigger_detail for d in decisions_a] == ["STAGE_MOVED", "EMBRYOS_UPDATE"] + assert [d.agent for d in decisions_a] == ["cand-a", "cand-a"] + assert [d.agent for d in decisions_b] == ["cand-b", "cand-b"] + + +def test_shadow_runner_isolates_candidate_failures(tmp_path: Path): + """A failing candidate doesn't break delivery to its peers.""" + bus = EventBus() + log_ok = DecisionLog(tmp_path / "ok.jsonl") + log_ok.open() + + class BoomCandidate(NoOpCandidate): + def on_event(self, event): + raise RuntimeError("intentional") + + runner = ShadowRunner(bus) + runner.add(BoomCandidate("boom", DecisionLog(tmp_path / "boom.jsonl"))) + runner.add(NoOpCandidate("ok", log_ok)) + runner.start() + + bus.publish(EventType.STAGE_MOVED, {}, source="t") + runner.stop() + log_ok.close() + + # Production candidate still received the event. + assert len(log_ok.read()) == 1 + + +def test_shadow_runner_watch_filter(tmp_path: Path): + """NoOpCandidate(watch=[...]) only fires for matching event types.""" + bus = EventBus() + dlog = DecisionLog(tmp_path / "d.jsonl") + dlog.open() + runner = ShadowRunner(bus) + runner.add(NoOpCandidate("only-errors", dlog, watch=["ERROR_OCCURRED"])) + runner.start() + + bus.publish(EventType.STAGE_MOVED, {}, source="t") + bus.publish(EventType.EMBRYOS_UPDATE, {}, source="t") + bus.publish(EventType.ERROR_OCCURRED, {"msg": "x"}, source="t") + runner.stop() + dlog.close() + + decs = dlog.read() + assert len(decs) == 1 + assert decs[0].trigger_detail == "ERROR_OCCURRED" From 2f4e1a255d88ff397512b6179ccf84797f1ed4d9 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 17:46:29 -0400 Subject: [PATCH 009/314] eval: capture production decisions per user turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the second half of the shadow-mode substrate. The agent now writes a Decision row to a per-session decisions.jsonl every time ConversationManager.call_claude returns — success or error. Pairs with the events.jsonl from Phase 6a so a candidate replay can be diffed against production turn-by-turn. What a production Decision captures ----------------------------------- - trigger always USER_MESSAGE for now (event/tick triggers land with the wake-router phase) - trigger_detail user message excerpt (200 chars) - tool_calls aggregated across the multi-step tool loop — every tool_use block Claude emitted during this turn - response_text final assistant text - prompt_hash short SHA-256 of (system_prompt, conversation_history) snapshotted BEFORE the tool loop appends to history. Same hash = same input; safe to compare candidate decisions against this one. - duration_ms wall time of the whole turn - error set on the failure path; the exception still re-raises to the caller so the existing error UX is unchanged Wiring ------ - gently/eval/decision_log.py new prompt_hash() helper (shared by production + candidates so the fingerprint format stays consistent) - gently/harness/conversation.py ConversationManager gains decision_log field; call_claude collects tool_use blocks across every Claude round, then writes one Decision in both success and except branches. Best-effort: a DecisionLog write failure never breaks the live agent. - gently/app/agent.py _init_decision_log opens session_dir/decisions.jsonl and assigns to self.conversation.decision_log; stop_decision_log mirrors stop_event_capture for shutdown cleanliness. - tests/test_eval.py +5 tests: prompt_hash stability and shape-tolerance; success path captures tool_calls + response + prompt_hash + duration; error path captures error + re-raises; no-log path is a clean no-op. Phase 6f. 20/20 tests green. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 75d7c9db7459fd3aebe8a73ca204c267816969de) --- gently/app/agent.py | 46 ++++++++ gently/eval/__init__.py | 3 +- gently/eval/decision_log.py | 23 ++++ gently/harness/conversation.py | 96 ++++++++++++++++ tests/test_eval.py | 198 +++++++++++++++++++++++++++++++++ 5 files changed, 365 insertions(+), 1 deletion(-) diff --git a/gently/app/agent.py b/gently/app/agent.py index b549e0f2..01c7371d 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -151,6 +151,11 @@ def __init__( # candidate orchestrator architectures. self.event_capture = None + # Decision log — what production decided at each turn (tool calls, + # response text, prompt hash). Pairs with event capture so a + # candidate replay can be diffed against production turn-by-turn. + self.decision_log = None + # Timelapse orchestrator (initialized when microscope connected) self.timelapse_orchestrator: Optional[TimelapseOrchestrator] = None @@ -219,6 +224,10 @@ def __init__( # by default so a long timelapse doesn't bury the meaningful events. self._init_event_capture() + # Open the per-session production decision log and hand it to the + # conversation manager so each Claude round-trip is captured. + self._init_decision_log() + # Wire interaction logger and choice handler to conversation manager self.conversation.interaction_logger = self.interaction_logger self.conversation.choice_handler = self.choice_handler @@ -502,6 +511,43 @@ def stop_event_capture(self): logging.getLogger(__name__).exception("EventCapture stop failed") self.event_capture = None + def _init_decision_log(self): + """Open the per-session decisions.jsonl and wire it into conversation. + + Each call to ConversationManager.call_claude writes one Decision + row (success or error) describing what production decided for the + user turn. Shadow candidates write their own rows into separate + logs and the two are diffed offline. + """ + from gently.eval import DecisionLog + try: + session_dir = None + sid = self.session_id + if self.store is not None and sid: + session_dir = self.store._session_dir(sid) + if session_dir is None: + logging.getLogger(__name__).debug( + "DecisionLog: no session dir for %s — skipping", sid) + return + path = session_dir / "decisions.jsonl" + self.decision_log = DecisionLog(path) + self.decision_log.open() + self.conversation.decision_log = self.decision_log + except Exception: + logging.getLogger(__name__).exception("Failed to init decision log") + self.decision_log = None + + def stop_decision_log(self): + """Flush + close the decisions.jsonl. Idempotent; safe at shutdown.""" + if self.decision_log is not None: + try: + self.decision_log.close() + except Exception: + logging.getLogger(__name__).exception("DecisionLog close failed") + self.decision_log = None + if hasattr(self, "conversation") and self.conversation is not None: + self.conversation.decision_log = None + def _init_timelapse_orchestrator(self): """Initialize the timelapse orchestrator if microscope is connected.""" if not self._has_microscope(): diff --git a/gently/eval/__init__.py b/gently/eval/__init__.py index 3c4ed22b..49cd873e 100644 --- a/gently/eval/__init__.py +++ b/gently/eval/__init__.py @@ -17,7 +17,7 @@ from .event_capture import EventCapture from .event_replay import EventReplay -from .decision_log import Decision, DecisionLog, DecisionTrigger +from .decision_log import Decision, DecisionLog, DecisionTrigger, prompt_hash from .shadow import OrchestratorCandidate, ShadowRunner, NoOpCandidate __all__ = [ @@ -26,6 +26,7 @@ "Decision", "DecisionLog", "DecisionTrigger", + "prompt_hash", "OrchestratorCandidate", "ShadowRunner", "NoOpCandidate", diff --git a/gently/eval/decision_log.py b/gently/eval/decision_log.py index e6d713e9..0a014b61 100644 --- a/gently/eval/decision_log.py +++ b/gently/eval/decision_log.py @@ -15,6 +15,7 @@ from __future__ import annotations +import hashlib import json import logging import threading @@ -29,6 +30,28 @@ logger = logging.getLogger(__name__) +def prompt_hash(system_prompt: Any, messages: Any) -> str: + """Stable short fingerprint of the input the orchestrator saw. + + Two candidates seeing byte-identical (system_prompt, messages) get + the same hash; a difference here means they're working from different + context, so any decision divergence is expected. Used in shadow A/B + to filter out apples-to-oranges comparisons. + + SHA-256 truncated to 16 hex chars — enough to make accidental + collisions vanishingly unlikely at the scale of one session's + decisions, short enough to skim by eye in a log. + """ + h = hashlib.sha256() + if isinstance(system_prompt, str): + h.update(system_prompt.encode("utf-8")) + else: + h.update(json.dumps(system_prompt, sort_keys=True, default=_json_default).encode("utf-8")) + h.update(b"\x1f") # separator so prompt boundary can't be ambiguous + h.update(json.dumps(messages, sort_keys=True, default=_json_default).encode("utf-8")) + return h.hexdigest()[:16] + + class DecisionTrigger(str, Enum): """What woke the agent up for this decision moment.""" USER_MESSAGE = "user_message" diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 8384ba9c..765d16b2 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -17,6 +17,27 @@ logger = logging.getLogger(__name__) +def _extend_tool_calls(out: List[Dict[str, Any]], content_blocks) -> None: + """Append every tool_use block in content_blocks to out. + + Tolerates absent attributes (some SDK versions / mock objects) so it + never crashes the live agent on a content-shape surprise. + """ + if not content_blocks: + return + for block in content_blocks: + try: + if getattr(block, "type", None) != "tool_use": + continue + out.append({ + "name": getattr(block, "name", None), + "input": getattr(block, "input", None), + "id": getattr(block, "id", None), + }) + except Exception: + continue + + class ConversationManager: """ Manages Claude API conversations, tool execution, and token tracking. @@ -48,6 +69,11 @@ def __init__(self, client, model, tool_registry): self.choice_handler = None self.context_store = None # for tool_label + # Decision capture for orchestrator A/B testing. Set by the agent + # alongside the EventCapture once the session folder is known. None + # = no capture, so tests / harnesses without a session still work. + self.decision_log = None + # ===== Quick Response ===== def try_quick_response(self, message: str, experiment, mode: str, @@ -175,6 +201,22 @@ async def call_claude(self, user_message: str, system_prompt, tools, } ) + # Snapshot inputs for decision capture BEFORE the tool loop starts + # appending to conversation_history. This is the state shadow + # candidates would need to reproduce production's input — same + # system_prompt and same starting messages. + decision_prompt_hash = None + if self.decision_log is not None: + try: + from gently.eval import prompt_hash as _prompt_hash + decision_prompt_hash = _prompt_hash( + system_prompt, list(self.conversation_history), + ) + except Exception: + logger.exception("Failed to compute decision prompt_hash") + + tool_calls_collected: List[Dict[str, Any]] = [] + assistant_message = "" error_occurred = None try: @@ -194,6 +236,7 @@ async def call_claude(self, user_message: str, system_prompt, tools, **api_kwargs ) self._track_token_usage(response) + _extend_tool_calls(tool_calls_collected, response.content) # Process tool calls while response.stop_reason == "tool_use": @@ -216,6 +259,7 @@ async def call_claude(self, user_message: str, system_prompt, tools, **api_kwargs ) self._track_token_usage(response) + _extend_tool_calls(tool_calls_collected, response.content) # Extract text response assistant_message = "" @@ -242,6 +286,14 @@ async def call_claude(self, user_message: str, system_prompt, tools, error=error_occurred, error_traceback=error_tb, ) + self._write_production_decision( + user_message=user_message, + tool_calls=tool_calls_collected, + response_text=assistant_message, + duration_ms=(time.time() - start_time) * 1000.0, + prompt_hash_value=decision_prompt_hash, + error=error_occurred, + ) raise if interaction and self.interaction_logger: @@ -251,10 +303,54 @@ async def call_claude(self, user_message: str, system_prompt, tools, total_duration_seconds=time.time() - start_time, ) + self._write_production_decision( + user_message=user_message, + tool_calls=tool_calls_collected, + response_text=assistant_message, + duration_ms=(time.time() - start_time) * 1000.0, + prompt_hash_value=decision_prompt_hash, + error=None, + ) + auto_save_fn() return assistant_message + def _write_production_decision( + self, + *, + user_message: str, + tool_calls: List[Dict[str, Any]], + response_text: str, + duration_ms: float, + prompt_hash_value: Optional[str], + error: Optional[str], + ) -> None: + """Persist one production Decision row (best-effort). + + Failures here are swallowed — decision capture must never break + the live agent. The DecisionLog itself is also tolerant of + serialisation errors. + """ + if self.decision_log is None: + return + try: + from datetime import datetime + from gently.eval import Decision, DecisionTrigger + self.decision_log.append(Decision( + timestamp=datetime.now(), + agent="production", + trigger=DecisionTrigger.USER_MESSAGE, + trigger_detail=(user_message or "")[:200], + tool_calls=tool_calls, + response_text=response_text, + prompt_hash=prompt_hash_value, + duration_ms=duration_ms, + error=error, + )) + except Exception: + logger.exception("Failed to write production Decision") + # ===== Dry-Run Tool Call (Benchmarking) ===== async def get_tool_call(self, user_message: str, system_prompt, tools) -> Optional[Dict]: diff --git a/tests/test_eval.py b/tests/test_eval.py index 8bc97064..28fe46ea 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -361,3 +361,201 @@ def test_shadow_runner_watch_filter(tmp_path: Path): decs = dlog.read() assert len(decs) == 1 assert decs[0].trigger_detail == "ERROR_OCCURRED" + + +# ============================================================================= +# prompt_hash +# ============================================================================= + +def test_prompt_hash_stable_and_distinguishing(): + """Identical inputs → identical hash; any change → different hash.""" + from gently.eval import prompt_hash + h1 = prompt_hash("sys-A", [{"role": "user", "content": "hi"}]) + h2 = prompt_hash("sys-A", [{"role": "user", "content": "hi"}]) + h3 = prompt_hash("sys-A", [{"role": "user", "content": "hello"}]) + h4 = prompt_hash("sys-B", [{"role": "user", "content": "hi"}]) + assert h1 == h2 + assert h1 != h3 + assert h1 != h4 + assert len(h1) == 16 # documented short fingerprint length + + +def test_prompt_hash_accepts_list_system_prompt(): + """Cached system prompts use the list-of-blocks shape; hashing must work.""" + from gently.eval import prompt_hash + list_prompt = [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}] + str_prompt = "sys" + # Different shapes, different hashes — that's fine, the point is just + # that the list case doesn't raise. + h_list = prompt_hash(list_prompt, []) + h_str = prompt_hash(str_prompt, []) + assert isinstance(h_list, str) and isinstance(h_str, str) + assert len(h_list) == 16 and len(h_str) == 16 + + +# ============================================================================= +# ConversationManager production-decision capture (success + error paths) +# ============================================================================= + +def _make_fake_conversation_manager(claude_client): + """Build a ConversationManager with a fake Claude client and a no-op + tool registry — enough to exercise call_claude's decision-write path.""" + import asyncio # noqa: F401 (used by callers) + from gently.harness.conversation import ConversationManager + + class _NoopReg: + # call_claude doesn't use this directly; tools list is passed in + pass + + return ConversationManager(claude_client, "claude-haiku-4-5-20251001", _NoopReg()) + + +class _Usage: + input_tokens = 10 + output_tokens = 20 + cache_creation_input_tokens = 0 + cache_read_input_tokens = 0 + + +class _ToolBlock: + type = "tool_use" + name = "detect_embryos" + input = {"min_confidence": 0.7} + id = "t1" + + +class _TextBlock: + type = "text" + text = "Done." + + +class _R1: + stop_reason = "tool_use" + content = [_ToolBlock()] + usage = _Usage() + + +class _R2: + stop_reason = "end_turn" + content = [_TextBlock()] + usage = _Usage() + + +def test_production_decision_capture_success(tmp_path: Path): + """One success turn through call_claude writes one Decision row.""" + import asyncio + + calls = {"n": 0} + + class _FakeMessages: + def create(self, **kw): + calls["n"] += 1 + return _R1() if calls["n"] == 1 else _R2() + + class _FakeClient: + messages = _FakeMessages() + + cm = _make_fake_conversation_manager(_FakeClient()) + + # Bypass actual tool execution + async def fake_exec(content_blocks, interaction): + return [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}] + cm._execute_tools_with_logging = fake_exec + + dlog = DecisionLog(tmp_path / "decisions.jsonl") + dlog.open() + cm.decision_log = dlog + + async def run(): + return await cm.call_claude( + user_message="find embryos please", + system_prompt="system", + tools=[], + mode="run", + auto_save_fn=lambda: None, + ) + + out = asyncio.run(run()) + dlog.close() + + assert out == "Done." + decs = dlog.read() + assert len(decs) == 1 + d = decs[0] + assert d.agent == "production" + assert d.trigger is DecisionTrigger.USER_MESSAGE + assert d.trigger_detail == "find embryos please" + assert d.tool_calls == [{ + "name": "detect_embryos", + "input": {"min_confidence": 0.7}, + "id": "t1", + }] + assert d.response_text == "Done." + assert d.error is None + assert d.prompt_hash is not None and len(d.prompt_hash) == 16 + assert d.duration_ms is not None and d.duration_ms >= 0 + + +def test_production_decision_capture_error(tmp_path: Path): + """A failing Claude call writes a Decision with error before re-raising.""" + import asyncio + + class _BoomMessages: + def create(self, **kw): + raise RuntimeError("simulated outage") + + class _BoomClient: + messages = _BoomMessages() + + cm = _make_fake_conversation_manager(_BoomClient()) + dlog = DecisionLog(tmp_path / "decisions.jsonl") + dlog.open() + cm.decision_log = dlog + + async def run(): + with pytest.raises(RuntimeError, match="simulated outage"): + await cm.call_claude( + user_message="do something", + system_prompt="system", + tools=[], + mode="run", + auto_save_fn=lambda: None, + ) + + asyncio.run(run()) + dlog.close() + decs = dlog.read() + assert len(decs) == 1 + assert decs[0].error == "simulated outage" + assert decs[0].trigger is DecisionTrigger.USER_MESSAGE + assert decs[0].response_text and "simulated outage" in decs[0].response_text + + +def test_production_decision_capture_no_log_is_no_op(tmp_path: Path): + """No DecisionLog attached → call_claude proceeds normally, no errors.""" + import asyncio + + calls = {"n": 0} + + class _M: + def create(self, **kw): + calls["n"] += 1 + return _R2() # immediate end_turn, no tool loop + + class _C: + messages = _M() + + cm = _make_fake_conversation_manager(_C()) + assert cm.decision_log is None # default + + async def run(): + return await cm.call_claude( + user_message="hi", + system_prompt="sys", + tools=[], + mode="run", + auto_save_fn=lambda: None, + ) + + out = asyncio.run(run()) + assert out == "Done." # no log to read; we just want no error From 1194a1f584a8222ef177f2b05e531db984bf92b4 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 18:13:05 -0400 Subject: [PATCH 010/314] eval: operator-action events + ReactiveCandidate (first real shadow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of the closed-loop paradigm, tightly coupled. Operator-action events (vocabulary) ----------------------------------- Three new EventType values for human-driven mutations. They're distinct from EMBRYOS_UPDATE because they carry INTENT, not just state delta — candidates can reason about "the operator just did X" without typing that fact into chat. OPERATOR_EDITED_EMBRYO PUT /api/embryos/{id}/position payload: embryo_id + old/new coarse + fine_position_invalidated OPERATOR_REMOVED_EMBRYO DELETE /api/embryos/{id} payload: embryo_id + last_position OPERATOR_MARKED_EMBRYOS detect_embryos web-editor finish payload: embryo_ids + count + stage_origin + pre_edit_count Map-edit routes publish via server.agent_bridge.agent._event_bus. detect_embryos publishes only when the operator actually confirmed via the web canvas (operator_marked flag) — if the editor was skipped, the SAM list still landed in experiment.embryos but it wasn't operator- confirmed, so no operator event. ReactiveCandidate (first real candidate) ---------------------------------------- gently/eval/candidates.py — pure-rule shadow orchestrator with a tiny world model (embryos + last stage + last error). Reacts to: EMBRYOS_UPDATE ingest, silent STAGE_MOVED ingest, silent OPERATOR_EDITED_EMBRYO propose recalibrate_embryo if fine was invalidated OPERATOR_MARKED_EMBRYOS propose calibrate_all_embryos for the new set OPERATOR_REMOVED_EMBRYO propose forget_embryo for cache tidy-up ERROR_OCCURRED escalate first occurrence, suppress same msg within 30s The thesis being tested: a rule-based responder can do the routine bookkeeping that today only happens when the operator chats with Claude. Shadow mode will tell us how often that thesis holds in practice. Tests ----- +7 ReactiveCandidate tests covering silent ingest, conditional recalibrate, marked-set proposal, removal tidy-up, error escalate/suppress, and a full event-stream-through-replay smoke that proves the captured jsonl alone is sufficient input to drive a candidate to a decision log. 27/27 green. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 0a97563e94bbe53a41704602744190ad92a81034) --- gently/app/tools/detection_tools.py | 20 +++ gently/core/event_bus.py | 8 + gently/eval/__init__.py | 2 + gently/eval/candidates.py | 257 ++++++++++++++++++++++++++++ gently/ui/web/routes/data.py | 50 +++++- tests/test_eval.py | 208 ++++++++++++++++++++++ 6 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 gently/eval/candidates.py diff --git a/gently/app/tools/detection_tools.py b/gently/app/tools/detection_tools.py index 08cf58fd..e30f5f33 100644 --- a/gently/app/tools/detection_tools.py +++ b/gently/app/tools/detection_tools.py @@ -204,6 +204,26 @@ async def detect_embryos( ) added.append((emb_id, m.get("role", default_role))) + # OPERATOR_MARKED_EMBRYOS — operator confirmed via the web canvas. + # This is the intent signal eval/shadow listeners hook for ReactiveCandidate. + if added: + bus = getattr(agent, '_event_bus', None) + if bus is not None: + from gently.core.event_bus import EventType + try: + bus.publish( + event_type=EventType.OPERATOR_MARKED_EMBRYOS, + data={ + 'embryo_ids': [eid for eid, _ in added], + 'count': len(added), + 'stage_origin': list(stage_pos), + 'pre_edit_count': len(sam_embryos), + }, + source='detect_embryos:web-editor', + ) + except Exception: + pass + role_counts = {} for _, r in added: role_counts[r] = role_counts.get(r, 0) + 1 diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index 0b7d57b2..3d59d9c6 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -84,6 +84,14 @@ class EventType(Enum): BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment + # Operator-action events. Distinct from EMBRYOS_UPDATE because they + # carry intent ("a human did this") rather than just state delta. + # Candidate orchestrators can subscribe and reason about what the + # operator just did without having to type it in chat. + OPERATOR_EDITED_EMBRYO = auto() # Map drag/drop -> PUT /api/embryos/{id}/position + OPERATOR_REMOVED_EMBRYO = auto() # Map delete -> DELETE /api/embryos/{id} + OPERATOR_MARKED_EMBRYOS = auto() # Marking canvas "Done" — operator confirmed N positions + # System events ERROR_OCCURRED = auto() WARNING_ISSUED = auto() diff --git a/gently/eval/__init__.py b/gently/eval/__init__.py index 49cd873e..e93f5d5c 100644 --- a/gently/eval/__init__.py +++ b/gently/eval/__init__.py @@ -19,6 +19,7 @@ from .event_replay import EventReplay from .decision_log import Decision, DecisionLog, DecisionTrigger, prompt_hash from .shadow import OrchestratorCandidate, ShadowRunner, NoOpCandidate +from .candidates import ReactiveCandidate __all__ = [ "EventCapture", @@ -30,4 +31,5 @@ "OrchestratorCandidate", "ShadowRunner", "NoOpCandidate", + "ReactiveCandidate", ] diff --git a/gently/eval/candidates.py b/gently/eval/candidates.py new file mode 100644 index 00000000..111cc935 --- /dev/null +++ b/gently/eval/candidates.py @@ -0,0 +1,257 @@ +"""Canned shadow orchestrator candidates. + +NoOpCandidate lives in shadow.py as the trivial baseline. Anything more +interesting — even pure-rule architectures with state — lives here. As +LLM-driven candidates land they should slot into this module too. + +Conventions every candidate should keep: + - It maintains its own tiny world model. The production agent's + `experiment` is intentionally not shared (a candidate that mutates + production state would defeat the point of shadow mode). + - Decisions go through `log_decision`. Never call hardware tools. + - State updates from events are cheap (no LLM, no I/O). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from gently.core.event_bus import Event + +from .decision_log import DecisionLog, DecisionTrigger +from .shadow import OrchestratorCandidate + +logger = logging.getLogger(__name__) + + +@dataclass +class _ReactiveWorldModel: + """The tiniest possible world model — everything ReactiveCandidate + needs to make rule-based decisions without re-reading the agent.""" + + # {embryo_id: {"coarse": {x, y} | None, "fine": {x, y} | None, + # "has_fine": bool, "confidence": float}} + embryos: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + # Last live stage XY (µm) from a STAGE_MOVED event. + last_stage_um: Optional[Dict[str, float]] = None + + # Last error message + timestamp, so the candidate can avoid + # spam-proposing escalations for the same recurring failure. + last_error: Optional[Dict[str, Any]] = None + + # Count of events seen, by type name — useful debug field that also + # ends up in the decision context_summary. + seen: Dict[str, int] = field(default_factory=dict) + + +class ReactiveCandidate(OrchestratorCandidate): + """Pure-rule reactive shadow orchestrator. + + The thesis being tested: *can a rule-based responder do the + routine bookkeeping that today only happens when the operator + chats with Claude?* + + Reactions + --------- + OPERATOR_EDITED_EMBRYO + Operator moved an embryo on the Map. The PUT also clears fine + position. Propose `recalibrate_embryo(embryo_id)` so the new + coarse position gets a SPIM-fine alignment before the next + acquisition. If `fine_position_invalidated` was False (no fine + existed yet) skip the proposal — there's nothing to refresh. + + OPERATOR_MARKED_EMBRYOS + Operator just confirmed a fresh set of embryos via the marking + canvas. Propose `calibrate_all_embryos` to bring them all into + focus. Cheap pattern: kick off calibration the moment sightings + land, instead of waiting for the operator to type it. + + OPERATOR_REMOVED_EMBRYO + Operator deleted an embryo. Propose a tidy-up step + `forget_embryo(embryo_id)` for any candidate that wants to + clean caches / learnings keyed on the gone embryo. No-op for + production today (state mutation already happened); the + proposal is reserved for downstream cleanup tools. + + ERROR_OCCURRED + Propose `escalate_to_operator(error_message)` once per distinct + error. Suppresses if the same error fires twice within 30s — + avoids drowning the operator in repeat alarms. + + EMBRYOS_UPDATE / STAGE_MOVED + Update the world model. No decision logged (silent ingest). + + """ + + # If two ERROR_OCCURRED events with the same message arrive within + # this window, only the first proposes an escalation. + ERROR_SUPPRESS_WINDOW_SEC = 30.0 + + def __init__(self, name: str, decisions: DecisionLog): + super().__init__(name, decisions) + self.world = _ReactiveWorldModel() + + # ---- event handlers ---------------------------------------------------- + + def on_event(self, event: Event) -> None: + name = event.event_type.name + self.world.seen[name] = self.world.seen.get(name, 0) + 1 + + # Always ingest state-shaped events first. + if name == "EMBRYOS_UPDATE": + self._ingest_embryos_update(event) + return + if name == "STAGE_MOVED": + self._ingest_stage_moved(event) + return + + # Operator + error events produce decisions. + if name == "OPERATOR_EDITED_EMBRYO": + self._react_operator_edited(event) + return + if name == "OPERATOR_MARKED_EMBRYOS": + self._react_operator_marked(event) + return + if name == "OPERATOR_REMOVED_EMBRYO": + self._react_operator_removed(event) + return + if name == "ERROR_OCCURRED": + self._react_error(event) + return + + # ---- ingests ----------------------------------------------------------- + + def _ingest_embryos_update(self, event: Event) -> None: + embryos = (event.data or {}).get("embryos") or [] + new_world: Dict[str, Dict[str, Any]] = {} + for emb in embryos: + new_world[emb.get("id", "")] = { + "coarse": emb.get("position_coarse"), + "fine": emb.get("position_fine"), + "has_fine": bool(emb.get("has_fine_position")), + "confidence": emb.get("detection_confidence", 0.0), + } + self.world.embryos = new_world + + def _ingest_stage_moved(self, event: Event) -> None: + d = event.data or {} + if "x" in d and "y" in d: + self.world.last_stage_um = {"x": float(d["x"]), "y": float(d["y"])} + + # ---- reactions --------------------------------------------------------- + + def _react_operator_edited(self, event: Event) -> None: + data = event.data or {} + eid = data.get("embryo_id") or "" + invalidated = bool(data.get("fine_position_invalidated")) + tool_calls: List[Dict[str, Any]] = [] + # Only propose a recalibration when there was a fine position + # that the edit just invalidated. New coarse without any prior + # fine has nothing to refresh yet. + if invalidated: + tool_calls.append({ + "name": "recalibrate_embryo", + "input": {"embryo_id": eid}, + "id": None, + }) + self.log_decision( + trigger=DecisionTrigger.EVENT, + trigger_detail="OPERATOR_EDITED_EMBRYO", + tool_calls=tool_calls, + response_text=( + f"Operator moved {eid}; proposing recalibration." + if invalidated else + f"Operator moved {eid}; no prior fine -- no action." + ), + recent_event_ids=[event.event_id], + context_summary=self._summary(), + ) + + def _react_operator_marked(self, event: Event) -> None: + data = event.data or {} + ids = data.get("embryo_ids") or [] + count = data.get("count", len(ids)) + tool_calls: List[Dict[str, Any]] = [] + if count: + tool_calls.append({ + "name": "calibrate_all_embryos", + "input": {"embryo_ids": list(ids)}, + "id": None, + }) + self.log_decision( + trigger=DecisionTrigger.EVENT, + trigger_detail="OPERATOR_MARKED_EMBRYOS", + tool_calls=tool_calls, + response_text=( + f"Operator marked {count} embryos; proposing calibration." + if count else + "Operator marked zero embryos; no action." + ), + recent_event_ids=[event.event_id], + context_summary=self._summary(), + ) + + def _react_operator_removed(self, event: Event) -> None: + data = event.data or {} + eid = data.get("embryo_id") or "" + self.log_decision( + trigger=DecisionTrigger.EVENT, + trigger_detail="OPERATOR_REMOVED_EMBRYO", + tool_calls=[{ + "name": "forget_embryo", + "input": {"embryo_id": eid}, + "id": None, + }], + response_text=f"Operator removed {eid}; proposing cache tidy-up.", + recent_event_ids=[event.event_id], + context_summary=self._summary(), + ) + + def _react_error(self, event: Event) -> None: + from datetime import datetime + data = event.data or {} + msg = str(data.get("msg") or data.get("error") or data.get("message") or "unknown") + now = datetime.now() + prior = self.world.last_error + suppress = ( + prior is not None + and prior.get("msg") == msg + and (now - prior["ts"]).total_seconds() < self.ERROR_SUPPRESS_WINDOW_SEC + ) + self.world.last_error = {"msg": msg, "ts": now} + if suppress: + self.log_decision( + trigger=DecisionTrigger.EVENT, + trigger_detail="ERROR_OCCURRED", + tool_calls=[], + response_text=f"Suppressed repeat error within {self.ERROR_SUPPRESS_WINDOW_SEC:.0f}s window: {msg[:120]}", + recent_event_ids=[event.event_id], + context_summary=self._summary(), + ) + return + self.log_decision( + trigger=DecisionTrigger.EVENT, + trigger_detail="ERROR_OCCURRED", + tool_calls=[{ + "name": "escalate_to_operator", + "input": {"error_message": msg, "source": event.source}, + "id": None, + }], + response_text=f"New error -- proposing escalation: {msg[:120]}", + recent_event_ids=[event.event_id], + context_summary=self._summary(), + ) + + # ---- helpers ----------------------------------------------------------- + + def _summary(self) -> str: + n_emb = len(self.world.embryos) + n_fine = sum(1 for v in self.world.embryos.values() if v.get("has_fine")) + stage = self.world.last_stage_um + stage_str = f"({stage['x']:.1f}, {stage['y']:.1f})" if stage else "unknown" + seen = sum(self.world.seen.values()) + return (f"{n_emb} embryos ({n_fine} fine-calibrated); " + f"stage {stage_str}; {seen} events ingested") diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 6a3e8ef5..7bb5ad3b 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -89,6 +89,10 @@ async def update_embryo_position( position — the operator is overriding the sighting, so any SPIM-objective fine alignment derived from the old coarse is no longer trustworthy and must be re-run. + + Publishes OPERATOR_EDITED_EMBRYO with both the old and new + positions so candidates can reason about the magnitude of the + correction and trigger re-calibration suggestions. """ agent = _require_agent_with_experiment() emb = agent.experiment.embryos.get(embryo_id) @@ -99,9 +103,28 @@ async def update_embryo_position( y = float(body.get("y")) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="Body needs numeric x and y") + old_coarse = dict(emb.position_coarse) if emb.position_coarse else None + had_fine = bool(emb.position_fine) emb.position_coarse = {"x": x, "y": y} emb.position_fine = {} agent.experiment.notify_embryos_changed() + + bus = getattr(agent, "_event_bus", None) + if bus is not None: + from gently.core.event_bus import EventType + try: + bus.publish( + event_type=EventType.OPERATOR_EDITED_EMBRYO, + data={ + "embryo_id": embryo_id, + "old_position_coarse": old_coarse, + "new_position_coarse": {"x": x, "y": y}, + "fine_position_invalidated": had_fine, + }, + source="web:map-edit", + ) + except Exception: + logger.exception("Failed to publish OPERATOR_EDITED_EMBRYO") return emb.to_dict() @router.delete("/api/embryos/{embryo_id}", @@ -110,11 +133,36 @@ async def delete_embryo(embryo_id: str): """Remove an embryo from the experiment. Goes through ExperimentState.remove_embryo so the observer hook - fires EMBRYOS_UPDATE automatically. + fires EMBRYOS_UPDATE automatically. Also publishes + OPERATOR_REMOVED_EMBRYO carrying the embryo's last known position + — candidates can use that to e.g. clean up associated cache or + log the deletion in their own world model. """ agent = _require_agent_with_experiment() + emb = agent.experiment.embryos.get(embryo_id) + last_position = None + if emb is not None: + last_position = { + "coarse": dict(emb.position_coarse) if emb.position_coarse else None, + "fine": dict(emb.position_fine) if emb.position_fine else None, + } if not agent.experiment.remove_embryo(embryo_id): raise HTTPException(status_code=404, detail=f"Embryo {embryo_id} not found") + + bus = getattr(agent, "_event_bus", None) + if bus is not None: + from gently.core.event_bus import EventType + try: + bus.publish( + event_type=EventType.OPERATOR_REMOVED_EMBRYO, + data={ + "embryo_id": embryo_id, + "last_position": last_position, + }, + source="web:map-delete", + ) + except Exception: + logger.exception("Failed to publish OPERATOR_REMOVED_EMBRYO") return {"ok": True, "embryo_id": embryo_id} @router.get("/api/embryos/current") diff --git a/tests/test_eval.py b/tests/test_eval.py index 28fe46ea..879b7fc9 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -19,6 +19,7 @@ EventCapture, EventReplay, NoOpCandidate, + ReactiveCandidate, ShadowRunner, ) @@ -559,3 +560,210 @@ async def run(): out = asyncio.run(run()) assert out == "Done." # no log to read; we just want no error + + +# ============================================================================= +# ReactiveCandidate +# ============================================================================= + +def _publish(bus, event_type_name: str, data: dict, source: str = "test"): + bus.publish(EventType[event_type_name], data, source=source) + + +def _decisions_for(dlog: DecisionLog): + return dlog.read() + + +def test_reactive_ingests_embryos_update_silently(tmp_path: Path): + """EMBRYOS_UPDATE updates the world model but emits no decision.""" + bus = EventBus() + dlog = DecisionLog(tmp_path / "d.jsonl") + dlog.open() + cand = ReactiveCandidate("reactive-test", dlog) + runner = ShadowRunner(bus) + runner.add(cand) + runner.start() + + _publish(bus, "EMBRYOS_UPDATE", {"embryos": [ + {"id": "embryo_1", "position_coarse": {"x": 1.0, "y": 2.0}, + "position_fine": {}, "has_fine_position": False}, + {"id": "embryo_2", "position_coarse": {"x": 3.0, "y": 4.0}, + "position_fine": {"x": 3.1, "y": 4.1}, "has_fine_position": True}, + ]}) + + runner.stop() + dlog.close() + + assert _decisions_for(dlog) == [] # silent + assert set(cand.world.embryos.keys()) == {"embryo_1", "embryo_2"} + assert cand.world.embryos["embryo_2"]["has_fine"] + + +def test_reactive_proposes_recalibrate_when_fine_invalidated(tmp_path: Path): + bus = EventBus() + dlog = DecisionLog(tmp_path / "d.jsonl") + dlog.open() + runner = ShadowRunner(bus) + runner.add(ReactiveCandidate("reactive", dlog)) + runner.start() + + _publish(bus, "OPERATOR_EDITED_EMBRYO", { + "embryo_id": "embryo_2", + "old_position_coarse": {"x": 3, "y": 4}, + "new_position_coarse": {"x": 30, "y": 40}, + "fine_position_invalidated": True, + }) + runner.stop() + dlog.close() + decs = _decisions_for(dlog) + assert len(decs) == 1 + assert decs[0].tool_calls == [{ + "name": "recalibrate_embryo", + "input": {"embryo_id": "embryo_2"}, + "id": None, + }] + + +def test_reactive_skips_recalibrate_when_no_fine_existed(tmp_path: Path): + bus = EventBus() + dlog = DecisionLog(tmp_path / "d.jsonl") + dlog.open() + runner = ShadowRunner(bus) + runner.add(ReactiveCandidate("reactive", dlog)) + runner.start() + + _publish(bus, "OPERATOR_EDITED_EMBRYO", { + "embryo_id": "embryo_1", + "old_position_coarse": {"x": 1, "y": 2}, + "new_position_coarse": {"x": 10, "y": 20}, + "fine_position_invalidated": False, + }) + runner.stop() + dlog.close() + decs = _decisions_for(dlog) + assert len(decs) == 1 + assert decs[0].tool_calls == [] # nothing to refresh + assert "no action" in decs[0].response_text.lower() + + +def test_reactive_proposes_calibrate_all_on_marked(tmp_path: Path): + bus = EventBus() + dlog = DecisionLog(tmp_path / "d.jsonl") + dlog.open() + runner = ShadowRunner(bus) + runner.add(ReactiveCandidate("reactive", dlog)) + runner.start() + + _publish(bus, "OPERATOR_MARKED_EMBRYOS", { + "embryo_ids": ["embryo_001", "embryo_002", "embryo_003"], + "count": 3, + }) + runner.stop() + dlog.close() + decs = _decisions_for(dlog) + assert len(decs) == 1 + assert decs[0].tool_calls == [{ + "name": "calibrate_all_embryos", + "input": {"embryo_ids": ["embryo_001", "embryo_002", "embryo_003"]}, + "id": None, + }] + + +def test_reactive_proposes_forget_on_removal(tmp_path: Path): + bus = EventBus() + dlog = DecisionLog(tmp_path / "d.jsonl") + dlog.open() + runner = ShadowRunner(bus) + runner.add(ReactiveCandidate("reactive", dlog)) + runner.start() + + _publish(bus, "OPERATOR_REMOVED_EMBRYO", { + "embryo_id": "embryo_5", + "last_position": {"coarse": {"x": 1, "y": 2}, "fine": None}, + }) + runner.stop() + dlog.close() + decs = _decisions_for(dlog) + assert len(decs) == 1 + assert decs[0].tool_calls == [{ + "name": "forget_embryo", + "input": {"embryo_id": "embryo_5"}, + "id": None, + }] + + +def test_reactive_escalates_first_error_then_suppresses_repeat(tmp_path: Path): + bus = EventBus() + dlog = DecisionLog(tmp_path / "d.jsonl") + dlog.open() + runner = ShadowRunner(bus) + runner.add(ReactiveCandidate("reactive", dlog)) + runner.start() + + _publish(bus, "ERROR_OCCURRED", {"msg": "camera lost lock"}) + _publish(bus, "ERROR_OCCURRED", {"msg": "camera lost lock"}) # within 30s + _publish(bus, "ERROR_OCCURRED", {"msg": "different error"}) + runner.stop() + dlog.close() + decs = _decisions_for(dlog) + assert len(decs) == 3 + # 1st: escalate + assert decs[0].tool_calls[0]["name"] == "escalate_to_operator" + # 2nd: suppressed + assert decs[1].tool_calls == [] + assert "suppressed" in decs[1].response_text.lower() + # 3rd: different message -> escalate + assert decs[2].tool_calls[0]["name"] == "escalate_to_operator" + + +def test_reactive_full_event_stream_through_replay(tmp_path: Path): + """Capture a realistic operator-driven session and replay through the + candidate. End-to-end smoke that the recorded jsonl is enough to + drive a candidate's decision log without any other inputs.""" + src = EventBus() + cap = EventCapture(tmp_path / "events.jsonl") + cap.start(src) + + src.publish(EventType.EMBRYOS_UPDATE, {"embryos": [ + {"id": "embryo_1", "position_coarse": {"x": 1.0, "y": 2.0}, + "position_fine": {"x": 1.05, "y": 2.05}, "has_fine_position": True}, + ]}, source="agent") + src.publish(EventType.OPERATOR_EDITED_EMBRYO, { + "embryo_id": "embryo_1", + "old_position_coarse": {"x": 1.0, "y": 2.0}, + "new_position_coarse": {"x": 5.0, "y": 6.0}, + "fine_position_invalidated": True, + }, source="web:map-edit") + src.publish(EventType.OPERATOR_MARKED_EMBRYOS, { + "embryo_ids": ["embryo_2", "embryo_3"], "count": 2, + }, source="detect_embryos:web-editor") + src.publish(EventType.ERROR_OCCURRED, {"msg": "lost focus"}, + source="device-layer") + cap.stop() + + dst = EventBus() + dlog = DecisionLog(tmp_path / "decisions.jsonl") + dlog.open() + runner = ShadowRunner(dst) + runner.add(ReactiveCandidate("replay-cand", dlog)) + runner.start() + + n = EventReplay(tmp_path / "events.jsonl").replay(dst) + runner.stop() + dlog.close() + + assert n == 4 # all four events replayed + decs = _decisions_for(dlog) + # EMBRYOS_UPDATE is silent ingest; the other 3 each produce a decision. + triggers = [(d.trigger.value, d.trigger_detail) for d in decs] + assert triggers == [ + ("event", "OPERATOR_EDITED_EMBRYO"), + ("event", "OPERATOR_MARKED_EMBRYOS"), + ("event", "ERROR_OCCURRED"), + ] + tool_names = [d.tool_calls[0]["name"] if d.tool_calls else None for d in decs] + assert tool_names == [ + "recalibrate_embryo", + "calibrate_all_embryos", + "escalate_to_operator", + ] From ed75933e157a9c37608938a5f48163c2c24ec625 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 15 May 2026 19:52:56 -0400 Subject: [PATCH 011/314] docs: closed-loop paradigm write-up Distillation of the design conversation that produced the paradigm/closed-loop branch: - The four orchestrator roles and which one creates the friction - Web/chat reconciliation patterns A/B/C/D - Why 'turn' is the wrong unit and 'decision moment' is right - Wake-router model (events + schedule + user input) - Tiered world model (snapshot / digest / pull tools / lazy summariser) - Five testing primitives ranked by payoff - Coarse-vs-fine schema as 'measurement provenance' - Map as collaborative world model - Revolutionary trajectories: plans-as-goals, compounding learning, collaborative world model, reverse-mode microscopy, continuous shadow - What is built (commit table), what is not yet, and the open questions for the next iteration Future-self / new-collaborator reference, not a transcript. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 938baf8a74fbd8a1fe3a08df113104412283c596) --- docs/CLOSED_LOOP_PARADIGM.md | 536 +++++++++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 docs/CLOSED_LOOP_PARADIGM.md diff --git a/docs/CLOSED_LOOP_PARADIGM.md b/docs/CLOSED_LOOP_PARADIGM.md new file mode 100644 index 00000000..8f1cf80d --- /dev/null +++ b/docs/CLOSED_LOOP_PARADIGM.md @@ -0,0 +1,536 @@ +# Closed-Loop Paradigm: Notes on the Discussion + +This document captures the design conversation that produced everything on the +`paradigm/closed-loop` branch: the schema split, the Map-as-embryo-home work, +the operator-action vocabulary, the eval substrate (capture / replay / +decisions / shadow), and the trajectory the system is on. It is a +distillation, not a transcript — a future-self / new-collaborator reference +for *why* this code looks the way it does and *where it is going*. + +--- + +## 1. The Original Friction + +The conversation started from a small, concrete observation by the operator: + +> "It feels awkward that the operator has to go between the chat in the TUI +> and the viz server… or even to chat about detecting embryos." + +That awkwardness is a symptom, not a defect. It surfaces a deeper design +question: **what is the orchestrator (the agent) actually for?** Today the +orchestrator does at least four jobs at once, and one of them — *tool router* +— is the one creating the friction. + +### The four orchestrator roles + +| Role | Description | Replaceable by a button? | +| --- | --- | --- | +| 1. Tool router | "Detect embryos" → `detect_embryos()` call | **Yes** — this is the friction surface | +| 2. Workflow runner | Timelapses, multi-embryo plans, perception loops | No | +| 3. Domain reasoner | Knows microscopy, embryos, safety constraints | No | +| 4. Session memory | Coherent narrative of what happened and why | No | + +Routing a single click through chat for a routine action is the system +fighting against its own users. Routing a multi-step scientific decision +through Claude is using the right tool for the right job. The paradigm here +is: **shrink role 1 to its essentials, keep roles 2–4 first-class, and let +the UI carry the rest.** + +--- + +## 2. Web ↔ Chat Reconciliation Patterns + +Four ways to relate the web UI and the chat orchestrator. Each has a +distinct world model property: + +### A. Chat-only intent (the old default) + +Every action originates in chat. The web is observation + delegated subtasks +(e.g. the marking canvas is a delegation the orchestrator triggers). + +* Cleanest record. +* Worst friction. +* Orchestrator's world model is "complete" because every change passes + through it. + +### B. Two parallel command surfaces + +Operator clicks in web, web acts directly; orchestrator finds out by polling +state or doesn't find out at all. + +* Lowest friction. +* Orchestrator's world model **drifts from reality** — fatal for role 4 + (session memory) and dangerous for role 3 (safety reasoning). + +### C. Web acts, orchestrator subscribes *(the chosen direction)* + +Operator clicks → web performs the action **and** publishes an event +(`OPERATOR_*`) → orchestrator's session memory ingests it. + +* Chat log shows only human conversation. +* Orchestrator's working context shows chat + events as a single timeline. +* Phase 7 (operator events vocabulary + reactive candidate) is the first + installment of this pattern. + +### D. Cross-pattern hybrid + +Different action classes use different patterns. Heavy / novel / composite +actions use chat (A); routine / clickable / contextual actions use web (C). +This is what the system actually drifts toward; pattern C is the substrate +that makes it possible. + +The orchestrator's job shifts from being **a funnel for action** to being **a +brain that knows what's happening on every surface**. + +--- + +## 3. The "Turn" is Wrong; the "Decision Moment" is Right + +Chat-AI literature reasons in *turns* (user message → assistant response). +That model imports an assumption that does not hold here: the human is at +the keyboard continuously. In a microscopy experiment running 12+ hours, the +human checks in once, twice, maybe ten times. The agent is autonomous in +between. + +The right unit is a **decision moment**, triggered by: + +1. **User message** — rare, interrupting (classic chat turn). +2. **Critical event** — error, safety violation, lost focus, perception + anomaly. Wake immediately; decide to act / abort / escalate. +3. **Phase boundary** — between timepoints, between embryos. Built-in + checkpoint: review state, decide whether to continue. +4. **Periodic checkpoint** — every N minutes if nothing else happened. + Catches slow drifts. + +Between moments the agent is asleep. Plans execute autonomously. Events +accumulate on the bus and in the world model. When the next decision +moment fires, the agent reads: + +* The trigger (why am I waking up?) +* The world snapshot (NOW state) +* The events digest (what happened since last wake) +* The conversation history (which might be hours old and less relevant + than usual) + +This is closer to a **supervisory controller** than a chat partner. The +conversation history matters less than usual; what matters more is the +**flight log** (events) plus the **current state snapshot**. + +### Trigger model — concrete + +A small router (in code, not Claude) sits between the bus and the brain: + +``` + user input ─┐ + event bus ──┼─► wake-router ──► (compose context) ──► claude.messages.create + schedule ──┘ +``` + +The router's responsibilities: +* Subscribe to a whitelist of "wake-worthy" event types. +* Hold a debounce / coalescing buffer (so a burst of events becomes one + wake). +* Keep a heartbeat schedule (every N minutes if no other trigger fired). +* On wake, package: trigger, world snapshot, events digest, recent + conversation tail. +* Surface the package to the brain. + +The brain stays the brain (Claude). The router is cheap, deterministic, +debuggable code. It's the **meta-orchestrator** the operator mentioned — +**not as another LLM**, but as a control surface. + +### Phase boundaries: hand-back vs subscribe + +Two designs for letting the brain look in mid-plan: + +* **Plan hands control back** at well-known points (between embryos, every + 5 timepoints). Cheaper, predictable, slightly less reactive. +* **Plan never pauses; brain subscribes to plan events** ("perception + complete for embryo 3"). More reactive, more plan-coupling. + +The first one composes better with the supervisory-controller framing and +is the recommended starting point. + +### Idle ticks + +If 30 min pass with no event and no user, should the agent wake to verify +everything's OK? Default to **yes — periodic ticks with a high action +threshold.** Most ticks should result in the agent doing nothing. The +purpose is catching slow drifts (focus, sample state, hardware +degradation) that don't trigger their own events. + +--- + +## 4. World Model — Tiered, Not Monolithic + +A common mistake is "summarise everything every turn." Better is a tiered +model where different freshness/density tiers carry different cadence +costs. + +### Tier 1 — World snapshot + +Structured, ~30 lines, computed from in-memory state (not events), every +wake. + +Includes: live stage XY/Z, current session id, embryo list with +calibration state, current plan, acquisition status, recent operator +actions (one-line summary). + +Cheap to build, fresh every time. Already mostly present in the +codebase — `agent.experiment.get_summary()` plus the cached +`DEVICE_STATE_UPDATE` payload is 80% of this. + +### Tier 2 — Recent-events digest + +Hand-written formatter over the events bus, filtered to wake-worthy types, +inserted as a system note at each wake. + +Shape: `"Since last response: operator added embryo 4 via Map at 14:32; calibration completed for embryo 2; one perception trace pending."` + +Hand-written because LLM summarisation here adds latency, cost, and +non-determinism for low value. Events are already structured. + +### Tier 3 — Pull tools + +For when reasoning needs depth: `get_recent_perceptions(embryo_id, n=5)`, +`get_session_timeline()`, `get_learnings(campaign_id)`, etc. The agent +calls these when it wants the detail. + +### Tier 4 — Optional LLM summariser + +Reserved for genuinely natural-language streams that resist rule-based +compression: accumulated CV reasoning chains, narrative observations, +cross-session learnings. Use a smaller, faster Claude model (Haiku is the +natural fit). Run lazily, when a tier-3 tool asks for "summarise the last +30 min for embryo 3." + +### Why this shape + +Decision moments are **rare** in autonomous mode. Token budget per wake +can be generous (it's mostly idle compute). What matters more than budget +is **cadence of waking** — saving 200 tokens per turn doesn't help if +you're waking up at the wrong moments. + +--- + +## 5. Testing — Where Most Projects Fail + +You cannot iterate on agent architecture without a way to compare +architectures. Microscopy makes this hard: + +* Physical, non-deterministic, non-replayable in the trivial sense. +* "Correct" is fuzzy — biological judgements rarely have ground truth. +* Slow feedback (a timelapse takes hours). +* Can't always reset to a clean state (samples are consumed). + +Five testing primitives, ranked by payoff per unit work (this ordering +informed Phase 6's build order): + +### 5.1 Event replay *(built — Phase 6a/6b)* + +Capture the full event stream during a real run. Offline, replay it +through any candidate architecture. Diff its decisions against +production's. **Foundation** — without it, every change to the +orchestrator is a flight test. + +### 5.2 Shadow mode *(built — Phase 6d)* + +During a real experiment, candidate architectures run alongside +production. They see the same events but their decisions are *logged, +not enacted*. Unique value over pure replay: shadow agents experience +real temporal cadence, so timing-sensitive things (drift, races) are +caught. + +### 5.3 Synthetic event sequences + +Hand-crafted streams: cascading errors, ambiguous perception, +contradictory operator actions, focus drift, network drop mid-acquisition. +Stress / chaos testing. The orchestrator is correct if it doesn't do +something catastrophic — much easier to score than biological +correctness. + +Trivially built on top of 5.1 — write a `jsonl` by hand, replay it. + +### 5.4 Decision-level micro-benchmarks + +Specific judgements — "given this perception result and these recent +observations, should the agent re-focus?" — captured as +(input → expected decision) pairs labelled by a biologist. Regression +suite. Cheap with biologist time, expensive to bootstrap, very valuable +once you have a few hundred. + +### 5.5 Multi-agent A/B in production + +Two embryos in the same dish, one supervised by architecture A and one +by B (both honouring the firmware fence). Compare biological outcomes. +Slow (one timelapse per data point), but the **only thing that measures +biological correctness end-to-end.** + +--- + +## 6. Embryo Schema: Coarse vs Fine + +Foundational and quietly important. Each embryo carries: + +* `position_coarse` — set by bottom-camera detection or manual Map + placement. Always present. +* `position_fine` — set later by SPIM-objective alignment (workflow not + yet built). Initially `{}`. +* `stage_position` — a *derived property*: `fine if fine else coarse`. + Downstream motion / perception keeps reading this and stays agnostic + about which calibration stage we're in. + +This is the seed for a broader idea: **measurements have provenance and +calibration state**. The same embryo at the same nominal XY can have +different "true" positions depending on which sensor sighted it. Encode +that explicitly so any downstream decision can ask *"how confident is +this position?"* without needing to know the whole calibration history. + +When the operator drags an embryo on the Map, the PUT clears `fine` — +overriding the sighting invalidates any SPIM-derived fine alignment +derived from the old coarse. `OPERATOR_EDITED_EMBRYO` carries +`fine_position_invalidated` so the candidate / future controller can +schedule a re-alignment without inferring it. + +--- + +## 7. The Map as Collaborative World Model + +The Devices > Map page is more than visualisation. It is the **first +collaborative surface** between operator and orchestrator: both can read +the embryo list; both can mutate it. The orchestrator subscribes; the +operator clicks. + +Visual semantics matter: + +* Coarse-only embryo → outlined ring + number. *Provisional.* +* SPIM-fine-calibrated → filled disc + number. *Committed.* + +Calibration state is then visible at a glance across the slide — the +operator can scan and see "embryo 3 still needs alignment" without +opening anything. + +The pick-up / drop interaction (Phase 5) deliberately rejects +click-to-add: the Map is a schematic, not a satellite view. Adding a +new sighting without a visual reference is guessing. New embryos go +through the bottom-camera marking canvas. The Map is for **editing what +already exists**. + +### Future arc + +* **Annotations beyond position**: operator marks "this is the control", + "this one is dead, skip", "I think this is in 2-cell stage". These + become first-class scientific observations through additional + `OPERATOR_*` events. +* **Satellite tile**: render the live bottom-camera frame as an overlay + on the Map at the current stage XY, scaled by um_per_pixel. Inside + that tile, click-to-add becomes meaningful (you can see what you're + picking). Outside, the Map stays schematic. + +--- + +## 8. Revolutionary Trajectories + +Some of these are reasonable extensions; some are genuinely new. + +### 8.1 Plans-as-goals, not scripts + +Operator specifies "characterise gut development for these four +embryos." Orchestrator translates this into a continuously adapted +imaging plan that changes based on what perception sees mid-run. The +plan isn't a fixed script handed to Bluesky — it's a negotiation the +orchestrator keeps in flight, with the world model as the substrate +for adaptation. + +Requires: tier-1 + tier-2 world model, decent perception loop, a way +to express goals as predicates over the world model. + +### 8.2 Compounding cross-session learning + +`agent/learnings/` already exists. Today it's barely used. With replay ++ shadow, an architecture that proposes priors ("embryos at 3-fold +typically need slower piezo") becomes **A/B testable across sessions**. +Improvement gets *measurable*, which is the unlock — most "smart +microscopy" today is shallow because it has no measurement loop. + +The right framing: each session is a **trial**, the orchestrator is the +**experimenter**, the world model is what carries learning between +trials. + +### 8.3 Collaborative world model + +The Map (operator edits embryos) is the first instance. Extend +everywhere: + +* Operator annotates morphology on the Map → orchestrator updates + hypothesis space. +* Operator marks a focus failure → orchestrator marks the calibration + region as untrustworthy. +* Operator confirms a perception → orchestrator increases confidence in + the perception predicate for similar inputs. + +The point is making the operator's tacit knowledge **first-class data** +that the system can reason about, not just record. + +### 8.4 Reverse-mode microscopy + +"I want to know X — plan the imaging that answers X." The orchestrator +translates scientific goals into imaging plans. This is the +plans-as-goals idea taken to its conclusion: the operator describes +intent in scientific terms, the orchestrator owns the imaging strategy. + +Tractable only once 8.1 and the goal language are built. + +### 8.5 Continuous shadow / always-on critic + +Run the production orchestrator + a shadow candidate continuously, and +log all decision divergences. Over weeks, the divergence log becomes a +**dataset of disagreements**. Each disagreement is either: + +* Production was right, candidate was wrong → candidate needs a fix. +* Candidate was right, production was wrong → consider promotion or + investigate why production picked differently. +* Both were defensible → annotate the case. + +Free with the eval substrate (Phase 6); the only addition is a +divergence collator. + +--- + +## 9. Concretely Built Today (`paradigm/closed-loop` branch) + +| # | Commit | What | +| --- | --- | --- | +| 1 | `3e410581` | Schema split: `position_coarse` / `position_fine` / derived `stage_position`. | +| 2 | `617e54c9` | `ExperimentState.notify_embryos_changed()` observer → `EMBRYOS_UPDATE` broadcast. | +| 3 | `144d9fc9` | Map render layer — lavender rings (coarse) / discs (fine) / numbers. | +| 4 | `4fbb9edf` | `detect_embryos` flows through web Marking canvas; `auth.py` + `require_control`. | +| 5 | `8f6553e1` | Map pick-up / drop / Delete to edit embryos in place (control-gated PUT/DELETE). | +| 6 | `808fe813` | Side-fix: re-enable XY joystick at device-layer boot. | +| 7 | `f7a13d69` | Side-fix: image-anchored crosshair + scroll-to-zoom in camera panel. | +| 8 | `d69cc219` | `gently/eval/`: event capture / replay / shadow / decision log scaffolding. | +| 9 | `75d7c9db` | Production decision capture wired through `ConversationManager.call_claude`. | +| 10 | `0a97563e` | `OPERATOR_*` event vocabulary + `ReactiveCandidate` (first real shadow). | + +### Per-session disk shape now + +`D:\Gently3\sessions\{id}\` + +* `events.jsonl` — captured event bus, telemetry-filtered. +* `decisions.jsonl` — every Claude turn (success + error). +* `interaction_log.jsonl` — pre-existing chat-shaped interactions. +* `timeline.jsonl` — pre-existing session timeline. +* Plus everything from the legacy FileStore layout. + +### Eval CLI + +`python scripts/replay_session.py {session_id_prefix} [--histogram] [--candidate {name}] [--real-time] [--time-scale N]` + +--- + +## 10. What is *Not* Done Yet + +These are the natural follow-ups; sketched as future-self breadcrumbs. + +### Near-term (days) + +* **Tier-1 world snapshot** as a system-prompt section the brain sees + on every wake. Build the snapshot from `agent.experiment` plus the + last cached `DEVICE_STATE_UPDATE`. ~30 lines of formatted prose, every + wake. +* **Tier-2 events digest** — hand-written formatter that reads the + bus's recent meaningful events (or the captured jsonl tail) and + produces a one-paragraph "since last response" note. +* **Snapshot ingest into the brain's prompt** — `_update_system_prompt` + already takes a `context_summary`; route tier-1 + tier-2 through it. + +### Medium-term (weeks) + +* **Wake-router** — the code-level scheduler from §3. Currently the + brain only wakes on user message. Add: event-driven wake (subscribe + to wake-worthy events), periodic-tick wake (heartbeat), debounce / + coalesce buffer. +* **More operator events** — `OPERATOR_ANNOTATED_EMBRYO` ("this is the + control", "skip, looks dead"), `OPERATOR_STARTED_TIMELAPSE`, + `OPERATOR_INTERRUPTED_PLAN`, `OPERATOR_TOGGLED_CAMERA`. Whatever the + Map / web UI lets the operator do should publish a typed event. +* **SPIM-fine alignment workflow** — populate `position_fine`. Tool + + per-embryo state transition. Triggers `EMBRYOS_UPDATE` and a new + `FINE_ALIGNMENT_COMPLETED` event the orchestrator can react to. +* **Continuous-shadow harness** — extend `ShadowRunner` to run a + candidate alongside production in the live agent process (not just + during replay). Collect divergences into a per-session + `divergences.jsonl`. + +### Longer arc (months) + +* **A goal expression language** — predicates over the world model that + let the operator say "image until 4-fold" or "follow the cell + divisions in embryo 3 at high resolution." This is the substrate for + §8.1 (plans-as-goals). +* **LLM-driven candidates** — once the rule-based `ReactiveCandidate` + proves the substrate, add Claude-driven candidates (Haiku for cheap, + Opus for thinking). Use the snapshot+digest as their input. +* **Cross-session learning loop** — wire the `learnings/` store into + the world model as priors. Add a learning-write surface (a tool the + orchestrator can call when it notices a pattern). Use shadow A/B to + validate that learnings improve decisions. +* **Goal-driven evaluation** — once goals exist, "did the experiment + achieve its goal" becomes a measurable end-to-end success rate. The + ultimate metric is this, not turn-level decision diffs. + +--- + +## 11. Principles That Surface Throughout + +A few recurring design priors worth naming: + +1. **Distill, don't dump.** Structured summaries beat raw logs in + prompts. Hand-written formatters beat LLM summarisers for + structured data. LLMs for prose, code for structure. +2. **Pull beats push when uncertain.** Default to tools the agent + queries on demand, not data shoved into every prompt. Push only + what's universally relevant (the world snapshot). +3. **Same shape for production and shadow.** If production writes a + Decision with these fields, shadow candidates write Decisions with + the same fields. Diff is then trivial. +4. **Events carry intent; state carries position.** `EMBRYOS_UPDATE` + is state (the embryo list now). `OPERATOR_EDITED_EMBRYO` is intent + (a human just did this). Both exist; they answer different + questions. +5. **The brain doesn't move hardware.** All hardware action goes + through tools that go through the device layer that goes through + ophyd that goes through MMCore. Shadow candidates are constructively + prevented from acting. Layers are not negotiable. +6. **No SaveCardSettings.** Firmware persistent state silently inherits + between sessions; if it ever gets out of sync with code it's a + debugging nightmare. Apply firmware config every boot, code wins. +7. **Localhost is the diSPIM box. Remote is view-only by default.** + Auth surface stays tiny and explicit. Token upgrade is the seam, + not user accounts. + +--- + +## 12. Open Questions (Worth Revisiting Later) + +* **Continuous vs episodic shadows.** Continuous always-on shadow + captures divergence over time but multiplies cost (multiple LLM + candidates running). Episodic shadow at decision moments is cheaper + but misses timing-sensitive cases. Hybrid? +* **Is the conversation history the right substrate at all?** With + decision moments hours apart, prior chat may be more distracting + than useful. Maybe the brain shouldn't see chat history beyond N + hours; the world model + events digest are the durable memory and + chat is just for the active dialogue. +* **How much should the operator know about the orchestrator's plan?** + Today the operator drives by asking. With autonomous mode, the + orchestrator runs experiments largely on its own. Should there be a + permanent "what is the orchestrator thinking right now" surface + visible on the Map? An always-on intent display? +* **Failure semantics.** If a candidate would have made a different + decision than production, and production's decision led to a bad + outcome, the candidate "wins." How do we score? Define "bad outcome" + rigorously enough that it can be measured? + +These are not blockers. They are notes for the next iteration of this +document, after a few weeks of running on the substrate built here. From 6dd37be4b55fb2aa84f5f49497870ccc6e4a5032 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Sat, 16 May 2026 17:16:04 -0400 Subject: [PATCH 012/314] events: bridge Python logs onto the EventBus / Events page The console output from launch_gently.py is the most informative surface in the system -- calibration progress, plan executor state changes, perception decisions, MMCore callbacks. Until now it lived only in the terminal and on-disk gently_*.log. This bridge fans it onto the EventBus too, so the Events page in the viz server mirrors what the operator would otherwise have to alt-tab to a terminal to see. Backend ------- gently/core/log_bridge.py LogToBusHandler(logging.Handler) -- emit() publishes EventType.LOG_RECORD with {level, level_name, logger, message, module, func, line, ts_ms, exc_text?}. Per-thread re-entry guard prevents a subscriber's own log call from spawning a cascade. Loggers in gently.core.event_bus and gently.core.log_bridge are never bridged. configure_log_bridge() reads three env vars: GENTLY_LOG_BUS off/on (default on) GENTLY_LOG_BUS_LEVEL threshold (default INFO) GENTLY_LOG_BUS_INCLUDE_THIRDPARTY also bridge aiohttp/uvicorn/ bluesky/anthropic/httpx/httpcore (default off -- keeps the page readable; durable copy still in gently_*.log) Idempotent: re-attaching is a no-op. gently/core/event_bus.py EventType.LOG_RECORD added, plus inclusion in _NO_HISTORY_TYPES (log records can fire hundreds-per-minute and would crowd out the bounded history deque used for "meaningful" events). launch_gently.py configure_log_bridge() runs right after configure_logging() in main(). Single line, env-controlled, no API changes. Frontend -------- gently/ui/web/static/js/events.js addEventToTable() branches on LOG_RECORD vs everything else. Log rows render with a level-coloured badge (DEBUG / INFO / WARN / ERROR), the logger name greyed before the message, and click-to-expand reveals the full payload including stack traces. gently/ui/web/static/css/main.css Four new .event-type-badge.log-{debug,info,warn,error} classes matching the existing badge palette. Monospace font for log message, red tint for exception lines. websocket.js already forwards everything except DEVICE_STATE_UPDATE / BOTTOM_CAMERA_FRAME to the events table; LOG_RECORD inherits that behaviour automatically. Tests ----- tests/test_log_bridge.py: 10 tests covering pass-through, level threshold, exception capture, re-entry guard, bridge-internal logger skip, GENTLY_LOG_BUS=off path, default attach, idempotency, third-party exclusion default, opt-in third-party. 10/10 green; full paradigm suite 42/42. Phase 9. On paradigm/closed-loop only. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 6318691b24994d006e7cf83893b5ad1f05fd68a8) --- gently/core/event_bus.py | 8 + gently/core/log_bridge.py | 175 ++++++++++++++++++++++ gently/ui/web/static/css/main.css | 14 ++ gently/ui/web/static/js/events.js | 148 ++++++++++++++----- launch_gently.py | 5 + tests/test_log_bridge.py | 235 ++++++++++++++++++++++++++++++ 6 files changed, 546 insertions(+), 39 deletions(-) create mode 100644 gently/core/log_bridge.py create mode 100644 tests/test_log_bridge.py diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index 3d59d9c6..d5495976 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -84,6 +84,11 @@ class EventType(Enum): BOTTOM_CAMERA_FRAME = auto() # Live JPEG frame from the bottom camera stream EMBRYOS_UPDATE = auto() # Full embryo list snapshot from agent.experiment + # Python logging.LogRecord republished onto the bus so the Events page + # surfaces what would otherwise only land in the terminal. See + # gently/core/log_bridge.py — opt-in handler. + LOG_RECORD = auto() + # Operator-action events. Distinct from EMBRYOS_UPDATE because they # carry intent ("a human did this") rather than just state delta. # Candidate orchestrators can subscribe and reason about what the @@ -166,6 +171,9 @@ class EventType(Enum): _NO_HISTORY_TYPES = frozenset({ EventType.DEVICE_STATE_UPDATE, EventType.BOTTOM_CAMERA_FRAME, # ~2 Hz JPEG frames — would crowd history out + 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/log_bridge.py b/gently/core/log_bridge.py new file mode 100644 index 00000000..d8d7e69f --- /dev/null +++ b/gently/core/log_bridge.py @@ -0,0 +1,175 @@ +"""Bridge Python logging into the EventBus so the Events page mirrors the +console. + +A small ``LogToBusHandler`` subclasses ``logging.Handler``. Every record it +sees gets published as ``EventType.LOG_RECORD`` with a compact payload the +frontend can render. The handler attaches itself to whichever loggers +``configure_log_bridge`` is told to cover — by default only ``gently`` and +``gently_perception``, which keeps third-party noise (aiohttp access logs, +bluesky state transitions, anthropic SDK chatter) off the page unless the +operator opts in. + +Env-configurable: + GENTLY_LOG_BUS — "on" / "off" (default: on) + GENTLY_LOG_BUS_LEVEL — DEBUG / INFO (default) / WARNING / ERROR + GENTLY_LOG_BUS_INCLUDE_THIRDPARTY — "1"/"true" to include common third- + party loggers (uvicorn, aiohttp, + bluesky, anthropic, httpx, httpcore) + +Re-entrancy is the only real subtlety: if a log call happens inside the +EventBus.publish path (e.g. from the dispatch loop's logger), republishing +it as another LOG_RECORD would loop forever. Guarded with a thread-local +re-entry flag. +""" + +from __future__ import annotations + +import logging +import os +import threading +from typing import Iterable, Optional, Sequence + +from .event_bus import EventBus, EventType, get_event_bus + +logger = logging.getLogger(__name__) + + +# Loggers we never want on the Events page — they emit at the wrong layer +# (their own log lines describe bus dispatch / events page WebSocket frames) +# so republishing them would create feedback or infinite churn. +_NEVER_BRIDGE = frozenset({ + "gently.core.event_bus", + "gently.core.log_bridge", +}) + +# Loggers that count as "third-party noise" — silenced by default, can be +# opted in with GENTLY_LOG_BUS_INCLUDE_THIRDPARTY=1. +_THIRDPARTY_DEFAULTS: Sequence[str] = ( + "uvicorn", "uvicorn.error", "uvicorn.access", + "aiohttp", "aiohttp.access", + "anthropic", "httpx", "httpcore", + "bluesky", "bluesky.RE.state", +) + + +class LogToBusHandler(logging.Handler): + """Publishes each record onto the EventBus as a LOG_RECORD event. + + Per-thread re-entry guard prevents infinite loops when something in + the publish path itself logs. + """ + + def __init__(self, bus: EventBus, *, level: int = logging.INFO): + super().__init__(level=level) + self._bus = bus + self._reentry = threading.local() + + def emit(self, record: logging.LogRecord) -> None: + # Re-entry guard: if a downstream subscriber's handler logs, we + # must not republish that log line. + if getattr(self._reentry, "active", False): + return + # Never bridge our own machinery — those records describe the + # bridge itself, would loop. + if record.name in _NEVER_BRIDGE: + return + self._reentry.active = True + try: + try: + # format() runs all configured formatters (incl. exc_info + # serialisation). We send the formatted message + the + # structured bits separately so the frontend can choose + # how to render. + message = record.getMessage() + except Exception: + message = "" + + payload = { + "level": int(record.levelno), + "level_name": record.levelname, + "logger": record.name, + "message": message, + "module": record.module, + "func": record.funcName, + "line": record.lineno, + # Wall-clock ms since epoch — frontend uses this for its + # own ordering / display, separate from the EventBus's + # internal timestamp. + "ts_ms": int(record.created * 1000), + } + if record.exc_info: + try: + payload["exc_text"] = logging.Formatter().formatException(record.exc_info) + except Exception: + pass + + self._bus.publish( + event_type=EventType.LOG_RECORD, + data=payload, + source=f"log:{record.name}", + ) + except Exception: + # If we can't publish (shutdown, etc.), drop the record + # silently — the live console + on-disk log still have it. + pass + finally: + self._reentry.active = False + + +def configure_log_bridge( + bus: Optional[EventBus] = None, + *, + loggers: Optional[Iterable[str]] = None, + level: Optional[str] = None, + include_thirdparty: Optional[bool] = None, +) -> Optional[LogToBusHandler]: + """Attach a LogToBusHandler to the requested loggers. + + Returns the installed handler (or None if the bridge is disabled). + Idempotent: safe to call multiple times — only the first call attaches. + + Parameters honour env-var defaults so the launch script doesn't need + to know the knobs: + GENTLY_LOG_BUS — "off" disables entirely + GENTLY_LOG_BUS_LEVEL — threshold (default INFO) + GENTLY_LOG_BUS_INCLUDE_THIRDPARTY — adds aiohttp/uvicorn/bluesky/etc. + """ + if os.environ.get("GENTLY_LOG_BUS", "on").lower() in ("off", "0", "false", "no"): + return None + + if bus is None: + bus = get_event_bus() + + if level is None: + level = os.environ.get("GENTLY_LOG_BUS_LEVEL", "INFO") + level_int = getattr(logging, level.upper(), logging.INFO) + + if include_thirdparty is None: + env_val = os.environ.get("GENTLY_LOG_BUS_INCLUDE_THIRDPARTY", "") + include_thirdparty = env_val.lower() in ("1", "true", "yes", "on") + + if loggers is None: + loggers = ["gently", "gently_perception"] + if include_thirdparty: + loggers = list(loggers) + list(_THIRDPARTY_DEFAULTS) + + handler = LogToBusHandler(bus, level=level_int) + + attached = [] + for name in loggers: + target = logging.getLogger(name) + # Skip if already attached (idempotency for re-invocation). + if any(isinstance(h, LogToBusHandler) for h in target.handlers): + continue + target.addHandler(handler) + attached.append(name) + + if attached: + # Surface the configuration once at startup — using our own logger + # (which is in _NEVER_BRIDGE) so this announcement itself doesn't + # become a LOG_RECORD event. + logger.info( + "Log bridge active: level=%s, loggers=%s, include_thirdparty=%s", + logging.getLevelName(level_int), attached, include_thirdparty, + ) + return handler diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index ef4dac96..c0a6c539 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -2036,6 +2036,20 @@ a.tab-link.active { .event-type-badge.error { background: rgba(248, 81, 73, 0.2); color: #f85149; } .event-type-badge.default { background: var(--bg-hover); color: var(--text-muted); } +/* Log-record badges per level. The level is the badge text (DEBUG / INFO / + WARN / ERROR) for log rows; the LOG_RECORD type itself is collapsed into + the level so the column doesn't read the same string for every line. */ +.event-type-badge.log-debug { background: rgba(125, 134, 145, 0.18); color: #9ba3b0; } +.event-type-badge.log-info { background: rgba(88, 166, 255, 0.16); color: var(--accent); } +.event-type-badge.log-warn { background: rgba(210, 153, 34, 0.22); color: var(--accent-orange); } +.event-type-badge.log-error { background: rgba(248, 81, 73, 0.22); color: #f85149; } + +/* Log line message: monospace, faint logger prefix, expandable trace. */ +.log-row .event-data { font-family: 'JetBrains Mono', ui-monospace, monospace; } +.log-logger { color: var(--text-muted); opacity: 0.85; margin-right: 0.5rem; } +.log-message { color: var(--text); } +.log-exc { color: #f85149; opacity: 0.85; } + .event-source { color: var(--text-muted); font-size: 0.75rem; diff --git a/gently/ui/web/static/js/events.js b/gently/ui/web/static/js/events.js index 02365e40..3e37eb66 100644 --- a/gently/ui/web/static/js/events.js +++ b/gently/ui/web/static/js/events.js @@ -41,6 +41,40 @@ function getEventBadgeClass(eventType) { return 'default'; } +// Log-record helpers -------------------------------------------------- +// LOG_RECORD events come from the Python logging bridge. We collapse the +// generic "LOG_RECORD" type into the actual level (DEBUG / INFO / WARN / +// ERROR) so the table is readable -- otherwise every row in a busy +// session reads the same string in the Type column. +function isLogEvent(event) { + return event && event.event_type === 'LOG_RECORD'; +} + +function logLevelLabel(d) { + // levelname is fastest path; fall back to numeric mapping if missing. + const lvl = (d && (d.level_name || '')).toString().toUpperCase(); + if (lvl) { + if (lvl === 'WARNING') return 'WARN'; + if (lvl === 'CRITICAL') return 'CRIT'; + return lvl; + } + const n = d && Number(d.level); + if (!isFinite(n)) return 'LOG'; + if (n >= 50) return 'CRIT'; + if (n >= 40) return 'ERROR'; + if (n >= 30) return 'WARN'; + if (n >= 20) return 'INFO'; + return 'DEBUG'; +} + +function logBadgeClass(d) { + const label = logLevelLabel(d); + if (label === 'DEBUG') return 'log-debug'; + if (label === 'INFO') return 'log-info'; + if (label === 'WARN') return 'log-warn'; + return 'log-error'; // ERROR / CRIT collapse together +} + // Search helper functions function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -212,45 +246,81 @@ function addEventToTable(event, prepend = true) { if (hasImage) tr.classList.add('has-image'); tr.dataset.eventId = event.event_id || ''; - const badgeClass = getEventBadgeClass(event.event_type); - - // Image indicator icon - const imageIndicator = hasImage - ? ` - - - - - - ` - : ''; - - // Thumbnail preview - const thumbnailHtml = hasImage - ? `Event image` - : ''; - - tr.innerHTML = ` - ${formatEventTime(event.timestamp)} - ${imageIndicator}${event.event_type} - ${event.source || '-'} - ${thumbnailHtml}
${formatEventData(event.data)}
- `; - - // Click to expand data - tr.addEventListener('click', () => { - const dataDiv = tr.querySelector('.event-data'); - dataDiv.classList.toggle('expanded'); - if (dataDiv.classList.contains('expanded')) { - dataDiv.innerHTML = `
${JSON.stringify(event.data, null, 2)}
`; - } else { - dataDiv.innerHTML = formatEventData(event.data); - } - }); + if (isLogEvent(event)) { + // Log rows have a compact, distinctive shape: level badge in the + // Type column, logger name + message in the Data column. Click to + // toggle a pre with the full payload (incl. exception trace). + tr.classList.add('log-row'); + const d = event.data || {}; + const badgeCls = logBadgeClass(d); + const label = logLevelLabel(d); + const message = highlightSearchTerms(d.message || ''); + const loggerName = highlightSearchTerms(d.logger || '-'); + const excTag = d.exc_text ? ' ⏎ trace…' : ''; + tr.innerHTML = ` + ${formatEventTime(event.timestamp)} + ${label} + ${event.source || '-'} +
+ ${loggerName}${message}${excTag} +
+ `; + tr.addEventListener('click', () => { + const dataDiv = tr.querySelector('.event-data'); + dataDiv.classList.toggle('expanded'); + if (dataDiv.classList.contains('expanded')) { + const tracePart = d.exc_text + ? `\n\n${d.exc_text}` : ''; + dataDiv.innerHTML = + `
${d.logger || ''}  ${d.func || ''}:${d.line || ''}\n` +
+                    `${(d.message || '')}${tracePart}
`; + } else { + dataDiv.innerHTML = + `${loggerName}` + + `${message}${excTag}`; + } + }); + } else { + const badgeClass = getEventBadgeClass(event.event_type); + + // Image indicator icon + const imageIndicator = hasImage + ? ` + + + + + + ` + : ''; + + // Thumbnail preview + const thumbnailHtml = hasImage + ? `Event image` + : ''; + + tr.innerHTML = ` + ${formatEventTime(event.timestamp)} + ${imageIndicator}${event.event_type} + ${event.source || '-'} + ${thumbnailHtml}
${formatEventData(event.data)}
+ `; + + // Click to expand data + tr.addEventListener('click', () => { + const dataDiv = tr.querySelector('.event-data'); + dataDiv.classList.toggle('expanded'); + if (dataDiv.classList.contains('expanded')) { + dataDiv.innerHTML = `
${JSON.stringify(event.data, null, 2)}
`; + } else { + dataDiv.innerHTML = formatEventData(event.data); + } + }); + } if (prepend) { tbody.insertBefore(tr, tbody.firstChild); diff --git a/launch_gently.py b/launch_gently.py index 1727dd2a..58f94fb5 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -29,6 +29,7 @@ import yaml from gently.log_config import configure_logging +from gently.core.log_bridge import configure_log_bridge from gently.app.agent import MicroscopyAgent from gently.organisms import load_organism from gently.hardware import load_hardware, get_hardware @@ -124,6 +125,10 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: log_file = str(log_dir / f"gently_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log") # File always gets INFO+, console uses the requested level configure_logging(level=log_level, log_file=log_file) + # Mirror gently / gently_perception log lines onto the EventBus so the + # Events page in the viz server shows them too. Env vars control level + # and whether to include noisy third-party loggers (off by default). + configure_log_bridge() logger.info("Logging to %s (console level: %s)", log_file, log_level) # Load organism module from config diff --git a/tests/test_log_bridge.py b/tests/test_log_bridge.py new file mode 100644 index 00000000..2079d365 --- /dev/null +++ b/tests/test_log_bridge.py @@ -0,0 +1,235 @@ +"""Tests for the logging->EventBus bridge.""" + +from __future__ import annotations + +import logging +import os + +import pytest + +from gently.core.event_bus import EventBus, EventType +from gently.core.log_bridge import ( + LogToBusHandler, + configure_log_bridge, + _NEVER_BRIDGE, +) + + +@pytest.fixture +def bus_with_capture(): + bus = EventBus() + seen = [] + bus.subscribe(EventType.LOG_RECORD, lambda ev: seen.append(ev)) + return bus, seen + + +@pytest.fixture +def isolated_logger(request): + """Fresh logger per test; cleaned up after. + + Using uniquely-named loggers under the gently.* namespace ensures we + don't tangle with any handlers installed by other tests in the suite. + """ + name = f"gently.test_logbridge.{request.node.name}" + lgr = logging.getLogger(name) + lgr.setLevel(logging.DEBUG) + # Don't propagate to root; we don't want pytest's caplog to swallow it + # before our handler sees it. + original_propagate = lgr.propagate + lgr.propagate = False + yield lgr + # Teardown: strip handlers we added + for h in list(lgr.handlers): + lgr.removeHandler(h) + lgr.propagate = original_propagate + + +def test_handler_publishes_each_record(bus_with_capture, isolated_logger): + """Every log call below threshold becomes a LOG_RECORD event.""" + bus, seen = bus_with_capture + isolated_logger.addHandler(LogToBusHandler(bus, level=logging.DEBUG)) + + isolated_logger.debug("debug msg") + isolated_logger.info("info msg") + isolated_logger.warning("warn msg") + isolated_logger.error("error msg") + + assert len(seen) == 4 + levels = [ev.data["level_name"] for ev in seen] + assert levels == ["DEBUG", "INFO", "WARNING", "ERROR"] + msgs = [ev.data["message"] for ev in seen] + assert msgs == ["debug msg", "info msg", "warn msg", "error msg"] + + +def test_handler_respects_level_threshold(bus_with_capture, isolated_logger): + """Records below the handler level are dropped.""" + bus, seen = bus_with_capture + isolated_logger.addHandler(LogToBusHandler(bus, level=logging.WARNING)) + + isolated_logger.debug("nope") + isolated_logger.info("also nope") + isolated_logger.warning("yes") + isolated_logger.error("yes") + + assert [ev.data["level_name"] for ev in seen] == ["WARNING", "ERROR"] + + +def test_exc_text_captured_on_exception(bus_with_capture, isolated_logger): + """logger.exception() includes the formatted traceback in payload.""" + bus, seen = bus_with_capture + isolated_logger.addHandler(LogToBusHandler(bus, level=logging.DEBUG)) + + try: + raise RuntimeError("simulated") + except RuntimeError: + isolated_logger.exception("blew up") + + assert len(seen) == 1 + payload = seen[0].data + assert payload["level_name"] == "ERROR" + assert payload["message"] == "blew up" + assert payload.get("exc_text") and "RuntimeError" in payload["exc_text"] + assert "simulated" in payload["exc_text"] + + +def test_reentry_guard_prevents_infinite_loop(bus_with_capture, isolated_logger): + """A bus subscriber that itself logs must NOT spawn cascading events. + + Without the guard, every subscriber-emitted log would republish as + another LOG_RECORD, spawn another subscriber call, ... ad infinitum. + """ + bus, seen = bus_with_capture + isolated_logger.addHandler(LogToBusHandler(bus, level=logging.DEBUG)) + + # Subscriber that re-logs on every event it sees. + def loud(ev): + isolated_logger.debug("subscriber-internal log") + + bus.subscribe(EventType.LOG_RECORD, loud) + + isolated_logger.info("trigger") + # Exactly one event — the original. The subscriber's log was + # suppressed by the re-entry guard. + assert len(seen) == 1 + assert seen[0].data["message"] == "trigger" + + +def test_handler_skips_bridge_internals(bus_with_capture): + """Records from the bridge's own loggers must never be republished.""" + bus, seen = bus_with_capture + h = LogToBusHandler(bus, level=logging.DEBUG) + + for blocked in _NEVER_BRIDGE: + lgr = logging.getLogger(blocked) + lgr.setLevel(logging.DEBUG) + lgr.addHandler(h) + try: + lgr.info("from %s", blocked) + finally: + lgr.removeHandler(h) + + assert seen == [] # nothing republished + + +def test_configure_log_bridge_off_returns_none(bus_with_capture, monkeypatch): + """GENTLY_LOG_BUS=off disables the bridge entirely.""" + bus, seen = bus_with_capture + monkeypatch.setenv("GENTLY_LOG_BUS", "off") + h = configure_log_bridge(bus=bus, loggers=["gently.cfg_off_test"]) + assert h is None + logging.getLogger("gently.cfg_off_test").info("should not appear") + assert seen == [] + + +def test_configure_log_bridge_attaches_handler(bus_with_capture, monkeypatch): + """Default-on path attaches a handler that publishes records.""" + bus, seen = bus_with_capture + monkeypatch.setenv("GENTLY_LOG_BUS", "on") + monkeypatch.setenv("GENTLY_LOG_BUS_LEVEL", "INFO") + monkeypatch.delenv("GENTLY_LOG_BUS_INCLUDE_THIRDPARTY", raising=False) + + target_name = "gently.cfg_attach_test" + target = logging.getLogger(target_name) + target.setLevel(logging.DEBUG) # otherwise inherits root WARNING + + h = configure_log_bridge(bus=bus, loggers=[target_name]) + assert h is not None + try: + target.info("hi") + assert any(ev.data["message"] == "hi" for ev in seen) + finally: + target.removeHandler(h) + + +def test_configure_log_bridge_is_idempotent(bus_with_capture, monkeypatch): + """Calling configure twice doesn't double-attach.""" + bus, seen = bus_with_capture + monkeypatch.setenv("GENTLY_LOG_BUS", "on") + + target = "gently.idem_test" + logging.getLogger(target).setLevel(logging.DEBUG) + h1 = configure_log_bridge(bus=bus, loggers=[target]) + h2 = configure_log_bridge(bus=bus, loggers=[target]) + assert h1 is not None + try: + logging.getLogger(target).warning("once") + # If both handlers were attached we'd see two events for the same + # record. One event = no double-attach. + warn_events = [ev for ev in seen if ev.data["message"] == "once"] + assert len(warn_events) == 1 + finally: + for h in (h1, h2): + if h is not None: + try: + logging.getLogger(target).removeHandler(h) + except Exception: + pass + + +def test_third_party_excluded_by_default(bus_with_capture, monkeypatch): + """uvicorn / aiohttp / bluesky etc. don't get bridged unless opted in.""" + bus, seen = bus_with_capture + monkeypatch.setenv("GENTLY_LOG_BUS", "on") + monkeypatch.delenv("GENTLY_LOG_BUS_INCLUDE_THIRDPARTY", raising=False) + # Default behaviour: loggers list omits third-party. We pass None so + # the function picks its own default. + logging.getLogger("gently").setLevel(logging.DEBUG) + h = configure_log_bridge(bus=bus, loggers=None) + assert h is not None + try: + logging.getLogger("aiohttp.access").info("noisy 1") + logging.getLogger("bluesky").info("noisy 2") + assert seen == [] + # But gently.* still works + logging.getLogger("gently.proves_attached").info("kept") + assert any(ev.data["message"] == "kept" for ev in seen) + finally: + for n in ("gently", "gently_perception"): + try: + logging.getLogger(n).removeHandler(h) + except Exception: + pass + + +def test_third_party_included_when_opted_in(bus_with_capture, monkeypatch): + """GENTLY_LOG_BUS_INCLUDE_THIRDPARTY=1 brings the noisy loggers in.""" + bus, seen = bus_with_capture + monkeypatch.setenv("GENTLY_LOG_BUS", "on") + monkeypatch.setenv("GENTLY_LOG_BUS_INCLUDE_THIRDPARTY", "1") + logging.getLogger("aiohttp").setLevel(logging.DEBUG) + logging.getLogger("aiohttp.access").setLevel(logging.DEBUG) + + h = configure_log_bridge(bus=bus, loggers=None) + assert h is not None + try: + logging.getLogger("aiohttp.access").info("now visible") + assert any(ev.data["message"] == "now visible" for ev in seen) + finally: + # Strip handler from every logger we might have attached to — + # belt and braces, since the function defaults to a long list. + for name in (list(logging.Logger.manager.loggerDict.keys()) + + ["gently", "gently_perception"]): + try: + logging.getLogger(name).removeHandler(h) + except Exception: + pass From 7017866b8b7f2badf354b0b4a20aff80922b357a Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Thu, 28 May 2026 13:18:10 -0400 Subject: [PATCH 013/314] switchbot: BLE room-light accessory device + web test GUI SwitchBot Bot (WoHand) as a Bluesky/ophyd-protocol device over `bleak` (set('on'|'off'|'press') -> Status, read/describe). Controls the diSPIM room light used for bottom-camera imaging. Adds a standalone FastAPI test GUI under diagnostics/ (buttons + morse blinker). Dep in requirements_device.txt. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit a790b028bd384595d58f58dc8af5356300f306dd) --- diagnostics/switchbot_webgui.py | 345 ++++++++++++++++++++++++++++++++ gently/hardware/switchbot.py | 176 ++++++++++++++++ requirements_device.txt | 3 + 3 files changed, 524 insertions(+) create mode 100644 diagnostics/switchbot_webgui.py create mode 100644 gently/hardware/switchbot.py diff --git a/diagnostics/switchbot_webgui.py b/diagnostics/switchbot_webgui.py new file mode 100644 index 00000000..5832b103 --- /dev/null +++ b/diagnostics/switchbot_webgui.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +""" +Temporary web GUI to play with the SwitchBot Bot that switches the diSPIM room +light (on for bottom-camera/brightfield imaging, off otherwise). + +This is a TEST TOOL, not part of the production device layer. It drives the Bot +directly over BLE using the same command protocol as +``gently.hardware.switchbot.SwitchBot`` (same command bytes + GATT UUIDs), but +over a single *persistent* connection so the buttons feel snappy and the morse +blinker is fast — the device-layer class is connect-per-command (~1-2 s each), +which is fine for a plan step but hopeless for blinking. + +Features: ON / OFF / PRESS buttons, and a morse-code blinker (blinks the real +room light + mirrors the pattern on screen). The Bot is a mechanical switch +pusher, so each toggle is a ~0.5-1 s servo move — morse is deliberately slow. + +Run: + .venv/bin/python diagnostics/switchbot_webgui.py + # then open http://127.0.0.1:8765 + + .venv/bin/python diagnostics/switchbot_webgui.py --address EC:6F:04:06:5B:23 --port 8765 +""" +from __future__ import annotations + +import argparse +import asyncio +import logging +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, JSONResponse +from pydantic import BaseModel + +# Reuse the device-layer device's protocol definitions (single source of truth). +from gently.hardware.switchbot import _COMMANDS, _CTRL_CHAR + +logger = logging.getLogger("switchbot_webgui") + +DEFAULT_ADDRESS = "EC:6F:04:06:5B:23" + +# ITU morse, letters + digits. Unsupported characters are skipped. +MORSE = { + "A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".", "F": "..-.", + "G": "--.", "H": "....", "I": "..", "J": ".---", "K": "-.-", "L": ".-..", + "M": "--", "N": "-.", "O": "---", "P": ".--.", "Q": "--.-", "R": ".-.", + "S": "...", "T": "-", "U": "..-", "V": "...-", "W": ".--", "X": "-..-", + "Y": "-.--", "Z": "--..", + "0": "-----", "1": ".----", "2": "..---", "3": "...--", "4": "....-", + "5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.", +} + + +class Bot: + """A single persistent BLE connection to the Bot, with serialized access.""" + + def __init__(self, address: str): + self.address = address + self._client = None + self._lock = asyncio.Lock() + self._morse_task: asyncio.Task | None = None + self.state = "unknown" + self.busy = False + + async def _ensure(self): + from bleak import BleakClient + if self._client is not None and self._client.is_connected: + return + self._client = BleakClient(self.address, timeout=20) + await self._client.connect() + logger.info("connected to %s", self.address) + + async def _write(self, action: str): + """Write one command, reconnecting once if the link dropped.""" + from bleak.exc import BleakError + for attempt in (1, 2): + try: + await self._ensure() + await self._client.write_gatt_char(_CTRL_CHAR, _COMMANDS[action], response=True) + if action in ("on", "off"): + self.state = action + return + except (BleakError, OSError, asyncio.TimeoutError) as exc: + logger.warning("write %s attempt %d failed: %s", action, attempt, exc) + self._client = None # force reconnect + if attempt == 2: + raise + + async def _cancel_morse(self): + task = self._morse_task + if task and not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + self._morse_task = None + + async def command(self, action: str) -> str: + """ON/OFF/PRESS. Interrupts any running morse (manual override).""" + await self._cancel_morse() + async with self._lock: + await self._write(action) + return self.state + + def schedule(self, text: str, unit: float): + """Build an on/off timeline [(state, seconds), ...] for a message.""" + seq = [("off", round(unit, 3))] # settle to a known state first + for ch in text.upper(): + if ch == " ": + seq.append(("off", round(unit * 7, 3))) + continue + code = MORSE.get(ch) + if not code: + continue + for sym in code: + seq.append(("on", round(unit * (3 if sym == "-" else 1), 3))) + seq.append(("off", round(unit, 3))) # intra-letter gap + st, _ = seq[-1] + seq[-1] = (st, round(unit * 3, 3)) # upgrade to inter-letter gap + return seq + + async def start_morse(self, text: str, unit: float): + await self._cancel_morse() + seq = self.schedule(text, unit) + if len(seq) <= 1: + return None + restore = self.state + self._morse_task = asyncio.create_task(self._play(seq, restore)) + return seq + + async def _play(self, seq, restore: str): + async with self._lock: + self.busy = True + try: + for state, dur in seq: + await self._write(state) + await asyncio.sleep(dur) + await self._write(restore if restore in ("on", "off") else "off") + finally: + self.busy = False + + async def stop(self): + await self._cancel_morse() + async with self._lock: + await self._write("off") + return self.state + + async def disconnect(self): + await self._cancel_morse() + if self._client is not None and self._client.is_connected: + await self._client.disconnect() + + +BOT: Bot | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + if BOT is not None: + await BOT.disconnect() + + +app = FastAPI(lifespan=lifespan) + + +class MorseReq(BaseModel): + text: str + unit: float = 1.5 + + +@app.get("/", response_class=HTMLResponse) +async def index(): + return PAGE.replace("__ADDRESS__", BOT.address) + + +@app.get("/status") +async def status(): + return {"state": BOT.state, "busy": BOT.busy, "address": BOT.address} + + +@app.post("/cmd/{action}") +async def cmd(action: str): + if action not in _COMMANDS: + return JSONResponse({"error": f"unknown action {action!r}"}, status_code=400) + try: + state = await BOT.command(action) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=502) + return {"state": state} + + +@app.post("/morse") +async def morse(req: MorseReq): + unit = max(0.3, min(4.0, req.unit)) + text = req.text[:40] + try: + seq = await BOT.start_morse(text, unit) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=502) + if seq is None: + return JSONResponse({"error": "nothing sendable in that text"}, status_code=400) + seconds = round(sum(d for _, d in seq), 1) + return {"schedule": seq, "unit": unit, "seconds": seconds} + + +@app.post("/stop") +async def stop(): + try: + state = await BOT.stop() + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=502) + return {"state": state} + + +PAGE = """ + + +diSPIM Room Light + +
+

diSPIM Room Light

+
SwitchBot Bot · __ADDRESS__
+
+
+
+ + + +
+
+ +
+ fast + + slow + 0.7s +
+
+ + +
+
+
+
+ +""" + + +def main(): + ap = argparse.ArgumentParser(description="Temporary SwitchBot room-light web GUI") + ap.add_argument("--address", default=DEFAULT_ADDRESS, help="Bot BLE MAC address") + ap.add_argument("--port", type=int, default=8765) + ap.add_argument("--host", default="127.0.0.1", help="bind host (default: localhost only)") + args = ap.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + global BOT + BOT = Bot(args.address) + print(f"\n diSPIM Room Light GUI → http://{args.host}:{args.port}\n Bot: {args.address}\n") + uvicorn.run(app, host=args.host, port=args.port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/gently/hardware/switchbot.py b/gently/hardware/switchbot.py new file mode 100644 index 00000000..131c50ef --- /dev/null +++ b/gently/hardware/switchbot.py @@ -0,0 +1,176 @@ +""" +SwitchBot Bot (WoHand) control as a Bluesky/Ophyd-protocol device. + +The SwitchBot Bot is a Bluetooth-LE button pusher. In "Switch mode" it supports +explicit on/off; in "Press mode" it does a momentary press. This module talks to +it directly over BLE via ``bleak`` using the documented GATT command protocol — +no SwitchBot cloud, no hub. + +The device follows the same duck-typed Bluesky protocol as the diSPIM devices +(see ``gently.hardware.dispim.devices.optical.DiSPIMLED``): ``set(state)`` returns +an ophyd ``Status``, plus ``read()``/``describe()``. So it drops into plans via +``yield from bps.mv(bot, 'on')``. + +BLE I/O is async (``bleak``); ``set()`` runs a fresh connect→write→disconnect +cycle in a worker thread and resolves the Status when done. Connecting per command +keeps the implementation robust (no stale-connection handling) at the cost of +~1-2 s latency, which is fine for a low-frequency accessory. For lower latency or +encrypted/password-protected Bots, swap the ``_send_command`` body for PySwitchbot. + +Self-test (drives a real Bot):: + + python gently/hardware/switchbot.py EC:6F:04:06:5B:23 on off +""" +from __future__ import annotations + +import asyncio +import logging +import threading +import time +from collections import OrderedDict + +logger = logging.getLogger(__name__) + +# SwitchBot Bot GATT. Note the UUID group is 9fb8 — the widely-copied 9fb9 is wrong. +_CTRL_CHAR = "cba20002-224d-11e6-9fb8-0002a5d5c51b" # write / write-without-response +_NOTIFY_CHAR = "cba20003-224d-11e6-9fb8-0002a5d5c51b" # notify (command response) + +_COMMANDS = { + "on": bytes([0x57, 0x01, 0x01]), + "off": bytes([0x57, 0x01, 0x02]), + "press": bytes([0x57, 0x01, 0x00]), +} +_RESP_OK = 0x01 # first byte of the response notification on success + + +class SwitchBotError(RuntimeError): + """BLE I/O failed, timed out, or the Bot reported a non-OK response.""" + + +async def _send_command(address: str, command: bytes, timeout: float) -> bytes: + """Connect, send one command, await the response notification, disconnect. + + Returns the raw response bytes; raises SwitchBotError on timeout or non-OK. + """ + from bleak import BleakClient # lazy import keeps module import cheap + + response: dict[str, bytes] = {} + got = asyncio.Event() + + def _on_notify(_char, data: bytearray) -> None: + response["data"] = bytes(data) + got.set() + + async with BleakClient(address, timeout=timeout) as client: + await client.start_notify(_NOTIFY_CHAR, _on_notify) + await client.write_gatt_char(_CTRL_CHAR, command, response=True) + try: + await asyncio.wait_for(got.wait(), timeout=timeout) + except asyncio.TimeoutError as exc: + raise SwitchBotError("no response notification from SwitchBot") from exc + finally: + try: + await client.stop_notify(_NOTIFY_CHAR) + except Exception: # disconnect cleanup is best-effort + pass + + data = response["data"] + if not data or data[0] != _RESP_OK: + raise SwitchBotError(f"SwitchBot returned non-OK response: {data.hex()}") + return data + + +class SwitchBot: + """Bluesky-protocol device for a SwitchBot Bot button pusher. + + Parameters + ---------- + address : str + BLE MAC address, e.g. "EC:6F:04:06:5B:23". + name : str + Device name used as the key in plans and read() output. + timeout : float + Per-command BLE connect/response timeout in seconds. + + Valid states for ``set``: 'on', 'off', 'press'. + """ + + def __init__(self, address: str, name: str = "switchbot", *, timeout: float = 20.0): + self.address = address + self.name = name + self.timeout = timeout + self.parent = None # required for Bluesky bps.mv() + self._state = "unknown" # last commanded on/off state + self._lock = threading.Lock() # serialize BLE access (one radio, one bot) + + # -- Bluesky settable protocol ------------------------------------------- + def set(self, state: str): + """Send on/off/press. Returns an ophyd Status that finishes when done.""" + from ophyd.status import Status + + state = str(state).lower() + if state not in _COMMANDS: + raise ValueError(f"state {state!r} not in {list(_COMMANDS)}") + + status = Status(obj=self, timeout=self.timeout + 5) + + def worker(): + with self._lock: + try: + data = asyncio.run( + _send_command(self.address, _COMMANDS[state], self.timeout) + ) + except Exception as exc: + logger.warning("SwitchBot %s set(%s) failed: %s", self.name, state, exc) + status.set_exception(exc) + return + if state in ("on", "off"): + self._state = state + logger.info("SwitchBot %s -> %s (resp %s)", self.name, state, data.hex()) + status.set_finished() + + threading.Thread(target=worker, name=f"{self.name}-set", daemon=True).start() + return status + + # -- Bluesky readable protocol ------------------------------------------- + def read(self): + return OrderedDict({ + self.name: {"value": self._state, "timestamp": time.time()} + }) + + def describe(self): + return OrderedDict({ + self.name: {"source": f"switchbot:{self.address}", "dtype": "string", "shape": []} + }) + + def read_configuration(self): + return OrderedDict() + + def describe_configuration(self): + return OrderedDict() + + +if __name__ == "__main__": + # Standalone self-test, e.g.: python gently/hardware/switchbot.py AA:BB:.. on off + import sys + + address = "EC:6F:04:06:5B:23" + cmds = [] + for arg in sys.argv[1:]: + if ":" in arg and len(arg) >= 17: # looks like a MAC address + address = arg + else: + cmds.append(arg.lower()) + cmds = cmds or ["on", "off"] + + logging.basicConfig(level=logging.INFO, format="%(message)s") + bot = SwitchBot(address) + print(f"SwitchBot {address} — sequence: {cmds}\n") + for i, cmd in enumerate(cmds): + print(f"set({cmd!r}) ...") + st = bot.set(cmd) + st.wait(30) # blocks; raises if the command failed + print(f" done; read() -> {bot.read()[bot.name]['value']}") + if i != len(cmds) - 1: + time.sleep(1.5) + print("\nOK") diff --git a/requirements_device.txt b/requirements_device.txt index 71612cf6..bbcc5fdd 100644 --- a/requirements_device.txt +++ b/requirements_device.txt @@ -22,3 +22,6 @@ pyyaml>=6.0 # SAM embryo detection (optional, needs CUDA GPU) # torch>=2.0.0 # segment-anything + +# BLE accessory control (SwitchBot Bot — diSPIM room light) +bleak>=0.21.0 From d42ac6b7e1681f0163b16d73da7d21f770171e8f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Thu, 28 May 2026 13:18:10 -0400 Subject: [PATCH 014/314] temperature: ACUITYnano thermal controller device (serial/MQTT/mock) Bluesky/ophyd-protocol device for the ACUITYnano Peltier controller. set(target) blocks until "[ SYSTEM LOCKED ]"; read() reports water temp, setpoint, state. Serial + MQTT transports plus a mock backend for hardware-free testing. Deps in requirements_device.txt. Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 5f7912e5ecda9dd154bda147e782b519ac814d56) --- gently/hardware/temperature.py | 242 +++++++++++++++++++++++++++++++++ requirements_device.txt | 4 + 2 files changed, 246 insertions(+) create mode 100644 gently/hardware/temperature.py diff --git a/gently/hardware/temperature.py b/gently/hardware/temperature.py new file mode 100644 index 00000000..526d576d --- /dev/null +++ b/gently/hardware/temperature.py @@ -0,0 +1,242 @@ +""" +ACUITYnano Precision Thermal Controller as a Bluesky/Ophyd-protocol device. + +Wraps the vendor SDK — a Peltier/TEC water-cooled controller, 0.0-99.9 C. Two +transports expose the same core API: + - USB serial : acuitynano_precision_thermalizer_serial (vendor-recommended + for closed-loop automation; zero-latency) + - MQTT : acuitynano_precision_thermalizer_api (multi-client; adds + get_peltier_temp()) + +The device follows the same duck-typed Bluesky protocol as the diSPIM devices +(see gently.hardware.dispim.devices.optical.DiSPIMLED). A temperature controller +is the textbook bluesky "settable that completes on stabilization": + + yield from bps.mv(temperature, 20.0) # blocks until the controller LOCKS + +set(target) commands the setpoint, enables the TEC, and returns an ophyd Status +that finishes only when the controller reports "[ SYSTEM LOCKED ]" (or raises on +timeout). read() reports the live water temperature (plus setpoint / state, and +peltier temp when the transport provides it). BLE-style work runs in a worker +thread so the Status integrates with the RunEngine. + +NOTE: the vendor `acuitynano_precision_thermalizer_*` packages are NOT on PyPI — +install them on the device-layer machine. Local logic can be exercised with the +built-in mock backend: `python gently/hardware/temperature.py --mock 20`. +""" +from __future__ import annotations + +import logging +import threading +import time +from collections import OrderedDict + +logger = logging.getLogger(__name__) + +TEMP_MIN_C = 0.0 +TEMP_MAX_C = 99.9 + + +def _make_backend(cfg: dict): + """Construct the vendor SDK transport from a config mapping.""" + backend = str(cfg.get("backend", "serial")).lower() + if backend == "mock": + return _MockBackend() + if backend == "serial": + from acuitynano_precision_thermalizer_serial import AcuityNanoPrecisionThermalizerSerial + return AcuityNanoPrecisionThermalizerSerial( + cfg["com_port"], baud_rate=cfg.get("baud_rate", 115200) + ) + if backend == "mqtt": + from acuitynano_precision_thermalizer_api import AcuityNanoPrecisionThermalizerAPI + # The vendor package ships with an embedded HiveMQ Cloud broker + creds, + # so MQTT can run with no config. Pass only the keys actually provided, + # to override those embedded defaults (and keep secrets in config, not code). + kwargs = {k: cfg[k] for k in ("broker", "port", "user", "password") if k in cfg} + return AcuityNanoPrecisionThermalizerAPI(**kwargs) + raise ValueError(f"unknown temperature backend {backend!r} (use 'serial', 'mqtt', or 'mock')") + + +def create_temperature_controller(cfg: dict) -> "TemperatureController": + """Factory used by the device layer: build transport + wrap as a device.""" + backend = _make_backend(cfg) + if "feedback_peltier" in cfg and hasattr(backend, "set_feedback_sensor"): + backend.set_feedback_sensor(use_peltier=bool(cfg["feedback_peltier"])) + return TemperatureController( + backend, + name=cfg.get("name", "temperature"), + stabilize_timeout=cfg.get("stabilize_timeout", 600.0), + ) + + +class TemperatureController: + """Bluesky-protocol device for the ACUITYnano thermal controller. + + Parameters + ---------- + backend : object + Vendor SDK instance exposing set_temperature / get_water_temp / + get_system_state / enable_tec / wait_for_target. + name : str + Device name; the registry key and primary read() field. + stabilize_timeout : float + Seconds to wait for "[ SYSTEM LOCKED ]" before set() fails. + """ + + def __init__(self, backend, name: str = "temperature", *, stabilize_timeout: float = 600.0): + self._dev = backend + self.name = name + self.stabilize_timeout = float(stabilize_timeout) + self.parent = None # required for Bluesky bps.mv() + self._setpoint = None # last commanded target + self._lock = threading.Lock() + + # -- Bluesky settable protocol ------------------------------------------- + def set(self, target_c): + """Command setpoint + enable TEC; Status finishes when the system locks.""" + from ophyd.status import Status + + target = float(target_c) + if not (TEMP_MIN_C <= target <= TEMP_MAX_C): + raise ValueError(f"target {target} C outside [{TEMP_MIN_C}, {TEMP_MAX_C}]") + + status = Status(obj=self, timeout=self.stabilize_timeout + 30) + + def worker(): + with self._lock: + try: + self._dev.set_temperature(target) # vendor also validates range + self._dev.enable_tec(True) + locked = self._dev.wait_for_target(timeout_seconds=self.stabilize_timeout) + except Exception as exc: + logger.warning("temperature %s set(%.2f) failed: %s", self.name, target, exc) + status.set_exception(exc) + return + self._setpoint = target + if locked: + logger.info("temperature %s locked at %.2f C", self.name, target) + status.set_finished() + else: + status.set_exception(TimeoutError( + f"{self.name} did not stabilize at {target} C within {self.stabilize_timeout}s" + )) + + threading.Thread(target=worker, name=f"{self.name}-set", daemon=True).start() + return status + + # -- Explicit controls (outside the bps.mv() path) ----------------------- + def enable(self, on: bool = True): + self._dev.enable_tec(bool(on)) + + def setpoint(self, target_c): + """Command the setpoint without blocking for stabilization.""" + self._dev.set_temperature(float(target_c)) + + # -- Bluesky readable protocol ------------------------------------------- + def read(self): + now = time.time() + data = OrderedDict() + data[self.name] = {"value": self._safe(self._dev.get_water_temp), "timestamp": now} + data[f"{self.name}_setpoint"] = {"value": self._setpoint, "timestamp": now} + data[f"{self.name}_state"] = { + "value": self._safe(self._dev.get_system_state, default="unknown"), "timestamp": now + } + if self._has_peltier(): + data[f"{self.name}_peltier"] = {"value": self._safe(self._dev.get_peltier_temp), "timestamp": now} + return data + + def describe(self): + src = f"acuitynano:{self.name}" + d = OrderedDict() + d[self.name] = {"source": src, "dtype": "number", "shape": []} + d[f"{self.name}_setpoint"] = {"source": src, "dtype": "number", "shape": []} + d[f"{self.name}_state"] = {"source": src, "dtype": "string", "shape": []} + if self._has_peltier(): + d[f"{self.name}_peltier"] = {"source": src, "dtype": "number", "shape": []} + return d + + def read_configuration(self): + return OrderedDict() + + def describe_configuration(self): + return OrderedDict() + + def close(self): + """Release the transport (serial port / MQTT client) on shutdown.""" + for method in ("close", "disconnect"): + fn = getattr(self._dev, method, None) + if fn is not None: + try: + fn() + except Exception: + pass + return + + # -- helpers -------------------------------------------------------------- + def _has_peltier(self) -> bool: + return getattr(self._dev, "get_peltier_temp", None) is not None + + @staticmethod + def _safe(fn, default=None): + try: + return fn() + except Exception: + return default + + +class _MockBackend: + """In-memory fake mirroring the vendor API, for local testing without hardware.""" + + def __init__(self, *args, **kwargs): + self._target = 25.0 + self._enabled = False + + def set_temperature(self, t): + if not (TEMP_MIN_C <= float(t) <= TEMP_MAX_C): + raise ValueError("Target must be between 0.0 and 99.9 C") + self._target = float(t) + + def enable_tec(self, on): + self._enabled = bool(on) + + def set_feedback_sensor(self, use_peltier=False): + pass + + def wait_for_target(self, timeout_seconds=300): + time.sleep(0.5) # pretend to ramp + settle + return True + + def get_water_temp(self): + return self._target + + def get_peltier_temp(self): + return self._target - 1.0 + + def get_system_state(self): + return "[ SYSTEM LOCKED ]" if self._enabled else "[ IDLE ]" + + def close(self): + pass + + +if __name__ == "__main__": + import sys + + logging.basicConfig(level=logging.INFO, format="%(message)s") + if "--mock" in sys.argv: + target = 20.0 + for arg in sys.argv[1:]: + try: + target = float(arg) + break + except ValueError: + continue + dev = TemperatureController(_MockBackend(), name="temperature", stabilize_timeout=10) + print(f"[mock] set({target}) — blocks until locked ...") + st = dev.set(target) + st.wait(15) + print("[mock] read ->", {k: v["value"] for k, v in dev.read().items()}) + print("OK") + else: + print("Real-hardware self-test needs the vendor SDK + a controller. " + "Run with --mock to exercise the device logic locally.") diff --git a/requirements_device.txt b/requirements_device.txt index bbcc5fdd..26b10dab 100644 --- a/requirements_device.txt +++ b/requirements_device.txt @@ -25,3 +25,7 @@ pyyaml>=6.0 # BLE accessory control (SwitchBot Bot — diSPIM room light) bleak>=0.21.0 + +# ACUITYnano thermal controller transports +pyserial>=3.5 +paho-mqtt>=1.6.0 From 32a8ad28be00c765f2b5ea4bcfa3df7b9c667f99 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Thu, 28 May 2026 13:18:10 -0400 Subject: [PATCH 015/314] device_layer: register switchbot + temperature; expose temperature to the agent Config-gated registration of the SwitchBot and ACUITYnano devices alongside the MMCore devices. Adds /api/temperature/{set,status} REST endpoints + client methods, and set_temperature/get_temperature agent tools so the agent can hold or shift sample temperature (C. elegans development rate). Co-Authored-By: Claude Opus 4.7 (cherry picked from commit 249ae4ac0752ea419e9dd430423ab7db936efeed) --- gently/app/tools/__init__.py | 1 + gently/app/tools/temperature_tools.py | 75 ++++++++++++++++++ gently/hardware/dispim/client.py | 9 +++ gently/hardware/dispim/device_layer.py | 103 +++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 gently/app/tools/temperature_tools.py diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index 765b3d33..4dd1b776 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -10,6 +10,7 @@ from . import stage_tools from . import led_tools from . import light_source_tools +from . import temperature_tools from . import calibration_tools from . import acquisition_tools from . import volume_tools diff --git a/gently/app/tools/temperature_tools.py b/gently/app/tools/temperature_tools.py new file mode 100644 index 00000000..43ff8ab0 --- /dev/null +++ b/gently/app/tools/temperature_tools.py @@ -0,0 +1,75 @@ +""" +Temperature Control Tools + +Agent tools for the ACUITYnano thermal controller. Temperature drives C. elegans +development rate, so these let the agent hold or shift the sample temperature as +part of closed-loop experiments. +""" +from typing import Dict + +from gently.harness.tools.registry import tool, ToolCategory, ToolExample + + +@tool( + name="set_temperature", + description=( + "Set the sample temperature setpoint in Celsius (0.0-99.9). The thermal " + "controller ramps toward the target and this returns immediately — poll " + "get_temperature until the state reads '[ SYSTEM LOCKED ]' before imaging. " + "Temperature controls C. elegans development rate (~15 C slow, 20 C standard, " + "25 C fast)." + ), + category=ToolCategory.HARDWARE, + requires_microscope=True, + examples=[ + ToolExample("Hold the sample at 20 degrees", {"target_c": 20.0}), + ToolExample("Warm the embryos to 25 C to speed development", {"target_c": 25.0}), + ], +) +async def set_temperature(target_c: float, context: Dict) -> str: + """Command the thermal controller to a target temperature. + + Parameters + ---------- + target_c : float + Target temperature in degrees Celsius (0.0-99.9). + """ + client = context.get('client') + try: + result = await client.set_temperature(target_c) + if result.get('success'): + return (f"Commanded {target_c} C. Currently {result.get('temperature_c')} C, " + f"state {result.get('state')!r}. Ramping — call get_temperature to confirm lock.") + return f"Error setting temperature: {result.get('error', 'unknown error')}" + except Exception as e: + return f"Error setting temperature: {e}" + + +@tool( + name="get_temperature", + description=( + "Read the current sample temperature, target setpoint, and lock state from the " + "thermal controller. Use to confirm the sample has stabilized at the setpoint " + "('[ SYSTEM LOCKED ]') before acquiring." + ), + category=ToolCategory.HARDWARE, + requires_microscope=True, + examples=[ + ToolExample("What's the current temperature?"), + ToolExample("Has the sample reached temperature yet?"), + ], +) +async def get_temperature(context: Dict) -> str: + """Read current temperature, setpoint, and lock state.""" + client = context.get('client') + try: + r = await client.get_temperature() + if r.get('success'): + msg = (f"Temperature {r.get('temperature_c')} C " + f"(setpoint {r.get('setpoint_c')} C, state {r.get('state')!r}") + if r.get('peltier_c') is not None: + msg += f", peltier {r.get('peltier_c')} C" + return msg + ")" + return f"Error reading temperature: {r.get('error', 'unknown error')}" + except Exception as e: + return f"Error reading temperature: {e}" diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 6f097670..03ff8009 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -802,6 +802,15 @@ async def get_led_status(self) -> Dict: """Get current LED status.""" return await self._api_get('/api/led/status') + async def set_temperature(self, target_c: float) -> Dict: + """Command the thermal-controller setpoint (Celsius). Non-blocking — the + controller ramps; poll get_temperature() for the lock state.""" + return await self._api_post('/api/temperature/set', {'target_c': target_c}) + + async def get_temperature(self) -> Dict: + """Get current temperature, setpoint, and lock state.""" + return await self._api_get('/api/temperature/status') + # ------------------------------------------------------------------ # Live device-state readout (streamed from the device layer poller) # ------------------------------------------------------------------ diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 48e19746..a1af5e4a 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -237,6 +237,39 @@ async def initialize(self): for name in self.devices: logger.debug(" - %s", name) + # Optional BLE accessory: SwitchBot Bot. It's a Bluetooth device, not a + # Micro-Manager adapter, so it's created here (independently of MMCore) + # and added to the same registry. Plans address it by name, e.g. + # bps.mv(switchbot, 'on'). Config-gated: no `switchbot:` section => no-op. + sb_cfg = self.config.get('switchbot') + if sb_cfg: + try: + from gently.hardware.switchbot import SwitchBot + sb_name = sb_cfg.get('name', 'switchbot') + self.devices[sb_name] = SwitchBot( + address=sb_cfg['address'], + name=sb_name, + timeout=sb_cfg.get('timeout', 20.0), + ) + logger.info("Created SwitchBot '%s' at %s", sb_name, sb_cfg['address']) + except Exception as exc: + logger.warning("Could not create SwitchBot: %s", exc) + + # Optional temperature controller (ACUITYnano). Like the SwitchBot it's + # not an MMCore adapter — created here from config and added to the same + # registry. Plans block on it via bps.mv(temperature, 20.0) until the + # controller reports SYSTEM LOCKED. Config-gated: no `temperature:` => no-op. + temp_cfg = self.config.get('temperature') + if temp_cfg: + try: + from gently.hardware.temperature import create_temperature_controller + tc = create_temperature_controller(temp_cfg) + self.devices[tc.name] = tc + logger.info("Created temperature controller '%s' (backend=%s)", + tc.name, temp_cfg.get('backend', 'serial')) + except Exception as exc: + logger.warning("Could not create temperature controller: %s", exc) + # Push XY safety bounds down to the ASI Tiger firmware so the joystick # can't drive past Layer-1 software limits. The XY_STAGE_*_UM constants # in devices/stage.py are the single source of truth — both the @@ -1294,6 +1327,74 @@ async def handle_set_led(self, request): 'traceback': traceback.format_exc() }, status=500) + async def handle_get_temperature_status(self, request): + """GET /api/temperature/status - current temperature, setpoint, lock state.""" + try: + temp = self.devices.get('temperature') + if temp is None: + return web.json_response({'success': False, 'error': 'temperature device not found'}) + r = temp.read() + return web.json_response({ + 'success': True, + 'temperature_c': r.get(temp.name, {}).get('value'), + 'setpoint_c': r.get(f'{temp.name}_setpoint', {}).get('value'), + 'state': r.get(f'{temp.name}_state', {}).get('value'), + 'peltier_c': r.get(f'{temp.name}_peltier', {}).get('value'), + }) + except Exception as e: + import traceback + return web.json_response({'success': False, 'error': str(e), + 'traceback': traceback.format_exc()}, status=500) + + async def handle_set_temperature(self, request): + """POST /api/temperature/set - command setpoint. Body: {target_c, wait?}. + + Non-blocking by default (controller ramps; poll status). wait=true blocks + until SYSTEM LOCKED or the device's stabilize timeout. + """ + try: + data = await request.json() + target = float(data.get('target_c')) + wait = bool(data.get('wait', False)) + if not (0.0 <= target <= 99.9): + return web.json_response({'success': False, + 'error': f'target {target} outside [0.0, 99.9]'}) + temp = self.devices.get('temperature') + if temp is None: + return web.json_response({'success': False, 'error': 'temperature device not found'}) + + if not wait: + temp.enable(True) + temp.setpoint(target) + r = temp.read() + return web.json_response({ + 'success': True, 'target_c': target, 'waited': False, + 'message': f'commanded {target} C (ramping)', + 'temperature_c': r.get(temp.name, {}).get('value'), + 'state': r.get(f'{temp.name}_state', {}).get('value'), + }) + + import time + status = temp.set(target) + timeout = float(getattr(temp, 'stabilize_timeout', 600.0)) + 10 + start = time.time() + while not status.done and (time.time() - start) < timeout: + await asyncio.sleep(0.5) + r = temp.read() + if status.done and status.success: + return web.json_response({ + 'success': True, 'target_c': target, 'waited': True, + 'message': f'locked at {target} C', + 'temperature_c': r.get(temp.name, {}).get('value'), + 'state': r.get(f'{temp.name}_state', {}).get('value'), + }) + return web.json_response({'success': False, 'target_c': target, + 'error': f'did not stabilize at {target} C within {timeout:.0f}s'}) + except Exception as e: + import traceback + return web.json_response({'success': False, 'error': str(e), + 'traceback': traceback.format_exc()}, status=500) + async def handle_set_camera_led_mode(self, request): """POST /api/camera/led_mode - Enable/disable automatic LED for bottom camera""" try: @@ -2236,6 +2337,8 @@ async def on_start(self): self._app.router.add_get('/api/plans', self.handle_get_plans) self._app.router.add_get('/api/led/status', self.handle_get_led_status) self._app.router.add_post('/api/led/set', self.handle_set_led) + self._app.router.add_get('/api/temperature/status', self.handle_get_temperature_status) + self._app.router.add_post('/api/temperature/set', self.handle_set_temperature) self._app.router.add_post('/api/camera/led_mode', self.handle_set_camera_led_mode) self._app.router.add_post('/api/camera/exposure', self.handle_set_camera_exposure) self._app.router.add_get('/api/camera/exposure', self.handle_get_camera_exposure) From 3c7bfc79a8d2f51baa86607fcbd001342c74ddfc Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 14:28:59 -0400 Subject: [PATCH 016/314] =?UTF-8?q?events:=20EMBRYO=5FTERMINATED=20?= =?UTF-8?q?=E2=80=94=20orchestrator=20emits,=20UI=20tracker=20handles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New EventType.EMBRYO_TERMINATED fires whenever an embryo's imaging stops for any reason (no_object terminal, configured stop condition, errors, user removal). The orchestrator emits it from both the no_object terminal path and the per-condition stop check. TimelapseStateTracker handles it by marking the embryo complete and carrying the completion_reason through for the UI. Single source of truth for "an embryo has stopped" — downstream listeners (filmstrip terminated badge, summary stats) now have one event to subscribe to instead of polling embryo state. Co-Authored-By: Claude Opus 4.7 (1M context) --- gently/app/orchestration/timelapse.py | 10 ++++++++++ gently/core/event_bus.py | 1 + gently/ui/web/timelapse_tracker.py | 12 ++++++++++++ 3 files changed, 23 insertions(+) diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index 28ae86b8..d35768f1 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -900,6 +900,11 @@ async def _check_stop_condition(self, embryo_state: EmbryoState): f"likely hatched / out of FOV)" ) logger.info(f"Embryo {embryo_state.id} stopped: {embryo_state.completion_reason}") + self._emit_event(EventType.EMBRYO_TERMINATED, { + "embryo_id": embryo_state.id, + "completion_reason": embryo_state.completion_reason, + "timepoints_acquired": embryo_state.timepoints_acquired, + }) return # Check all conditions (primary + additional) with OR logic @@ -909,6 +914,11 @@ async def _check_stop_condition(self, embryo_state: EmbryoState): embryo_state.is_complete = True embryo_state.completion_reason = reason logger.info(f"Embryo {embryo_state.id} stopped: {reason}") + self._emit_event(EventType.EMBRYO_TERMINATED, { + "embryo_id": embryo_state.id, + "completion_reason": reason, + "timepoints_acquired": embryo_state.timepoints_acquired, + }) return # Stop on first matching condition def _evaluate_single_condition( diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index d5495976..da10b19e 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -44,6 +44,7 @@ class EventType(Enum): EMBRYO_CENTERED = auto() EMBRYO_CALIBRATED = auto() EMBRYO_SKIPPED = auto() + EMBRYO_TERMINATED = auto() # {embryo_id, completion_reason} - emitted when an embryo's imaging stops (any reason: no_object terminal, stop condition met, errors, user removal) # Analysis events ANALYSIS_STARTED = auto() diff --git a/gently/ui/web/timelapse_tracker.py b/gently/ui/web/timelapse_tracker.py index b57086f9..665ef1b7 100644 --- a/gently/ui/web/timelapse_tracker.py +++ b/gently/ui/web/timelapse_tracker.py @@ -128,6 +128,18 @@ def handle_event(self, event_type: str, data: dict): self.status = "STOPPED" # Don't mark embryos as complete - they were stopped, not finished + elif event_type == "EMBRYO_TERMINATED": + # A single embryo's imaging was halted by the orchestrator + # (no_object terminal, configured stop condition, errors, etc). + # Carry the completion_reason through so the UI can show why. + eid = data.get("embryo_id") + if eid and eid in self.embryos: + self.embryos[eid]["is_complete"] = True + self.embryos[eid]["completion_reason"] = data.get("completion_reason") + self.embryos[eid].setdefault( + "completed_at", datetime.now().isoformat() + ) + elif event_type == "DETECTOR_EVALUATED": # All detector/perception evaluations (with reasoning) - populates reasoning panel eid = data.get("embryo_id") From a7250edaa5d0f0337c455b05d50cd663f262f234 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 14:29:50 -0400 Subject: [PATCH 017/314] Calibration: SPIM thumb expands into a floating popout for a larger live view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact SPIM thumb in the metrics strip is now a button. Click opens a draggable, fixed-position popout (~560×480) that mirrors the same live frame stream with bigger imagery, the embryo label, and a close affordance. Hover/focus on the thumb shows a ⤢ chip hinting the interaction; the chip is hidden until a frame actually arrives. The popout reuses SpimLivePreview's apply-on-render plumbing so no new stream is opened — same data path, second render target. Keeps the calibration profile compact by default while letting the operator pull a properly-sized view when they need to read fine structure. Co-Authored-By: Claude Opus 4.7 (1M context) --- gently/ui/web/static/css/main.css | 194 ++++++++++++++++++++- gently/ui/web/static/js/gallery.js | 269 +++++++++++++++++++++++++++-- 2 files changed, 445 insertions(+), 18 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index c0a6c539..4772dad7 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -1482,12 +1482,200 @@ a.tab-link.active { background: #000; border-radius: 3px; border: 1px solid var(--border); - display: none; /* shown when frame arrives via .has-frame */ - opacity: 1; + display: inline-block; + opacity: 0.35; /* dim until a real frame arrives (.has-frame) */ } .cal-spim-thumb.has-frame { - display: inline-block; + opacity: 1; +} + +/* Thumb wrapped in a button so click pops out a larger live view. + Sized to match the thumb so it remains clickable even before the + first frame arrives. */ +.cal-spim-thumb-btn { + position: relative; + padding: 0; + background: none; + border: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + line-height: 0; + color: inherit; + width: 96px; + height: 72px; +} + +.cal-spim-thumb-btn:focus-visible { + outline: 2px solid var(--accent, #4f8cff); + outline-offset: 2px; + border-radius: 4px; +} + +.cal-spim-expand-icon { + position: absolute; + top: 2px; + right: 2px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 11px; + line-height: 1; + padding: 2px 4px; + border-radius: 3px; + opacity: 0; + transition: opacity 0.12s ease; + pointer-events: none; +} + +.cal-spim-thumb-btn:hover .cal-spim-expand-icon, +.cal-spim-thumb-btn:focus-visible .cal-spim-expand-icon { + opacity: 1; +} + +/* Hide the expand chip when the thumb has no frame yet — nothing to expand. */ +.cal-spim-thumb-btn:has(.cal-spim-thumb:not(.has-frame)) .cal-spim-expand-icon { + display: none; +} + +/* ---------- Floating SPIM popout ---------- */ +.cal-spim-popout { + position: fixed; + top: 80px; + right: 24px; + width: 560px; + height: 480px; + min-width: 320px; + min-height: 260px; + z-index: 9000; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45), + 0 2px 8px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + resize: both; +} + +.cal-spim-popout[hidden] { + display: none; +} + +.cal-spim-popout.dragging { + user-select: none; + cursor: grabbing; +} + +.cal-spim-popout-header { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-elevated, var(--bg-card)); + border-bottom: 1px solid var(--border); + cursor: grab; + touch-action: none; +} + +.cal-spim-popout.dragging .cal-spim-popout-header { + cursor: grabbing; +} + +.cal-spim-popout-led { + width: 8px; + height: 8px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 6px rgba(74, 222, 128, 0.7); + animation: cal-spim-led-blink 1.6s ease-in-out infinite; +} + +.cal-spim-popout-led.idle { + background: var(--text-muted, #666); + box-shadow: none; + animation: none; +} + +.cal-spim-popout-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--text); +} + +.cal-spim-popout-embryo { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; + color: var(--text-muted); +} + +.cal-spim-popout-spacer { + flex: 1; +} + +.cal-spim-popout-close { + background: transparent; + border: 0; + color: var(--text-muted); + font-size: 20px; + line-height: 1; + padding: 0 6px; + cursor: pointer; + border-radius: 4px; +} + +.cal-spim-popout-close:hover { + color: var(--text); + background: var(--border); +} + +.cal-spim-popout-body { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + background: #000; + overflow: hidden; + padding: 4px; +} + +.cal-spim-popout-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + display: none; +} + +.cal-spim-popout-img.has-frame { + display: block; +} + +.cal-spim-popout-placeholder { + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.4px; +} + +.cal-spim-popout-placeholder[hidden] { + display: none; +} + +.cal-spim-popout-footer { + flex: 0 0 auto; + padding: 6px 12px; + border-top: 1px solid var(--border); + background: var(--bg-elevated, var(--bg-card)); + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* When a live frame is active, let the SPIM cell breathe a bit so the diff --git a/gently/ui/web/static/js/gallery.js b/gently/ui/web/static/js/gallery.js index 51ee4207..42159d18 100644 --- a/gently/ui/web/static/js/gallery.js +++ b/gently/ui/web/static/js/gallery.js @@ -422,13 +422,19 @@ const CalibrationProfileView = { /** Compact SPIM live indicator used inside the metrics strip. * Carries the same IDs as the old big preview so SpimLivePreview's - * apply-on-render logic continues to work unchanged. */ + * apply-on-render logic continues to work unchanged. The thumb is a + * button — click to open the floating popout for a larger view. */ _renderSpimIndicator() { return `
SPIM - +
@@ -1329,21 +1335,33 @@ const SpimLivePreview = { const placeholder = document.getElementById('cal-spim-placeholder'); const metaEl = document.getElementById('cal-spim-meta'); const led = document.getElementById('cal-spim-led'); - if (!img) return; // not in profile view const latest = embryoId ? this._latestByEmbryo[embryoId] : null; - if (latest) { - img.src = `data:image/png;base64,${latest.base64_png}`; - img.classList.add('has-frame'); - if (placeholder) placeholder.hidden = true; - if (metaEl) metaEl.textContent = this._formatMeta(latest); - if (led) led.classList.remove('idle'); - } else { - img.removeAttribute('src'); - img.classList.remove('has-frame'); - if (placeholder) placeholder.hidden = false; - if (metaEl) metaEl.textContent = '—'; - if (led) led.classList.add('idle'); + + if (img) { + if (latest) { + img.src = `data:image/png;base64,${latest.base64_png}`; + img.classList.add('has-frame'); + if (placeholder) placeholder.hidden = true; + if (metaEl) metaEl.textContent = this._formatMeta(latest); + if (led) led.classList.remove('idle'); + } else { + img.removeAttribute('src'); + img.classList.remove('has-frame'); + if (placeholder) placeholder.hidden = false; + if (metaEl) metaEl.textContent = '—'; + if (led) led.classList.add('idle'); + } + } + + // Mirror into popout if it's open — the popout lives outside the + // calibration panel's innerHTML reset, so we paint it independently. + if (typeof SpimPopout !== 'undefined') { + SpimPopout.paint(latest ? { + base64_png: latest.base64_png, + meta: this._formatMeta(latest), + embryoId, + } : null); } }, @@ -1375,6 +1393,227 @@ const SpimLivePreview = { document.addEventListener('DOMContentLoaded', () => SpimLivePreview.init()); +// ========================================== +// SPIM live popout (floating draggable window) +// ========================================== +// Lazy-built floating window that mirrors SpimLivePreview at a larger +// size. Draggable via the header bar, resizable from the bottom-right +// corner. Position and size persist in localStorage so the window +// re-opens where the operator last left it. Closes on Escape. +const SpimPopout = { + _STORAGE_KEY: 'gently.spimPopout.v1', + _root: null, + _isOpen: false, + + _ensureBuilt() { + if (this._root) return this._root; + + const el = document.createElement('div'); + el.className = 'cal-spim-popout'; + el.id = 'cal-spim-popout'; + el.hidden = true; + el.innerHTML = ` +
+ + SPIM Live + + + +
+
+ +
+ Awaiting SPIM frame… +
+
+ + `; + document.body.appendChild(el); + this._root = el; + + // Restore persisted geometry + const saved = this._loadGeometry(); + if (saved) { + el.style.left = `${saved.left}px`; + el.style.top = `${saved.top}px`; + el.style.width = `${saved.width}px`; + el.style.height = `${saved.height}px`; + } + + el.querySelector('#cal-spim-popout-close').addEventListener('click', () => this.close()); + this._wireDrag(el); + this._wireResizeObserver(el); + + return el; + }, + + open() { + const el = this._ensureBuilt(); + if (this._isOpen) return; + el.hidden = false; + this._isOpen = true; + + // Clamp into viewport in case window was resized while popout was hidden + this._clampIntoViewport(el); + + // Paint current frame for the selected embryo + const selected = (typeof CalibrationManager !== 'undefined') + ? CalibrationManager.selectedEmbryoId : null; + if (selected && typeof SpimLivePreview !== 'undefined') { + const latest = SpimLivePreview._latestByEmbryo[selected]; + this.paint(latest ? { + base64_png: latest.base64_png, + meta: SpimLivePreview._formatMeta(latest), + embryoId: selected, + } : null); + } else { + this.paint(null); + } + + document.addEventListener('keydown', this._onKey); + }, + + close() { + if (!this._root || !this._isOpen) return; + this._root.hidden = true; + this._isOpen = false; + document.removeEventListener('keydown', this._onKey); + }, + + toggle() { + this._isOpen ? this.close() : this.open(); + }, + + /** Called by SpimLivePreview whenever the current embryo's latest + * frame changes. Frame is {base64_png, meta, embryoId} or null. */ + paint(frame) { + if (!this._root || !this._isOpen) return; + const img = this._root.querySelector('#cal-spim-popout-img'); + const placeholder = this._root.querySelector('#cal-spim-popout-placeholder'); + const meta = this._root.querySelector('#cal-spim-popout-meta'); + const embryoEl = this._root.querySelector('#cal-spim-popout-embryo'); + const led = this._root.querySelector('#cal-spim-popout-led'); + + if (frame) { + img.src = `data:image/png;base64,${frame.base64_png}`; + img.classList.add('has-frame'); + placeholder.hidden = true; + meta.textContent = frame.meta || '—'; + embryoEl.textContent = frame.embryoId || ''; + led.classList.remove('idle'); + } else { + img.removeAttribute('src'); + img.classList.remove('has-frame'); + placeholder.hidden = false; + meta.textContent = '—'; + embryoEl.textContent = ''; + led.classList.add('idle'); + } + }, + + _onKey: (e) => { + if (e.key === 'Escape') SpimPopout.close(); + }, + + _wireDrag(el) { + const header = el.querySelector('#cal-spim-popout-header'); + let dragging = false; + let startX = 0, startY = 0, startLeft = 0, startTop = 0; + + header.addEventListener('pointerdown', (e) => { + // Don't start drag on the close button + if (e.target.closest('.cal-spim-popout-close')) return; + dragging = true; + const rect = el.getBoundingClientRect(); + startX = e.clientX; + startY = e.clientY; + startLeft = rect.left; + startTop = rect.top; + // Switch to absolute positioning if currently default + el.style.left = `${startLeft}px`; + el.style.top = `${startTop}px`; + el.style.right = 'auto'; + el.style.bottom = 'auto'; + header.setPointerCapture(e.pointerId); + el.classList.add('dragging'); + }); + + header.addEventListener('pointermove', (e) => { + if (!dragging) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + let nextLeft = startLeft + dx; + let nextTop = startTop + dy; + // Keep at least 40px of header on-screen + const w = el.offsetWidth; + const h = el.offsetHeight; + nextLeft = Math.max(-(w - 80), Math.min(window.innerWidth - 80, nextLeft)); + nextTop = Math.max(0, Math.min(window.innerHeight - 40, nextTop)); + el.style.left = `${nextLeft}px`; + el.style.top = `${nextTop}px`; + }); + + const endDrag = (e) => { + if (!dragging) return; + dragging = false; + el.classList.remove('dragging'); + try { header.releasePointerCapture(e.pointerId); } catch (_) {} + this._saveGeometry(el); + }; + header.addEventListener('pointerup', endDrag); + header.addEventListener('pointercancel', endDrag); + }, + + _wireResizeObserver(el) { + if (typeof ResizeObserver === 'undefined') return; + let saveTimer = null; + const ro = new ResizeObserver(() => { + if (!this._isOpen) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => this._saveGeometry(el), 250); + }); + ro.observe(el); + }, + + _clampIntoViewport(el) { + const rect = el.getBoundingClientRect(); + if (rect.left + 80 > window.innerWidth || rect.top + 40 > window.innerHeight + || rect.left < -(rect.width - 80) || rect.top < 0) { + // Recenter + const w = Math.min(rect.width || 520, window.innerWidth - 40); + const h = Math.min(rect.height || 440, window.innerHeight - 40); + el.style.width = `${w}px`; + el.style.height = `${h}px`; + el.style.left = `${Math.max(20, (window.innerWidth - w) / 2)}px`; + el.style.top = `${Math.max(20, (window.innerHeight - h) / 2)}px`; + } + }, + + _saveGeometry(el) { + const rect = el.getBoundingClientRect(); + const data = { + left: Math.round(rect.left), + top: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + try { localStorage.setItem(this._STORAGE_KEY, JSON.stringify(data)); } catch (_) {} + }, + + _loadGeometry() { + try { + const raw = localStorage.getItem(this._STORAGE_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + if (typeof data.left !== 'number') return null; + return data; + } catch (_) { return null; } + }, +}; + // Legacy wrappers kept for backward compatibility function renderCalibrationGallery() { CalibrationManager.render(); } From 7f241e1b1ececf56d8ded2332579789ae4f7315e Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 14:30:04 -0400 Subject: [PATCH 018/314] Embryos > Board: clock / stereo / pace columns replace confidence + rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board's metric columns are restructured around developmental time, the actual question this view answers ("is this embryo on pace?"): - 'clock' — elapsed wall-clock time in the current stage - 'stereo' — stereotypic developmental position at 20 °C reference - 'pace' — clock / stereo ratio; 1.0× means on reference pace These replace 'confidence' (never populated meaningfully) and 'rate' (misleading for slow embryos). 'eta' is now hatch-time, pace-corrected. Migration: dashboardConfig loaded from localStorage runs an idempotent filter that drops 'confidence' / 'rate' from the saved column list and inserts the three new columns in the right slots. Existing user configs upgrade silently on next load. Co-Authored-By: Claude Opus 4.7 (1M context) --- gently/ui/web/static/css/main.css | 18 ++- gently/ui/web/static/js/embryos.js | 209 ++++++++++++++++++++++------- 2 files changed, 176 insertions(+), 51 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 4772dad7..ea8dd142 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -3607,12 +3607,24 @@ kbd { .board-col { padding: 0 0.5rem; } .board-col-embryo { width: 100px; flex-shrink: 0; } .board-col-stage { width: 130px; flex-shrink: 0; } -.board-col-conf { width: 60px; flex-shrink: 0; text-align: center; } -.board-col-rate { width: 70px; flex-shrink: 0; text-align: center; } -.board-col-eta { width: 70px; flex-shrink: 0; text-align: center; } +.board-col-clock { width: 72px; flex-shrink: 0; text-align: right; font-variant-numeric: tabular-nums; } +.board-col-stereo { width: 140px; flex-shrink: 0; font-variant-numeric: tabular-nums; } +.board-col-pace { width: 90px; flex-shrink: 0; text-align: center; font-variant-numeric: tabular-nums; } +.board-col-eta { width: 70px; flex-shrink: 0; text-align: right; font-variant-numeric: tabular-nums; } .board-col-spark { flex: 1; min-width: 100px; } .board-col-alert { width: 110px; flex-shrink: 0; text-align: right; } +/* Pace cell coloring — green when on reference, orange when slow, + red when seriously slow. Class names mirror _formatPace(). */ +.board-col-pace.pace-unknown { color: var(--text-muted); } +.board-col-pace.pace-normal { color: var(--accent-green, #4ade80); } +.board-col-pace.pace-slow { color: #fb923c; } +.board-col-pace.pace-slow-bad { color: #f87171; font-weight: 600; } + +/* Subtle overdue mark in the stereo cell when clock has run past the + expected stage duration. */ +.stereo-overdue { color: #fb923c; margin-left: 4px; } + .board-rows { flex: 1; } .board-row { diff --git a/gently/ui/web/static/js/embryos.js b/gently/ui/web/static/js/embryos.js index f8b7cea3..29340cf5 100644 --- a/gently/ui/web/static/js/embryos.js +++ b/gently/ui/web/static/js/embryos.js @@ -59,7 +59,7 @@ const EmbryosManager = { dashboardConfig: { defaultView: 'default', board: { - columns: ['stage', 'confidence', 'rate', 'eta', 'sparkline', 'alert'], + columns: ['stage', 'clock', 'stereo', 'pace', 'eta', 'sparkline', 'alert'], sparklineLength: 20, warnOvertimeRatio: 1.5, criticalOvertimeRatio: 2.5 @@ -266,6 +266,23 @@ const EmbryosManager = { // Deep merge with defaults this.dashboardConfig = this._deepMerge(this.dashboardConfig, parsed); } + // Migrate legacy board columns: drop the never-populated + // 'confidence' column and the misleading 'rate' column in + // favour of clock/stereo/pace. Idempotent — runs on every load. + const cols = this.dashboardConfig.board?.columns; + if (Array.isArray(cols)) { + const filtered = cols.filter(c => c !== 'confidence' && c !== 'rate'); + const ensure = (key, after) => { + if (filtered.includes(key)) return; + const idx = filtered.indexOf(after); + if (idx === -1) filtered.push(key); + else filtered.splice(idx + 1, 0, key); + }; + ensure('clock', 'stage'); + ensure('stereo', 'clock'); + ensure('pace', 'stereo'); + this.dashboardConfig.board.columns = filtered; + } } catch (e) { console.warn('Failed to load dashboard config:', e); } @@ -370,9 +387,10 @@ const EmbryosManager = {
Embryo ${cols.includes('stage') ? 'Stage' : ''} - ${cols.includes('confidence') ? 'Conf' : ''} - ${cols.includes('rate') ? 'Rate' : ''} - ${cols.includes('eta') ? 'ETA' : ''} + ${cols.includes('clock') ? 'Clock' : ''} + ${cols.includes('stereo') ? 'Stereo' : ''} + ${cols.includes('pace') ? 'Pace' : ''} + ${cols.includes('eta') ? 'ETA' : ''} ${cols.includes('sparkline') ? 'Progression' : ''} ${cols.includes('alert') ? 'Alert' : ''}
@@ -412,54 +430,27 @@ const EmbryosManager = { const latest = reasoning.length > 0 ? reasoning[reasoning.length - 1] : null; const cols = this.dashboardConfig.board.columns; - // Stage const stage = latest?.stage || embryo.current_stage || '—'; const stageIcon = this.getStageIcon(stage); const stageName = this.formatStageName(stage); - // Confidence - const conf = latest ? this.normalizeConfidence(latest.confidence) : 'unknown'; - const confDots = conf === 'high' ? '●●●' : conf === 'medium' ? '●●○' : conf === 'low' ? '●○○' : '○○○'; - const confClass = conf === 'high' ? 'conf-high' : conf === 'medium' ? 'conf-med' : 'conf-low'; + const align = this._computeAlignment(latest); + const overtime = align?.overtime; - // Rate - const overtime = latest?.temporal_analysis?.overtime_ratio; - let rateText = '—'; - let rateClass = ''; - if (overtime != null) { - const rate = (1 / overtime).toFixed(1); - rateText = overtime < 0.9 ? `${rate}x↑` : overtime > 1.1 ? `${rate}x↓` : `${rate}x→`; - rateClass = overtime < 0.9 ? 'rate-fast' : overtime > 1.5 ? 'rate-slow' : 'rate-normal'; - } + const clockText = align ? this._formatMinutes(align.inStageClockMin) : '—'; + const stereoText = align ? this._formatStereoLabel(align) : '—'; + const pace = align ? this._formatPace(align) : { text: '—', className: '' }; + const eta = align ? this._formatEta(align) : '—'; - // ETA - let eta = '—'; - if (stage && this.STAGE_TIMING[stage] != null) { - const stageMinutes = this.STAGE_TIMING[stage]; - const hatchMinutes = this.STAGE_TIMING['hatched'] || 570; - const remaining = hatchMinutes - stageMinutes; - if (remaining > 0) { - const hours = (remaining / 60).toFixed(1); - eta = `~${hours}h`; - } else { - eta = 'done'; - } - } - - // Sparkline const sparklineSvg = cols.includes('sparkline') ? this._renderBoardSparkline(reasoning) : ''; - // Alert const arrested = latest?.temporal_analysis?.is_potentially_arrested; const slow = overtime && overtime > (this.dashboardConfig.board.warnOvertimeRatio || 1.5); - const lowConf = conf === 'low'; let alertHtml = ''; if (arrested) { alertHtml = '⚠ arrested'; } else if (slow) { - alertHtml = `⚠ slow ${overtime.toFixed(1)}x`; - } else if (lowConf) { - alertHtml = '⚠ low conf'; + alertHtml = `⚠ slow ${overtime.toFixed(1)}×`; } const status = embryo.isComplete ? 'complete' : embryo.lastError ? 'error' : 'running'; @@ -472,8 +463,9 @@ const EmbryosManager = { ${embryo.embryoId.replace(/embryo_?/i, 'E')} ${cols.includes('stage') ? `${stageIcon} ${stageName}` : ''} - ${cols.includes('confidence') ? `${confDots}` : ''} - ${cols.includes('rate') ? `${rateText}` : ''} + ${cols.includes('clock') ? `${clockText}` : ''} + ${cols.includes('stereo') ? `${stereoText}` : ''} + ${cols.includes('pace') ? `${pace.text}` : ''} ${cols.includes('eta') ? `${eta}` : ''} ${cols.includes('sparkline') ? `${sparklineSvg}` : ''} ${cols.includes('alert') ? `${alertHtml}` : ''} @@ -481,6 +473,99 @@ const EmbryosManager = { `; }, + /** Compute clock↔stereotypic alignment from perception temporal_analysis. + * + * Definitions: + * inStageClockMin — wall-clock minutes elapsed in current stage + * inStageStereoMin — stereotypic minutes "used" within the stage, + * capped at the stage's expected duration. An + * overdue embryo is stuck at the stage end in + * stereo time while clock keeps ticking. + * overtime — ratio inStageClockMin / expected_duration. + * >1 means the embryo has spent more clock time + * in the stage than the reference 20°C textbook + * duration. <1 just means "still within stage" — + * no slow/fast signal yet. + * stereoAgeMin — total stereotypic age, anchored at the start + * minute of the current stage in the reference + * table plus the (capped) in-stage stereo offset. + */ + _computeAlignment(latest) { + const ta = latest?.temporal_analysis; + if (!ta || !ta.current_stage) return null; + const stage = ta.current_stage; + const stageStart = this.STAGE_TIMING[stage]; + if (stageStart == null) return null; + + const expDur = Number(ta.expected_duration_min) || 0; + const inClock = Number(ta.time_in_stage_min) || 0; + const overtime = Number(ta.overtime_ratio) || 0; + + const inStereo = expDur > 0 ? Math.min(inClock, expDur) : inClock; + const stereoAge = stageStart + inStereo; + + return { + stage, + stageStart, + expDur, + inStageClockMin: inClock, + inStageStereoMin: inStereo, + stereoAgeMin: stereoAge, + overtime, + }; + }, + + /** Render the stereo cell: "≈early", "≈bean +12m", or "≈comma +88m ⚠" + * when overdue (stereo capped at stage end while clock keeps running). */ + _formatStereoLabel(align) { + const stageName = this.formatStageName(align.stage); + const offsetMin = Math.round(align.inStageStereoMin); + const overdue = align.expDur > 0 && align.inStageClockMin > align.expDur + 1; + const offsetStr = offsetMin > 0 ? ` +${offsetMin}m` : ''; + const overdueMark = overdue ? ' ' : ''; + return `≈${stageName}${offsetStr}${overdueMark}`; + }, + + _formatPace(align) { + // Only emit a pace signal once we have meaningful clock data. + // Within the first few minutes the ratio is tiny and noisy — show + // a dashed placeholder so the column doesn't lie about precision. + const NORMAL_BAND = 1.05; + const SLOW_BAND = 1.5; + if (align.inStageClockMin < 1 || align.expDur <= 0) { + return { text: '—', className: 'pace-unknown' }; + } + const r = align.overtime; + if (r <= NORMAL_BAND) { + return { text: '1.0×', className: 'pace-normal' }; + } + if (r <= SLOW_BAND) { + return { text: `${r.toFixed(1)}× slow`, className: 'pace-slow' }; + } + return { text: `⚠ ${r.toFixed(1)}×`, className: 'pace-slow-bad' }; + }, + + /** ETA in hours from current stereotypic position to hatched, scaled + * by observed pace when the embryo is demonstrably slow. */ + _formatEta(align) { + const hatchStereo = this.STAGE_TIMING['hatched'] || 570; + const remainStereo = hatchStereo - align.stereoAgeMin; + if (remainStereo <= 0) return 'done'; + const paceFactor = align.overtime > 1.05 ? align.overtime : 1.0; + const remainClockMin = remainStereo * paceFactor; + return `~${(remainClockMin / 60).toFixed(1)}h`; + }, + + /** Compact minute formatter: "45s" / "10m" / "1h 22m" / "3h". */ + _formatMinutes(min) { + if (min == null || !isFinite(min)) return '—'; + if (min < 1) return `${Math.round(min * 60)}s`; + if (min < 60) return `${Math.round(min)}m`; + const h = Math.floor(min / 60); + const m = Math.round(min - h * 60); + return m > 0 ? `${h}h ${m}m` : `${h}h`; + }, + _renderBoardSparkline(reasoning) { if (!reasoning.length) return ''; const sorted = [...reasoning].sort((a, b) => (a.timepoint ?? 0) - (b.timepoint ?? 0)); @@ -565,12 +650,24 @@ const EmbryosManager = { const shortName = embryo.embryoId.replace(/embryo_?/i, 'E'); const latestStage = sorted.length > 0 ? this.formatStageName(sorted[sorted.length - 1].stage) : '—'; + const isTerminated = !!embryo.isComplete; + const termReason = embryo.completionReason || ''; + // Short label for the badge — humanise the no_object terminal + // reason, otherwise keep the first clause of whatever the + // backend sent so the user still gets a hint. + const termBadge = isTerminated + ? (termReason.includes('no_object') ? 'HATCHED?' : 'STOPPED') + : ''; + const termTooltip = isTerminated + ? `Terminated — ${termReason || 'no reason given'}` + : ''; - html += `
`; + html += `
`; html += `
${shortName} ${latestStage} ${reasoning.length} eval + ${isTerminated ? `${termBadge}` : ''}
`; html += `
`; @@ -1000,7 +1097,7 @@ const EmbryosManager = { intervalSeconds: embryoData.interval_seconds || this.state.baseInterval, timepoints: embryoData.timepoints || 0, isComplete: embryoData.is_complete || false, - completionReason: null, + completionReason: embryoData.completion_reason || null, firstAcquired: embryoData.first_acquired ? new Date(embryoData.first_acquired) : null, lastAcquired: embryoData.last_acquired ? new Date(embryoData.last_acquired) : null, detections: embryoData.detections || {}, @@ -2726,10 +2823,17 @@ const EmbryosManager = { `; } - // Format confidence display - const confDisplay = typeof item.confidence === 'number' - ? `${Math.round(item.confidence * 100)}%` - : (item.confidence || 'Unknown'); + // Format confidence display. Hide entirely when the detector + // doesn't emit a probabilistic confidence (e.g. dopaminergic_signal + // returns structured intensity/structure findings instead) — the + // string "Unknown confidence" was actively confusing. + const hasNumericConf = typeof item.confidence === 'number'; + const hasTextConf = typeof item.confidence === 'string' && item.confidence.trim() !== ''; + const confHtml = hasNumericConf + ? `${Math.round(item.confidence * 100)}% confidence` + : hasTextConf + ? `${item.confidence}` + : ''; return `
@@ -2748,7 +2852,7 @@ const EmbryosManager = {
${item.stage ? this.formatStageName(item.stage) : (item.detected ? 'DETECTED' : 'Not detected')} - ${confDisplay} confidence + ${confHtml} ${transitionalHtml}
${detectorFindingsHtml} @@ -2979,13 +3083,22 @@ const EmbryosManager = { container.classList.remove('visible'); container.innerHTML = ''; } + // Filmstrip side panel — clearing innerHTML lets the :empty CSS + // rule collapse the panel and let the rows reclaim full width. + const filmstripDetail = document.getElementById('filmstrip-detail'); + if (filmstripDetail) { + filmstripDetail.innerHTML = ''; + } this.detailPanelVisible = false; this.currentDetailItem = null; - // Clear eval dot highlight + // Clear eval dot + filmstrip cell highlight document.querySelectorAll('.eval-dot.active').forEach(dot => { dot.classList.remove('active'); }); + document.querySelectorAll('.filmstrip-cell.active').forEach(cell => { + cell.classList.remove('active'); + }); }, // Navigate to previous/next item in detail panel From cbf41404846297a2b208155320ae7745b5c49e04 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 14:30:28 -0400 Subject: [PATCH 019/314] Bump version to 0.21.0.dev0 Opens the v0.21 development cycle on this branch. Targets per the KANBAN roadmap: cross-session resume, sacrificial vocab alias, campaign template loader (Path B), LDM Phase 1 MVP. Co-Authored-By: Claude Opus 4.7 (1M context) --- gently/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gently/__init__.py b/gently/__init__.py index be9985bb..37aee420 100644 --- a/gently/__init__.py +++ b/gently/__init__.py @@ -76,7 +76,7 @@ except ImportError: _VISUALIZATION_AVAILABLE = False -__version__ = "0.20.0" +__version__ = "0.21.0.dev0" __all__ = [ # Main entry point "Gently", diff --git a/pyproject.toml b/pyproject.toml index d07b6c42..2395f90e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gently" -version = "0.20.0" +version = "0.21.0.dev0" description = "Agentic harnessing for microscopy" readme = "docs/README.md" license = {text = "MIT"} From 63c5be0ee6eb2a8c52732f709db507ed5190e179 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 18:16:30 -0400 Subject: [PATCH 020/314] switchbot: accept newer 0x05 status-frame response as success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modern SwitchBot Bot firmware (≥ v4.x) replies to press/on/off with a 3-byte status frame: 0x05 + battery% + flag bits. Older firmware returned the bare 0x01 success byte. The strict 0x01-only check raised SwitchBotError for any current-production Bot even though the press had landed (visible on the controlled load). Widen _RESP_OK to accept either prefix. Both indicate the command reached the actuator. Co-Authored-By: Claude Opus 4.7 (1M context) --- gently/hardware/switchbot.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gently/hardware/switchbot.py b/gently/hardware/switchbot.py index 131c50ef..713a785f 100644 --- a/gently/hardware/switchbot.py +++ b/gently/hardware/switchbot.py @@ -40,7 +40,10 @@ "off": bytes([0x57, 0x01, 0x02]), "press": bytes([0x57, 0x01, 0x00]), } -_RESP_OK = 0x01 # first byte of the response notification on success +# First byte of the success response. Older Bot firmware returns 0x01 alone; +# modern firmware (≥ Bot v4.x) returns 0x05 followed by a status frame — +# byte 1 = battery %, byte 2 = flag bits. Both mean "press landed." +_RESP_OK = (0x01, 0x05) class SwitchBotError(RuntimeError): @@ -75,7 +78,7 @@ def _on_notify(_char, data: bytearray) -> None: pass data = response["data"] - if not data or data[0] != _RESP_OK: + if not data or data[0] not in _RESP_OK: raise SwitchBotError(f"SwitchBot returned non-OK response: {data.hex()}") return data From b6bd49ce21b4c9757576ce637615b6f08346be27 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 18:16:31 -0400 Subject: [PATCH 021/314] config: register room_light SwitchBot for the diSPIM rig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the BLE-attached SwitchBot Bot that toggles the room light into the device-layer config so DeviceLayerServer registers it on boot. Plans address it via `bps.mv(room_light, 'on')`. MAC is the bot already mounted on the rig. Reached over BLE via the TP-Link UB500 dongle on this desktop — RSSI -70 dBm, well within reliable range. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/config.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/config/config.yml b/config/config.yml index e59e9f6d..086de3f1 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,4 +1,13 @@ organism: "celegans" hardware: "dispim" mmconfig: "MMConfig_tracking_screening.cfg" -mmdirectory: "C:/Program Files/Micro-Manager-1.4" \ No newline at end of file +mmdirectory: "C:/Program Files/Micro-Manager-1.4" + +# SwitchBot Bot — physical button-pusher mounted on the diSPIM room light +# switch. Talks BLE direct (no SwitchBot Hub / cloud). Plans address it by +# name, e.g. `bps.mv(room_light, 'on')`. Remove this block to skip +# registration; the device layer is tolerant of either state. +switchbot: + name: room_light + address: "EC:6F:04:06:5B:23" + timeout: 20.0 \ No newline at end of file From 0158004e94d2b2e2e75cf182d13a6db3e498bf91 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 18:25:12 -0400 Subject: [PATCH 022/314] =?UTF-8?q?switchbot:=20read=5Fstatus()=20?= =?UTF-8?q?=E2=80=94=20battery=20/=20firmware=20via=200x57=200x02=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated status query (BLE 0x57 0x02) that returns battery percentage and firmware version without touching the actuator. Result is cached on the device instance and surfaced through read() / describe() as `_battery_pct` and `_firmware`, so the device-state stream picks them up automatically once polled. Verified on a Bot v4.2 over the TP-Link UB500: response `01 64 42 64 00 00 00 66 00 10 00 00 00` parses as battery 100%, firmware 0x42 (v4.2). Importantly, action-command responses are NOT used as a battery source — their byte-1 field looks like battery (an empirically 0x48-shaped value) but isn't: the dedicated query on the same bot reads 100%, so byte 1 of an action response is some other firmware-internal counter. Documented inline so the next reverse-engineer doesn't fall into the same trap. Periodic polling cadence is left to the caller; hourly is plenty for a battery that moves over months. Co-Authored-By: Claude Opus 4.7 (1M context) --- gently/hardware/switchbot.py | 72 +++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/gently/hardware/switchbot.py b/gently/hardware/switchbot.py index 713a785f..6a165054 100644 --- a/gently/hardware/switchbot.py +++ b/gently/hardware/switchbot.py @@ -40,9 +40,22 @@ "off": bytes([0x57, 0x01, 0x02]), "press": bytes([0x57, 0x01, 0x00]), } +# Dedicated status query: returns battery %, firmware version, mode flags. +# This is the only reliable source of battery — action-command responses +# also include status bytes but in a different format (byte 1 there isn't +# battery despite what's documented for older firmware). +_QUERY_STATUS = bytes([0x57, 0x02]) +# Status-query response format (firmware ≥ 6.x): +# byte 0 = 0x01 success +# byte 1 = battery % +# byte 2 = firmware version (BCD-ish: high nibble.low nibble — 0x42 = v4.2) +# bytes 3+ = mode flags / timer count / counters (firmware-dependent) +_STATUS_BATTERY_IDX = 1 +_STATUS_FIRMWARE_IDX = 2 # First byte of the success response. Older Bot firmware returns 0x01 alone; -# modern firmware (≥ Bot v4.x) returns 0x05 followed by a status frame — -# byte 1 = battery %, byte 2 = flag bits. Both mean "press landed." +# modern firmware (≥ Bot v4.x) returns 0x05 for action commands followed by +# action-status bytes. Both are "command landed" — the action payload format +# differs from the status-query payload format, so don't reuse parsers. _RESP_OK = (0x01, 0x05) @@ -104,6 +117,12 @@ def __init__(self, address: str, name: str = "switchbot", *, timeout: float = 20 self.timeout = timeout self.parent = None # required for Bluesky bps.mv() self._state = "unknown" # last commanded on/off state + # Status fields populated only by read_status(). Left as None until + # first contact — action commands deliberately don't update these, + # see note on _STATUS_BATTERY_IDX above. + self._battery_pct: int | None = None + self._firmware: int | None = None + self._status_ts: float | None = None self._lock = threading.Lock() # serialize BLE access (one radio, one bot) # -- Bluesky settable protocol ------------------------------------------- @@ -135,15 +154,58 @@ def worker(): threading.Thread(target=worker, name=f"{self.name}-set", daemon=True).start() return status + # -- Dedicated status query (no actuation) ------------------------------- + def read_status(self) -> dict: + """Query battery / firmware / mode without touching the switch arm. + + Synchronous: runs its own BLE connect → query → disconnect on the + caller's thread. Updates the cached status fields on success so + read() surfaces fresh values to the device-state stream. Use this + for periodic polls (~hourly is fine; battery doesn't move quickly). + + Returns a dict ``{battery_pct, firmware, raw_hex}``; raises + SwitchBotError on BLE / protocol failure. + """ + with self._lock: + data = asyncio.run( + _send_command(self.address, _QUERY_STATUS, self.timeout) + ) + info = { + "raw_hex": data.hex(), + "battery_pct": data[_STATUS_BATTERY_IDX] if len(data) > _STATUS_BATTERY_IDX else None, + "firmware": data[_STATUS_FIRMWARE_IDX] if len(data) > _STATUS_FIRMWARE_IDX else None, + } + if info["battery_pct"] is not None: + self._battery_pct = info["battery_pct"] + if info["firmware"] is not None: + self._firmware = info["firmware"] + self._status_ts = time.time() + logger.info("SwitchBot %s status: %s", self.name, info) + return info + # -- Bluesky readable protocol ------------------------------------------- def read(self): - return OrderedDict({ - self.name: {"value": self._state, "timestamp": time.time()} + ts = time.time() + out = OrderedDict({ + self.name: {"value": self._state, "timestamp": ts} }) + if self._battery_pct is not None: + out[f"{self.name}_battery_pct"] = { + "value": self._battery_pct, + "timestamp": self._status_ts or ts, + } + if self._firmware is not None: + out[f"{self.name}_firmware"] = { + "value": self._firmware, + "timestamp": self._status_ts or ts, + } + return out def describe(self): return OrderedDict({ - self.name: {"source": f"switchbot:{self.address}", "dtype": "string", "shape": []} + self.name: {"source": f"switchbot:{self.address}", "dtype": "string", "shape": []}, + f"{self.name}_battery_pct": {"source": f"switchbot:{self.address}", "dtype": "integer", "shape": []}, + f"{self.name}_firmware": {"source": f"switchbot:{self.address}", "dtype": "integer", "shape": []}, }) def read_configuration(self): From a2a82bca0bf92df1082310e8c3624c136252fbc5 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 23:32:34 -0400 Subject: [PATCH 023/314] feat(web): add agent chat window + control lock; retire TUI launch Begin the TUI->web convergence: - Floating agent-chat window in the web UI (agent-chat.js/.css, wired into index.html) connecting to /ws/agent: streaming text/thinking/tool calls, choice pickers, applied-spec cards, slash-command routing. All untrusted text is escaped before insertion. - Single-driver control lock in agent_ws.py: only the holder may drive the agent; other clients are observers with a "Take control" banner. Fixes the latent shared-conversation corruption when >1 client connects. - launch_gently.py no longer spawns the Node TUI. It starts the agent + viz server, prints a launch banner (URL, device status, storage, Ctrl-C), auto-opens the browser (--no-browser to suppress), and serves until interrupted. Removes the Node/dist requirement; --resume resolves to latest (interactive picking deferred to the browser). TUI source kept in-tree (reversible). Auth not yet added: the browser is now the only control path and is unauthenticated on the LAN. Bind to 127.0.0.1 or trust the LAN until self-managed accounts land. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/agent_ws.py | 96 ++++++- gently/ui/web/static/css/agent-chat.css | 233 ++++++++++++++++ gently/ui/web/static/js/agent-chat.js | 351 ++++++++++++++++++++++++ gently/ui/web/templates/index.html | 18 ++ launch_gently.py | 112 +++++--- 5 files changed, 763 insertions(+), 47 deletions(-) create mode 100644 gently/ui/web/static/css/agent-chat.css create mode 100644 gently/ui/web/static/js/agent-chat.js diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index df59aaeb..d9b8d5cb 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -10,7 +10,7 @@ import json import logging from datetime import datetime -from typing import Dict, Optional +from typing import Callable, Dict, Optional from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -31,6 +31,32 @@ def create_router(server) -> APIRouter: # Pending choice futures keyed by request_id _choice_futures: Dict[str, asyncio.Future] = {} + # ── Single-driver control arbitration ───────────────────── + # Shared across all /ws/agent clients (the router is created once). + # Only the control holder may drive the agent (chat/command/cancel); + # everyone else is an observer until they take control. This is the + # seed of the multi-user control lock and also prevents the shared + # agent conversation from being corrupted when >1 client connects. + _control: Dict[str, Optional[str]] = {"holder": None} + _clients: Dict[str, Callable] = {} + _client_labels: Dict[str, str] = {} + _client_counter = {"n": 0} + + async def _broadcast_control_status(): + """Tell every connected agent client who currently holds control.""" + holder = _control["holder"] + holder_label = _client_labels.get(holder) if holder else None + for cid, fn in list(_clients.items()): + try: + await fn({ + "type": "control_status", + "holder": holder, + "holder_label": holder_label, + "you_have_control": (cid == holder), + }) + except Exception: + pass + async def _run_wizard(wizard, websocket, send_fn, _choice_futures, bridge=None, log_transcript=None): """Run the wizard's interactive loop. @@ -136,6 +162,13 @@ async def agent_websocket(websocket: WebSocket): await websocket.close() return + # Assign a stable id for control arbitration. The first client to + # connect (in practice the TUI, spawned at launch) is labelled the + # terminal; later connections are browser windows. + _client_counter["n"] += 1 + client_id = f"agent_client_{_client_counter['n']}" + client_label = "the terminal" if _client_counter["n"] == 1 else "a browser window" + # Send connection metadata (version, tokens, embryo count, commands) meta = bridge.get_connect_metadata() _connected_msg = { @@ -409,6 +442,13 @@ def choice_future_factory(choice_data: dict) -> asyncio.Future: _choice_futures[request_id] = future return future + # Register this client for control arbitration; grant control if free. + _clients[client_id] = send_fn + _client_labels[client_id] = client_label + if _control["holder"] is None: + _control["holder"] = client_id + await _broadcast_control_status() + try: # ── Wizard phase ────────────────────────────────────── # Run startup wizard (if needed) before entering the REPL. @@ -497,6 +537,38 @@ async def _run_resolution_bootstrap(): _log_transcript("in", data) msg_type = data.get("type") + # ── Control arbitration ─────────────────────────── + # A client requesting the wheel. + if msg_type == "take_control": + prev = _control["holder"] + _control["holder"] = client_id + if prev and prev != client_id and prev in _clients: + try: + await _clients[prev]({ + "type": "notification", + "level": "warning", + "title": f"Control taken by {client_label}", + "body": "You are now viewing.", + }) + except Exception: + pass + await _broadcast_control_status() + continue + + # Only the holder may drive the agent. Observers are told + # to take control rather than silently corrupting the + # single shared conversation. + if msg_type in ("chat", "command", "cancel") and client_id != _control["holder"]: + holder_label = _client_labels.get(_control["holder"]) or "another client" + await send_fn({ + "type": "notification", + "level": "info", + "title": f"Viewing only — control is held by {holder_label}", + "body": "Take control to drive the microscope.", + }) + await _broadcast_control_status() + continue + if msg_type == "chat": text = data.get("text", "").strip() if not text: @@ -615,11 +687,23 @@ async def _run_resolution_bootstrap(): active_task.cancel() if bootstrap_task is not None and not bootstrap_task.done(): bootstrap_task.cancel() - # Clean up pending futures - for future in _choice_futures.values(): - if not future.done(): - future.cancel() - _choice_futures.clear() + # Release control arbitration for this client; hand the wheel + # to any remaining client (or free it) and resync everyone. + _clients.pop(client_id, None) + _client_labels.pop(client_id, None) + if _control["holder"] == client_id: + _control["holder"] = next(iter(_clients), None) + try: + await _broadcast_control_status() + except Exception: + pass + # Clean up pending futures only when the last client leaves — + # otherwise we'd cancel another connected client's pending choices. + if not _clients: + for future in _choice_futures.values(): + if not future.done(): + future.cancel() + _choice_futures.clear() return router diff --git a/gently/ui/web/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css new file mode 100644 index 00000000..99dbcf33 --- /dev/null +++ b/gently/ui/web/static/css/agent-chat.css @@ -0,0 +1,233 @@ +/* Floating agent-chat window — the web-side control surface. */ + +.agent-fab { + position: fixed; + right: 22px; + bottom: 22px; + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + cursor: pointer; + z-index: 1200; + font-size: 24px; + color: #fff; + background: var(--gradient-primary); + box-shadow: var(--shadow-glow-strong); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.agent-fab:hover { transform: translateY(-2px) scale(1.04); } +.agent-fab.ac-fab-active { transform: rotate(45deg); } + +.agent-chat { + position: fixed; + right: 22px; + bottom: 90px; + width: 400px; + max-width: calc(100vw - 44px); + height: 560px; + max-height: calc(100vh - 130px); + display: flex; + flex-direction: column; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45); + z-index: 1199; + overflow: hidden; +} +.agent-chat.hidden, .agent-control-banner.hidden { display: none; } + +.agent-chat-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--bg-hover); +} +.agent-chat-title { font-weight: 600; color: var(--text); flex: 0 0 auto; } +.agent-chat-conn { font-size: 11px; margin-left: 4px; color: var(--text-muted); } +.agent-chat-conn.ac-conn-ok { color: var(--accent-green); } +.agent-chat-conn.ac-conn-bad { color: var(--color-danger, #f87171); } +.agent-chat-close { + margin-left: auto; + background: none; + border: none; + color: var(--text-muted); + font-size: 22px; + line-height: 1; + cursor: pointer; +} +.agent-chat-close:hover { color: var(--text); } + +.agent-control-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + background: rgba(251, 146, 60, 0.12); + border-bottom: 1px solid var(--border); + color: var(--accent-orange, #fb923c); + font-size: 13px; +} +.ac-take-control { + margin-left: auto; + padding: 4px 12px; + border-radius: 8px; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} +.ac-take-control:hover { background: var(--accent-hover); } + +.agent-chat-log { + flex: 1 1 auto; + overflow-y: auto; + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 14px; + line-height: 1.5; +} + +.ac-msg { + padding: 8px 12px; + border-radius: 12px; + max-width: 90%; + word-wrap: break-word; + white-space: normal; +} +.ac-user { + align-self: flex-end; + background: var(--accent); + color: #fff; + border-bottom-right-radius: 4px; +} +.ac-agent { + align-self: flex-start; + background: var(--bg-hover); + color: var(--text); + border-bottom-left-radius: 4px; +} +.ac-agent code, .ac-agent strong { color: var(--accent-cyan); } +.ac-agent code { + background: rgba(0, 0, 0, 0.25); + padding: 1px 5px; + border-radius: 4px; + font-size: 12.5px; +} + +.ac-tool { + align-self: flex-start; + font-size: 12.5px; + color: var(--text-muted); + padding: 3px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); +} +.ac-tool-done .ac-tool-check { color: var(--accent-green); } +.ac-tool-spin { display: inline-block; animation: ac-spin 1.4s linear infinite; } +@keyframes ac-spin { to { transform: rotate(360deg); } } + +.ac-thinking { + align-self: flex-start; + font-size: 12.5px; + color: var(--text-muted); + font-style: italic; + opacity: 0.8; +} + +.ac-system { + align-self: center; + font-size: 12px; + color: var(--text-muted); + text-align: center; + padding: 2px 8px; +} +.ac-level-error { color: var(--color-danger, #f87171); } +.ac-level-warning { color: var(--accent-orange, #fb923c); } +.ac-level-success { color: var(--accent-green); } + +.ac-choice { + align-self: stretch; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 12px; + background: rgba(96, 165, 250, 0.06); +} +.ac-choice-q { color: var(--text); font-weight: 500; margin-bottom: 2px; } +.ac-choice-opt { + text-align: left; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} +.ac-choice-opt:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-hover); } +.ac-choice-opt:disabled { opacity: 0.5; cursor: default; } +.ac-choice-label { font-weight: 600; } +.ac-choice-desc { font-size: 12px; color: var(--text-muted); } +.ac-choice-picked { border-color: var(--accent-green); } + +.ac-spec { + align-self: flex-start; + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 12px; + background: var(--bg-hover); + font-size: 13px; +} +.ac-spec-title { font-weight: 600; color: var(--accent-purple); margin-bottom: 4px; } +.ac-spec-row { display: flex; justify-content: space-between; gap: 16px; color: var(--text-muted); } +.ac-spec-row span:last-child { color: var(--text); } + +.agent-chat-input { + display: flex; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--border); + background: var(--bg-hover); +} +.agent-chat-input textarea { + flex: 1 1 auto; + resize: none; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-card); + color: var(--text); + padding: 8px 10px; + font-family: inherit; + font-size: 14px; + line-height: 1.4; + max-height: 140px; +} +.agent-chat-input textarea:focus { outline: none; border-color: var(--accent); } +.agent-chat-input textarea:disabled { opacity: 0.6; } +.agent-chat-send { + flex: 0 0 auto; + align-self: flex-end; + padding: 8px 16px; + border-radius: 10px; + border: none; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; +} +.agent-chat-send:hover:not(:disabled) { background: var(--accent-hover); } +.agent-chat-send:disabled { opacity: 0.5; cursor: default; } +.agent-chat-send.ac-busy { background: var(--color-danger, #f87171); } diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js new file mode 100644 index 00000000..0b83f7a5 --- /dev/null +++ b/gently/ui/web/static/js/agent-chat.js @@ -0,0 +1,351 @@ +/** + * Floating agent-chat window — the web-side control surface. + * + * Connects to the same /ws/agent bridge the TUI uses, streams the agent's + * responses, and renders interactive choice pickers. A single-driver control + * lock on the server arbitrates who may drive the microscope; this client + * shows a banner and offers "Take control" when another client holds it. + * + * Self-contained IIFE (no build step). All untrusted text is escaped before + * insertion — never assign agent/user/tool strings to innerHTML directly. + */ +const AgentChat = (() => { + let ws = null; + let reconnectDelay = 1000; + const MAX_DELAY = 30000; + + let panelOpen = false; + let hasControl = true; // optimistic until the server says otherwise + let holderLabel = null; + let streaming = false; + let currentAgentEl = null; // the agent bubble currently being streamed into + let thinkingEl = null; + + // DOM refs (resolved in init) + let fab, panel, log, input, sendBtn, connDot, banner, closeBtn; + + // ── Safe rendering ──────────────────────────────────────── + function escapeHtml(s) { + const d = document.createElement('div'); + d.textContent = String(s == null ? '' : s); + return d.innerHTML; + } + + /** Minimal, safe markdown: escape first, then a few inline transforms. */ + function mdToHtml(text) { + let html = escapeHtml(text); + html = html.replace(/`([^`]+)`/g, '$1'); + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + html = html.replace(/\*([^*]+)\*/g, '$1'); + html = html.replace(/\n/g, '
'); + return html; + } + + // ── Message helpers ─────────────────────────────────────── + function scrollToBottom() { + log.scrollTop = log.scrollHeight; + } + + function addBubble(role, htmlOrText, { html = false } = {}) { + const el = document.createElement('div'); + el.className = `ac-msg ac-${role}`; + if (html) el.innerHTML = htmlOrText; + else el.textContent = htmlOrText; + log.appendChild(el); + scrollToBottom(); + return el; + } + + function addSystemLine(text, level = 'info') { + const el = document.createElement('div'); + el.className = `ac-system ac-level-${level}`; + el.textContent = text; + log.appendChild(el); + scrollToBottom(); + return el; + } + + function clearThinking() { + if (thinkingEl && thinkingEl.parentNode) thinkingEl.parentNode.removeChild(thinkingEl); + thinkingEl = null; + } + + // ── Protocol handlers ───────────────────────────────────── + function handle(msg) { + switch (msg.type) { + case 'connected': + reconnectDelay = 1000; + setConn(true); + // version / session in the header tooltip + if (msg.version) connDot.title = `connected · v${msg.version}`; + break; + + case 'control_status': + hasControl = !!msg.you_have_control; + holderLabel = msg.holder_label || null; + renderControl(); + break; + + case 'stream_start': + streaming = true; + clearThinking(); + currentAgentEl = null; // created lazily on first text + setBusy(true); + break; + + case 'thinking': + if (!thinkingEl) { + thinkingEl = document.createElement('div'); + thinkingEl.className = 'ac-thinking'; + thinkingEl.textContent = 'thinking…'; + log.appendChild(thinkingEl); + scrollToBottom(); + } + break; + + case 'text': { + clearThinking(); + if (!currentAgentEl) { + currentAgentEl = addBubble('agent', '', { html: true }); + currentAgentEl._raw = ''; + } + currentAgentEl._raw += (msg.text || ''); + currentAgentEl.innerHTML = mdToHtml(currentAgentEl._raw); + scrollToBottom(); + break; + } + + case 'tool_start': { + clearThinking(); + const label = msg.tool_label || msg.tool_name || 'tool'; + const el = document.createElement('div'); + el.className = 'ac-tool ac-tool-running'; + el.dataset.tool = msg.tool_name || ''; + el.innerHTML = ` ${escapeHtml(label)}…`; + log.appendChild(el); + scrollToBottom(); + break; + } + + case 'tool_call': { + // Mark the most recent running entry for this tool as done. + const running = [...log.querySelectorAll('.ac-tool-running')] + .filter(e => e.dataset.tool === (msg.tool_name || '')); + const el = running[running.length - 1]; + const label = msg.tool_name || 'tool'; + const dur = msg.duration ? ` · ${msg.duration.toFixed ? msg.duration.toFixed(1) : msg.duration}s` : ''; + const summary = msg.result_summary ? ` — ${escapeHtml(msg.result_summary)}` : ''; + if (el) { + el.className = 'ac-tool ac-tool-done'; + el.innerHTML = ` ${escapeHtml(label)}${dur}${summary}`; + } else { + addBubble('tool', `✓ ${escapeHtml(label)}${dur}${summary}`, { html: true }); + } + scrollToBottom(); + break; + } + + case 'choice_request': + renderChoice(msg); + break; + + case 'applied_spec': + renderSpec(msg.spec || {}); + break; + + case 'stream_end': + streaming = false; + clearThinking(); + currentAgentEl = null; + setBusy(false); + break; + + case 'command_result': + if (msg.error) addSystemLine(`${msg.command}: ${msg.error}`, 'error'); + else if (msg.content) addSystemLine(`${msg.command} ✓`, 'info'); + break; + + case 'notification': + addSystemLine(msg.body ? `${msg.title} — ${msg.body}` : msg.title, msg.level || 'info'); + break; + + case 'error': + streaming = false; + setBusy(false); + addSystemLine(msg.error || 'Unknown error', 'error'); + break; + + case 'ping': + send({ type: 'pong' }); + break; + + case 'pong': + case 'state_update': + case 'browse_result': + break; // not surfaced in the chat window (yet) + + default: + break; // unknown types ignored (forward-compatible) + } + } + + function renderChoice(msg) { + clearThinking(); + const data = msg.choice_data || {}; + const reqId = msg.request_id || data.request_id || ''; + const wrap = document.createElement('div'); + wrap.className = 'ac-choice'; + const q = document.createElement('div'); + q.className = 'ac-choice-q'; + q.innerHTML = mdToHtml(data.question || 'Choose:'); + wrap.appendChild(q); + + (data.options || []).forEach(opt => { + const btn = document.createElement('button'); + btn.className = 'ac-choice-opt'; + btn.disabled = !!opt.disabled; + const desc = opt.description ? `${escapeHtml(opt.description)}` : ''; + btn.innerHTML = `${escapeHtml(opt.label)}${desc}`; + btn.addEventListener('click', () => { + send({ type: 'choice_response', request_id: reqId, selected: opt.id }); + // lock the picker and show the pick + [...wrap.querySelectorAll('button')].forEach(b => b.disabled = true); + wrap.classList.add('ac-choice-answered'); + btn.classList.add('ac-choice-picked'); + }); + wrap.appendChild(btn); + }); + log.appendChild(wrap); + scrollToBottom(); + } + + function renderSpec(spec) { + const rows = []; + const add = (k, v) => { if (v !== undefined && v !== null && v !== '') rows.push([k, v]); }; + add('strain', spec.strain); + add('temp °C', spec.temperature_c); + add('slices', spec.num_slices); + add('exposure ms', spec.exposure_ms); + add('interval s', spec.interval_s); + add('stop at', spec.stop_condition); + if (!rows.length) return; + const html = '
Imaging spec applied
' + + rows.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join(''); + addBubble('spec', html, { html: true }); + } + + // ── Control / UI state ──────────────────────────────────── + function renderControl() { + if (hasControl) { + banner.classList.add('hidden'); + banner.innerHTML = ''; + input.disabled = false; + sendBtn.disabled = false; + input.placeholder = 'Message the agent…'; + } else { + banner.classList.remove('hidden'); + const who = holderLabel || 'another client'; + banner.innerHTML = `🔒 ${escapeHtml(who)} is driving`; + const btn = document.createElement('button'); + btn.className = 'ac-take-control'; + btn.textContent = 'Take control'; + btn.addEventListener('click', () => send({ type: 'take_control' })); + banner.appendChild(btn); + input.disabled = true; + sendBtn.disabled = true; + input.placeholder = 'Viewing only — take control to drive…'; + } + } + + function setBusy(busy) { + sendBtn.textContent = busy ? 'Stop' : 'Send'; + sendBtn.classList.toggle('ac-busy', busy); + } + + function setConn(ok) { + connDot.classList.toggle('ac-conn-ok', ok); + connDot.classList.toggle('ac-conn-bad', !ok); + if (!ok) connDot.title = 'reconnecting…'; + } + + // ── Transport ───────────────────────────────────────────── + function send(obj) { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); + } + + function connect() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${proto}//${location.host}/ws/agent`); + ws.onopen = () => { reconnectDelay = 1000; setConn(true); }; + ws.onclose = () => { + setConn(false); + setBusy(false); + streaming = false; + setTimeout(connect, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); + }; + ws.onerror = () => {}; + ws.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + handle(msg); + }; + } + + // ── Input handling ──────────────────────────────────────── + function submit() { + if (streaming) { send({ type: 'cancel' }); return; } // Send doubles as Stop + const text = input.value.trim(); + if (!text) return; + if (!hasControl) { renderControl(); return; } + addBubble('user', text); + if (text.startsWith('/')) { + send({ type: 'command', command: text }); // slash commands (e.g. /status) + } else { + send({ type: 'chat', text }); + } + input.value = ''; + autosize(); + } + + function autosize() { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 140) + 'px'; + } + + function togglePanel(open) { + panelOpen = (open === undefined) ? !panelOpen : open; + panel.classList.toggle('hidden', !panelOpen); + fab.classList.toggle('ac-fab-active', panelOpen); + if (panelOpen) { + if (!ws) connect(); + setTimeout(() => input.focus(), 50); + } + } + + // ── Init ────────────────────────────────────────────────── + function init() { + fab = document.getElementById('agent-fab'); + panel = document.getElementById('agent-chat'); + log = document.getElementById('agent-chat-log'); + input = document.getElementById('agent-chat-text'); + sendBtn = document.getElementById('agent-chat-send'); + connDot = document.getElementById('agent-chat-conn'); + banner = document.getElementById('agent-control-banner'); + closeBtn = document.getElementById('agent-chat-close'); + if (!fab || !panel) return; // markup not present + + fab.addEventListener('click', () => togglePanel()); + closeBtn.addEventListener('click', () => togglePanel(false)); + sendBtn.addEventListener('click', submit); + input.addEventListener('input', autosize); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } + if (e.key === 'Escape' && streaming) { e.preventDefault(); send({ type: 'cancel' }); } + }); + } + + document.addEventListener('DOMContentLoaded', init); + + return { togglePanel }; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index dd3bbfb0..6314d7a9 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -14,6 +14,7 @@ + {% include '_header.html' %} @@ -499,6 +500,22 @@

Properties

+ + + + @@ -519,5 +536,6 @@

Properties

+ diff --git a/launch_gently.py b/launch_gently.py index 1727dd2a..44e1e4bb 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -4,11 +4,16 @@ Conversational AI agent for diSPIM microscope control. +Starts the agent + web visualization server, then opens the browser UI. +The web UI is the control surface (the legacy Ink TUI is retired — its +source is kept in the tree but no longer launched). + Usage: - python launch_gently.py # Ink TUI (default) - python launch_gently.py --offline + python launch_gently.py # Start server + open browser + python launch_gently.py --no-browser # Start server, don't open a browser + python launch_gently.py --offline # Run without the device layer python launch_gently.py --sessions # List sessions and exit - python launch_gently.py --resume # Interactive session picker + python launch_gently.py --resume # Resume most recent session python launch_gently.py --resume latest # Resume most recent session python launch_gently.py --resume # Resume specific session python launch_gently.py -v # Verbose (INFO) logging @@ -89,10 +94,40 @@ def list_sessions(store: FileStore): print("Use: python launch_gently.py --resume ") +def _print_banner(viz_url, device_connected, offline, storage_dir, log_file, resumed): + """Print a human-readable launch banner to the terminal. + + This is the "what you see when you open it" surface now that the + server (not a TUI) is the long-running process. + """ + line = "─" * 56 + if offline: + dev = "○ offline (--offline)" + elif device_connected: + dev = "● connected" + else: + dev = "○ offline — run: python start_device_layer.py" + url = viz_url or "(viz server failed to start — check the log)" + tag = " [resumed session]" if resumed else "" + print() + print(f" ✦ Gently is running.{tag}") + print(f" {line}") + print(f" Open: {url}") + print(f" Device: {dev}") + print(f" Storage: {storage_dir}") + print(f" Logs: {log_file}") + print(f" Stop: Ctrl-C") + print(f" {line}") + print() + + def run_ink_picker(tui_dist: Path, sessions_json: str) -> str | None: """ Spawn the Ink TUI in session-picker mode and capture the selection. + Retired: kept for reference / potential reuse by a future web session + picker. No longer called by the launcher. + Returns the selected session ID, or None for a new session. """ proc = subprocess.run( @@ -116,7 +151,7 @@ def run_ink_picker(tui_dist: Path, sessions_json: str) -> str | None: return None -async def main(offline: bool = False, resume_session: str = None, show_sessions: bool = False, pick_session: bool = False, log_level: str = "WARNING"): +async def main(offline: bool = False, resume_session: str = None, show_sessions: bool = False, pick_session: bool = False, log_level: str = "WARNING", no_browser: bool = False): # Set up log file in storage directory storage_base = Path(os.environ.get("GENTLY_STORAGE", "D:/Gently3")) log_dir = storage_base / "logs" @@ -151,30 +186,20 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: store.close() return - # Ensure TUI is available - tui_dist = Path(__file__).parent / "gently" / "tui" / "dist" / "index.js" - if not tui_dist.exists() or not shutil.which("node"): - print("Error: TUI not available.") - if not tui_dist.exists(): - print(" Run: cd gently/tui && npm install && npm run build") - if not shutil.which("node"): - print(" Node.js not found in PATH") - store.close() - return + # Web-only: the TUI is retired. The browser is the control surface and + # the launcher just starts the server — no Node/dist requirement. - # Handle --resume (interactive picker, "latest", or specific session) + # Handle --resume. Interactive session picking has moved to the browser; + # without an explicit ID ("latest" or bare --resume) we resume the most + # recent session. session_to_resume = None - if pick_session: - # Two-phase launch: spawn Ink picker to select a session - items = _build_session_items(store) - if not items: - print("No saved sessions found. Starting new session.") - else: - session_to_resume = run_ink_picker(tui_dist, json.dumps(items)) - elif resume_session == "latest": + if pick_session or resume_session == "latest": sessions = store.list_sessions() if sessions: session_to_resume = sessions[0].get("session_id") + if pick_session: + print(f"Resuming most recent session: {session_to_resume} " + "(interactive session picking is moving into the browser)") else: print("No sessions found - starting fresh") elif resume_session: @@ -391,29 +416,32 @@ def _status_provider(): agent.viz_server.agent_bridge = bridge agent.viz_server.set_context_store(context_store) - ws_url = f"ws://localhost:{settings.network.viz_port}/ws/agent" - - # Spawn the Node.js TUI — it inherits stdin/stdout/stderr so Ink - # takes over the terminal. - tui_proc = subprocess.Popen( - ["node", str(tui_dist), "--ws-url", ws_url], - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, + # ── Banner + serve ────────────────────────────────────────────── + # The viz server runs in-process (uvicorn in a background task). With + # the TUI retired, the launcher's job is to keep that server alive and + # point the operator at the browser. + _print_banner( + viz_url=viz_url, + device_connected=bool(client and client.is_connected), + offline=offline, + storage_dir=storage_dir, + log_file=log_file, + resumed=session_to_resume is not None, ) - try: - # Wait for TUI to exit (blocks the event loop in a thread so - # the asyncio loop stays responsive for the viz server). - exit_code = await asyncio.get_event_loop().run_in_executor( - None, tui_proc.wait - ) - except (KeyboardInterrupt, asyncio.CancelledError): - tui_proc.terminate() + if viz_url and not no_browser: try: - tui_proc.wait(timeout=5) + import webbrowser + webbrowser.open(viz_url) except Exception: pass + + try: + # Serve until interrupted (Ctrl-C). Keep the event loop alive so + # the in-process viz server keeps handling browser clients. + await asyncio.Event().wait() + except (KeyboardInterrupt, asyncio.CancelledError): + pass finally: # Suppress noisy CancelledError / overlapped IO errors from # uvicorn during shutdown on Windows. @@ -448,6 +476,7 @@ def cli_main(): help="Resume a session. Without ID: shows picker. With ID: resumes that session.") parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose (INFO) logging") parser.add_argument("--debug", action="store_true", help="Enable debug logging (most verbose)") + parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the web UI in a browser") args = parser.parse_args() log_level = "WARNING" @@ -466,6 +495,7 @@ def cli_main(): resume_session=resume_id, pick_session=pick_session, log_level=log_level, + no_browser=args.no_browser, )) except (KeyboardInterrupt, RuntimeError, SystemExit): pass From 9460b785fc9641b27d200aa82ea38f9c5e5a2d54 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Thu, 28 May 2026 23:32:39 -0400 Subject: [PATCH 024/314] docs: add biologist-readiness plan Consolidated plan from the codebase audit: robustness gaps, biologist-UX gaps, complexity audit (legitimate vs refactorable + ~4000 lines of dead duplicate code), frontend audit, startup/topology, multi-user auth + single-driver control arbitration, a 5-day plan, the web-only convergence roadmap (milestones A-F), and progress to date. Co-Authored-By: Claude Opus 4.8 (1M context) --- notes/biologist-readiness-plan.md | 336 ++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 notes/biologist-readiness-plan.md diff --git a/notes/biologist-readiness-plan.md b/notes/biologist-readiness-plan.md new file mode 100644 index 00000000..00aae259 --- /dev/null +++ b/notes/biologist-readiness-plan.md @@ -0,0 +1,336 @@ +# Gently — Biologist-Readiness Plan + +> Engineering plan to make Gently more robust, easier for a non-programmer biologist to operate, +> and to evolve it into a multi-user, web-first microscope control system. +> Compiled from a codebase audit (architecture map, complexity audit of all >200-line files in `gently/`, +> robustness + UX review, frontend audit, startup/topology trace, and auth/multi-user ground-truth). + +**Author:** engineering analysis · **Date:** 2026-05-28 · **Horizon:** 1 focused week + a multi-sprint convergence arc + +--- + +## 0. Strategic decisions (already made) + +These are settled and shape everything below: + +1. **Frontend → converge on web-only.** The browser becomes the single surface (a floating agent chat window + the existing rich visuals). The Ink TUI becomes **legacy / maintenance-only** and is retired once the web reaches control parity. → *Do not invest in TUI refactors.* +2. **Processes → keep the two-process split, improve feedback.** The device layer (`start_device_layer.py`) stays a separate process from the agent (`launch_gently.py`) — this isolation is a safety feature, not an accident. Fix the *visibility* of its state, not the topology. +3. **Multi-user → LAN deployment, pluggable auth (no IT dependency to start).** Auth is a thin pluggable layer. Start with **Gently-managed accounts** (or shared/role tokens as an MVP) — needs nothing from institute IT. **Institute SSO (e.g. Janelia/HHMI login via a reverse proxy) is an optional later upgrade** that slots into the same layer if/when IT provides an endpoint. Gently owns the **control arbitration + roles + audit**, regardless of which login backend is used. +4. **Roles → viewers vs operators.** Anyone authenticated can **watch** (today's read-only experience, unchanged). Only **operators** can take control and drive the microscope. **Admins** can force-release and manage roles. +5. **Permission model → an explicit observable-vs-inputable classification.** Every endpoint/WS-message is tagged `observable` (read-only) or `inputable` (control). One registry drives all gating: viewer = observable set; operator-with-lock = observable + inputable. Adding a new action forces a classification; the audit log falls out of the `inputable` tag. +6. **Plan shape → balanced.** Interleave robustness/UX hardening with safe, high-value refactors. Bold-but-safe: refactor where features *won't* break; add tests *before* touching anything that might. + +--- + +## 1. Executive summary + +Gently is in **good architectural shape**. The hard parts (async acquisition state machine, hardware-safety code, the LLM loop) are well-factored. The problems that matter are **not "too complex"** — they are a handful of **silent, high-consequence failure modes**, an **opt-in/jargon UX that assumes a programmer**, and the **operational friction** of starting and using a multi-process, dual-frontend system. The web-only + multi-user direction resolves much of the friction *by construction* (e.g. it dissolves the embryo-marking hand-off and removes the Node dependency). + +**Top priorities, in order:** + +1. **Fix the verified, provable bugs** (status-tool KeyError, non-atomic writes, the silent device-down, the env-var split). Low risk, immediate value. +2. **Wire crash/restart auto-resume** — the single biggest data-loss risk; the code already exists but is never called. +3. **Harden transient-failure handling** (device hiccups, perception/Claude outages) so a brief blip doesn't silently end a run or image a dead embryo. +4. **Make state visible** — live device heartbeat, connection banner, liveness line, acquisition-settings panel, armed-rules display. +5. **Begin the web-only + multi-user arc** — browser agent chat, then the auth + single-driver control lock (the control lock must land *with* browser control, not after). + +--- + +## 2. State of the codebase — legitimate vs. accidental complexity + +Most large files are **legitimately large** (broad-but-cohesive domain modules), not tangled. Accidental complexity is concentrated and well-localized. + +### Leave alone — legitimate complexity (high feature-break risk) +- `harness/state.py` (979L) — shared mutable `EmbryoState`/`ExperimentState`. Splitting *creates* the duplication the design avoids. **Riskiest refactor target in the repo.** +- `harness/conversation.py` (774L) — core LLM loop (asend-recursion, observed-failure guards). +- `hardware/dispim/devices/*` (stage/optical/scanner/acquisition/camera/piezo) — laser/stage safety constants + MMCore vocab. +- `hardware/dispim/plans/calibration.py` (958L) — irreducible multi-phase calibration state machine. +- `core/imaging.py`, `event_bus.py`, `service.py`; `app/device_state_monitor.py`; `organisms/celegans/stages.py`. + +### Top refactor targets — accidental complexity worth fixing + +| File | Verdict | Risk | Effort | The fix | +|---|---|---|---|---| +| `app/tools/timelapse_tools.py` (815L) | REFACTORABLE | low | ~4h | Contains the confirmed KeyError bug. `@timelapse_tool` decorator kills the 6-line preamble in 17 tools; stop reaching into `orchestrator._embryo_states`. | +| `app/tools/calibration_tools.py` (1504L) | REFACTORABLE | low | ~2h | Delete ~450 lines of **dead code** (`fast_calibrate_embryo`, `hybrid_focus_selection`, `binary_edge_search`, `_fine_focus_sweep`) — unregistered, uncalled, reference nonexistent agent attrs. | +| `harness/bridge.py` (2215L) | REFACTORABLE | med | ~10h | God-object: 720-line `handle_command` if/elif ladder + case-folding bug (lowercases session/embryo IDs). Dispatch table off `CommandRegistry`. **High value for web convergence** — the browser control surface leans on this. | +| `harness/detection/verifier.py` (1158L) | REFACTORABLE | med | ~6h | `verify()`/`verify_with_context()` + two `_evaluate_consensus*` are superset/subset dupes; 5 `_run_*` + 4 `_parse_*` copy-paste. ~250 lines. **Capture consensus truth-table fixtures first.** | +| `mesh/peer_client.py` (393L) | REFACTORABLE | low | ~4h | 11 near-identical authed methods → one `_authed_json` helper (~270→~80 lines). | +| `hardware/dispim/claude_client.py` (631L) | REFACTORABLE | low | ~3h | 4 vision methods copy-paste → one `_vision_call`. | +| `harness/memory/file_store.py` (2552L) | MIXED | med | ~10h | Mixin split + shared serde. Lower priority than deleting the SQLite twin. | + +### The dominant *reduction* opportunity — ~4000 lines of dead duplicate code +The **legacy SQLite store stack** is a complete duplicate of the live file stores (CLAUDE.md says "No SQLite databases"): +- `core/store.py` (1064L) twins `core/file_store.py` +- `harness/memory/{store,_intentions,_plans,_understanding,_ml_pipelines}.py` (~2960L) twin `harness/memory/file_store.py` + +Dead in production, pinned only by ~41 tests. Delete **after** migrating tests to the `file_context_store` fixture → ~4000 lines gone, zero runtime change. **Friday work** (gated on test migration). + +--- + +## 3. Verified bugs (confirmed in source, not just inferred) + +| # | Bug | Location | Impact | +|---|---|---|---| +| V1 | `get_timelapse_status` reads `next_embryo`/`next_acquisition_in_seconds` that `to_dict()` never emits → **KeyError every call**. Same dead keys in `detection_tools.py`. | `app/tools/timelapse_tools.py:145-146,154` | Biologist's primary "is it working?" tool is broken. | +| V2 | `load_state()` fully implemented, `save_state()` runs every acquisition — but `load_state()` has **zero callers**. | `app/orchestration/timelapse.py:1643` | **No crash/restart auto-resume.** Overnight crash = whole night lost. | +| V3 | `_write_yaml` does `unlink()` then `rename()`; `save_state()` writes with no temp file. | `core/file_store.py:123-125` | **Non-atomic on Windows** — a power blip corrupts the files `/resume` needs. | +| V4 | Launcher reads `GENTLY_STORAGE`; everything else uses `GENTLY_STORAGE_PATH`. | `launch_gently.py:121` | Logs and data silently split to different paths. | +| V5 | Device-layer-down is a `logger.debug` (invisible at default log level). | `launch_gently.py:209-212` | Biologist starts with scope off, gets a normal-looking startup, discovers it mid-conversation. | +| V6 | **XSS / HTML injection** — event key/value (perception prose, paths, agent text) assigned via `innerHTML` with no escaping. | `ui/web/static/js/events.js:69-77, 130-151, 237` | Real injection surface in the events table. `escapeHtml` exists and is used elsewhere. | +| V7 | `/ws/agent` has **no connection guard/lock**; conversation state is a single shared object. | `routes/agent_ws.py:128`, `bridge.py:565`, `agent.py:759` | Latent today (TUI is sole client); **becomes live corruption the moment a browser drives the agent.** Fixed by the control lock (§9). | +| V8 | `bridge.handle_command` does `command.strip().lower()` then branches on it. | `harness/bridge.py:647,696` | Case-sensitive args (session IDs, hostnames, embryo IDs) silently corrupted. | +| V9 | Embryo marking blocks forever; `wait_for_marking(timeout=None)`; TUI never shows the viz URL or signals a browser is needed. | `ui/web/embryo_marker.py:79`, `server.py:481`, `detection_tools.py` | **Worst operational friction** — hangs if no browser is open. Dissolved by web-only convergence. | +| V10 | Marking is global shared state broadcast to all `/ws` clients; any client's `marking_done` clobbers. | `server.py:459-472`, `websocket.py:164-188` | Two browsers marking simultaneously clobber each other. Fixed by driver-only gating (§9). | + +--- + +## 4. Robustness gaps (ranked, for unattended multi-hour sessions) + +1. **[CRITICAL] No crash/restart auto-resume** (V2). `_resume_session` (`manager.py:40-117`) restores embryos+conversation but never the orchestrator or runtime fields (stop_condition, cadence_phase, next_due_at, error_count). +2. **[CRITICAL] Device hiccup permanently drops embryos.** `_acquire_embryo` (`timelapse.py:712`) treats network/timeout as terminal; 3 strikes → `complete: errors`. No auto-reconnect in `client.py`. +3. **[CRITICAL] Silent perception/detector outage.** `_run_perception`/`_run_detector` are log-only, no retry, no event. A Claude outage silently freezes stage/hatching detection **while the laser keeps firing.** +4. **[HIGH] Non-atomic writes** (V3). +5. **[HIGH] No abort path for a hung device-layer plan** — one RunEngine, no abort endpoint; one stuck acquisition freezes the wheel each round. +6. **[HIGH] Disk-full silently stops persistence** — `save_state` failures are `logger.debug`. +7. **[HIGH] Orphaned volume TIFFs** — swallowed `register_volume` failure + 300s `cleanup_incoming` race deletes valid volumes. +8. **[HIGH] Unbounded fatal exception kills the whole session** — `_run_loop` top-level except → FAILED, no per-iteration recovery. +9. **[MEDIUM]** Perception task leak / no per-call timeout (`timelapse.py:2706, 2483`). +10. **[MEDIUM]** Startup picker / `wait_for_marking` block forever. +11. **[MEDIUM]** Advisory `session.lock`, no PID check. + +--- + +## 5. Biologist usability gaps (ranked) + +1. **[CRITICAL] "Microscope not connected" is silent** (V5). → persistent banner worded as consequence+fix; live heartbeat dot; `/reconnect`. +2. **[CRITICAL] Phototoxicity protection is opt-in, silent, expert-only** — only arms if Claude is passed `monitoring_mode='expression_monitoring'`. → make it **default** for reporter/hatching experiments; agent states plainly what it armed; show armed rules in plain English. +3. **[CRITICAL] No LLM-independent emergency stop** — pause/stop are only LLM tools. → `/stop` `/pause` that call the orchestrator directly (no API round-trip). +4. **[HIGH] Silent auto-complete / auto-pause** — biologist must inspect `completion_reason`. → push plain-language notice; distinguish hardware-error (offer retry) from biological endpoint. +5. **[HIGH] No liveness reassurance.** → "last volume 0:47 ago · next in 1:13" line, yellow/red when stalled. +6. **[HIGH] Marking blocks with no browser cue** (V9). +7. **[HIGH] Cryptic launch hard-stops** (`ANTHROPIC_API_KEY not set`, "TUI not available", Node/npm). +8. **[HIGH] First-run setup landmines** — stale model IDs (`settings.py:55-58`), env-var split, raw `ModuleNotFoundError` on bad organism, README version drift (v0.11.0 vs 0.20.0). → `--doctor` preflight. +9. **[MEDIUM]** Jargon mismatch (campaign/role=test/burst/SAM/photodose). → relabel human-facing strings. +10. **[MEDIUM]** Stop-condition vocabulary mismatch ("pretzel"/"2fold" shown but rejected as targets); casing drift. +11. **[MEDIUM]** Generic error strings (raw `str(e)`/tracebacks reach the biologist). + +--- + +## 6. Frontend audit + +### Web UI (`gently/ui/web`) — the future single surface +- **Stack:** vanilla JS, no build step, FastAPI + Jinja2, Three.js for 3D. ~15k JS / 21 ` + + diff --git a/launch_gently.py b/launch_gently.py index 9a38e815..34cdff8e 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -185,6 +185,20 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: write_manifest(storage_dir) store = FileStore(storage_dir) + # ── Accounts / auth ─────────────────────────────────────────── + # Self-managed user accounts gate microscope control on the LAN. On first + # run we bootstrap an admin and print its one-time password in the banner. + # Set GENTLY_NO_AUTH=1 to disable accounts (legacy localhost-control mode). + admin_creds = None + if os.environ.get("GENTLY_NO_AUTH", "").strip().lower() not in ("1", "true", "yes"): + try: + from gently.ui.web.accounts import AccountStore, set_account_store + account_store = AccountStore(storage_dir / "auth") + set_account_store(account_store) + admin_creds = account_store.bootstrap_admin_if_empty() + except Exception as e: + logger.error("Account store init failed (continuing without auth): %s", e) + # Handle --sessions (just list and exit) if show_sessions: list_sessions(store) @@ -434,6 +448,13 @@ def _status_provider(): resumed=session_to_resume is not None, ) + if admin_creds: + _u, _p = admin_creds + print(" First-run admin account created — sign in at the URL above:") + print(f" username: {_u}") + print(f" password: {_p}") + print(" (Save this now. Add users via the admin API; GENTLY_NO_AUTH=1 disables auth.)\n") + if viz_url and not no_browser: try: import webbrowser From 6917a4282da3af2a198a8feb861c6b0dfd17d1bb Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:11:29 -0400 Subject: [PATCH 026/314] fix(launcher): make Ctrl-C stop the server on Windows The serve loop blocked on a bare asyncio.Event().wait(), which the Windows Proactor loop won't interrupt on Ctrl-C, leaving the server unstoppable. Install SIGINT/SIGTERM handlers (loop.add_signal_handler, falling back to signal.signal + call_soon_threadsafe on Windows) and poll a stop Event on a short interval so the interrupt surfaces and shutdown runs cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --- launch_gently.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/launch_gently.py b/launch_gently.py index 34cdff8e..37d4913c 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -462,10 +462,30 @@ def _status_provider(): except Exception: pass + # Keep the event loop alive so the in-process viz server keeps serving. + # On Windows the Proactor loop won't surface Ctrl-C while blocked on a + # bare Event().wait(), so install signal handlers and poll on a short + # interval (which also lets a pending KeyboardInterrupt surface). + import signal as _signal + _loop = asyncio.get_running_loop() + _stop = asyncio.Event() try: - # Serve until interrupted (Ctrl-C). Keep the event loop alive so - # the in-process viz server keeps handling browser clients. - await asyncio.Event().wait() + _loop.add_signal_handler(_signal.SIGINT, _stop.set) + _loop.add_signal_handler(_signal.SIGTERM, _stop.set) + except (NotImplementedError, AttributeError, RuntimeError, ValueError): + # Windows Proactor: add_signal_handler is unsupported — fall back to + # signal.signal, waking the loop via call_soon_threadsafe. + def _sig(*_a): + _loop.call_soon_threadsafe(_stop.set) + try: + _signal.signal(_signal.SIGINT, _sig) + _signal.signal(_signal.SIGTERM, _sig) + except (ValueError, OSError): + pass + + try: + while not _stop.is_set(): + await asyncio.sleep(0.3) except (KeyboardInterrupt, asyncio.CancelledError): pass finally: From e075dcd1f42ce45899952ca610d9efed270bc8de Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:16:16 -0400 Subject: [PATCH 027/314] fix(web): viewing is open; login elevates to control (not a page gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the dashboard no longer redirects to /login — viewing is open to everyone (the "watch like it is now" model). Login is an elevation to the control role, not a gate on the app. - pages.py: drop the /login redirect on the main page. - agent_ws.py: anonymous clients may connect to /ws/agent and *watch*; only authenticated operators/admins can hold/take the control lock (drive actions stay gated). No more close-on-unauthenticated. - agent-chat.js: distinguish anonymous ("Viewing — sign in to control", with a Sign in button) from a viewer-role account ("view-only"); header button is Sign in / Sign out accordingly. API model: observable (read) endpoints + watching the agent need no auth; only inputable (control) actions are gated via require_control / the control lock — auth is not attached to every endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/agent_ws.py | 15 ++++++-------- gently/ui/web/routes/pages.py | 12 +++++------ gently/ui/web/static/js/agent-chat.js | 29 ++++++++++++++++++++++++--- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index c29dfbce..ba49cb76 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -171,18 +171,15 @@ async def agent_websocket(websocket: WebSocket): from gently.ui.web.auth import SESSION_COOKIE _acct = get_account_store() username = None - can_control = True # legacy default + can_control = True # legacy default when no accounts are configured if _acct is not None and _acct.has_users(): + # Viewing is open: anonymous clients may connect and *watch* the + # conversation. Only authenticated operators/admins can hold or + # take the control lock (enforced on the drive actions below). _token = websocket.cookies.get(SESSION_COOKIE) username = _acct.verify_session(_token) if _token else None - if username is None: - await websocket.send_json({ - "type": "error", - "error": "Authentication required — please sign in.", - }) - await websocket.close() - return - can_control = _acct.get_role(username) in CONTROL_ROLES + role = _acct.get_role(username) if username else None + can_control = role in CONTROL_ROLES # Assign a stable id for control arbitration. The label shown to other # clients is the username when authenticated, else a generic window id. diff --git a/gently/ui/web/routes/pages.py b/gently/ui/web/routes/pages.py index 9c48912a..0cff7d82 100644 --- a/gently/ui/web/routes/pages.py +++ b/gently/ui/web/routes/pages.py @@ -9,12 +9,12 @@ def create_router(server) -> APIRouter: @router.get("/", response_class=HTMLResponse) async def index(request: Request): - """Serve the main SPA page (redirect to login if accounts require it).""" - from gently.ui.web.accounts import get_account_store - from gently.ui.web.auth import current_username - store = get_account_store() - if store is not None and store.has_users() and not current_username(request): - return RedirectResponse("/login", status_code=302) + """Serve the main SPA page. + + Viewing is open to everyone — the dashboard loads in view mode with no + login. Signing in is an *elevation* to control (handled in-app via the + chat window's "Sign in" affordance), not a gate on the page itself. + """ return server.templates.TemplateResponse( "index.html", {"request": request, "active_section": "embryos", "is_live": True} diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index cb42c409..8a60f3a0 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -262,8 +262,17 @@ const AgentChat = (() => { const who = holderLabel || 'another session'; input.disabled = true; sendBtn.disabled = true; - if (me && me.authenticated && me.can_control === false) { - // Viewer role — watching is all this account can do. + if (me && me.accounts && !me.authenticated) { + // Anonymous — viewing is open; sign in to control. + banner.innerHTML = `Viewing — sign in to control.`; + const btn = document.createElement('button'); + btn.className = 'ac-take-control'; + btn.textContent = 'Sign in'; + btn.addEventListener('click', () => { window.location.href = '/login'; }); + banner.appendChild(btn); + input.placeholder = 'Viewing — sign in to control…'; + } else if (me && me.authenticated && me.can_control === false) { + // Viewer-role account — watching is all this account can do. banner.innerHTML = `View-only access — you can watch but not control.`; input.placeholder = 'View-only access'; } else { @@ -356,8 +365,18 @@ const AgentChat = (() => { if (m && m.authenticated) { userEl.textContent = m.username; userEl.title = `Signed in as ${m.username} (${m.role})`; + signoutBtn.textContent = 'Sign out'; + signoutBtn.dataset.action = 'logout'; + signoutBtn.style.display = ''; + } else if (m && m.accounts) { + // Anonymous — viewing is open; sign in to gain control. + userEl.textContent = 'viewing'; + userEl.title = 'Not signed in — view-only'; + signoutBtn.textContent = 'Sign in'; + signoutBtn.dataset.action = 'login'; signoutBtn.style.display = ''; } else { + // No accounts configured (legacy mode). userEl.textContent = ''; signoutBtn.style.display = 'none'; } @@ -382,8 +401,12 @@ const AgentChat = (() => { fab.addEventListener('click', () => togglePanel()); closeBtn.addEventListener('click', () => togglePanel(false)); signoutBtn.addEventListener('click', async () => { + if (signoutBtn.dataset.action === 'login') { + window.location.href = '/login'; + return; + } try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (_) {} - window.location.href = '/login'; + window.location.reload(); }); fetchMe(); sendBtn.addEventListener('click', submit); From 645aacef15a4fe8a85739767c4cd80fd16c2bacc Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:24:29 -0400 Subject: [PATCH 028/314] feat(web): gate person-driven control actions by role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the inputable-action gating beyond the REST routes already covered in data.py: - chat.py: the per-timepoint VLM follow-up (POST /api/perception/chat/...) now requires the control role — it spends API budget and writes traces, so anonymous viewers can't trigger it. - websocket.py (/ws): marking actions (embryo_marked / marking_update / marking_done / marking_redetect) are gated to control-role clients via the session cookie; pure read/presence messages stay open so anyone can watch. Deliberately NOT gated here: device-layer ingest (POST images/volumes — a machine trust domain, would break under account mode where localhost is no longer auto-control) and campaign mutations (their own mesh scope auth). These need a separate machine-token pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/chat.py | 7 +++++-- gently/ui/web/routes/websocket.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/routes/chat.py b/gently/ui/web/routes/chat.py index 12833b13..1cdd15f7 100644 --- a/gently/ui/web/routes/chat.py +++ b/gently/ui/web/routes/chat.py @@ -16,10 +16,12 @@ from pathlib import Path from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel +from gently.ui.web.auth import require_control + logger = logging.getLogger(__name__) CHAT_MODEL = "claude-opus-4-7" @@ -106,7 +108,8 @@ async def get_chat(sid: str, eid: str, tp: int): return {"turns": _load_history(path)} @router.post("/api/perception/chat/{sid}/{eid}/{tp}") - async def post_chat(sid: str, eid: str, tp: int, body: ChatRequest): + async def post_chat(sid: str, eid: str, tp: int, body: ChatRequest, + _control=Depends(require_control)): """Append a user message and stream the assistant reply as SSE. Each SSE event is JSON: ``{"type": "delta", "text": "..."}`` for diff --git a/gently/ui/web/routes/websocket.py b/gently/ui/web/routes/websocket.py index b49518e0..3dce3320 100644 --- a/gently/ui/web/routes/websocket.py +++ b/gently/ui/web/routes/websocket.py @@ -11,6 +11,30 @@ logger = logging.getLogger(__name__) +# /ws message types that mutate experiment state (define what gets imaged). +# These are control actions and are gated by role; pure read/presence +# messages stay open so anyone can watch. +_MARKING_TYPES = frozenset({ + "embryo_marked", "marking_update", "marking_done", "marking_redetect", +}) + + +def _ws_can_control(websocket: WebSocket) -> bool: + """Whether this /ws client may perform control actions (marking). + + Account mode: operators/admins (by session cookie) only. Legacy mode + (no accounts configured): open, preserving prior behavior. + """ + from gently.ui.web.accounts import get_account_store, CONTROL_ROLES + from gently.ui.web.auth import SESSION_COOKIE + store = get_account_store() + if store is None or not store.has_users(): + return True + token = websocket.cookies.get(SESSION_COOKIE) + user = store.verify_session(token) if token else None + role = store.get_role(user) if user else None + return role in CONTROL_ROLES + def create_router(server) -> APIRouter: router = APIRouter() @@ -77,6 +101,11 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): msg_type = data.get("type") embryo_id = data.get("embryo_id") + # Gate control actions (marking) by role; viewing/presence stays open. + if msg_type in _MARKING_TYPES and not _ws_can_control(websocket): + logger.warning("Ignored %s from a view-only /ws client", msg_type) + return + if msg_type == "get_calibration": images = server.store.get_all_calibration(embryo_id) await websocket.send_json({ From 0fc43c6e1aa05897e63091d49ad0bc224af41be7 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:28:30 -0400 Subject: [PATCH 029/314] feat(web): uniform session transcript across all clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation is now the same for every client of a session — operators and viewers, live and on reconnect/refresh. - Broadcast: user messages and the agent's streamed reply (text/thinking/ tool calls/choice requests) go to ALL connected /ws/agent clients via a raw-websocket registry, not just the driver. Observers watch live. - History: a display transcript is accumulated server-side and persisted to /chat_display.json (user/agent/tool turns, capped to 500). On connect each client is sent a "history" message and rebuilds the transcript, so refreshes and late joiners see the full conversation. - Choice pickers are interactive only for the control holder; observers see them read-only and only the holder's choice_response resolves. - Client: handles "history" (rebuild) and "user_message" (live echo with author); stops double-echoing the sender's own chat (it now arrives via the broadcast); slash commands still echo locally (not broadcast). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/agent_ws.py | 98 ++++++++++++++++++++++++++- gently/ui/web/static/js/agent-chat.js | 59 ++++++++++++++-- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index ba49cb76..570c112a 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -41,6 +41,14 @@ def create_router(server) -> APIRouter: _clients: Dict[str, Callable] = {} _client_labels: Dict[str, str] = {} _client_counter = {"n": 0} + _raw_clients: Dict[str, WebSocket] = {} # client_id -> websocket (broadcast) + + # ── Uniform display transcript ──────────────────────────── + # A single conversation history shared by every client of this session. + # Persisted to /chat_display.json so it survives reconnects and + # restarts; broadcast live so all instances stay in sync. + _history: list = [] + _history_state = {"loaded": False, "path": None, "agent_buf": None} async def _broadcast_control_status(): """Tell every connected agent client who currently holds control.""" @@ -57,6 +65,75 @@ async def _broadcast_control_status(): except Exception: pass + def _load_history_once(bridge): + if _history_state["loaded"]: + return + _history_state["loaded"] = True + try: + agent = bridge.agent + store = getattr(agent, "store", None) + sid = getattr(agent, "session_id", None) + if store and sid: + sdir = store._session_dir(sid) + if sdir: + p = sdir / "chat_display.json" + _history_state["path"] = p + if p.exists(): + loaded = json.loads(p.read_text(encoding="utf-8")) or [] + if isinstance(loaded, list): + _history.extend(loaded) + except Exception: + logger.debug("Could not load chat history", exc_info=True) + + def _save_history(): + p = _history_state["path"] + if not p: + return + try: + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(_history[-500:]), encoding="utf-8") + tmp.replace(p) + except Exception: + pass + + def _record(item): + _history.append(item) + if len(_history) > 500: + del _history[: len(_history) - 500] + _save_history() + + def _flush_agent_buf(): + buf = _history_state["agent_buf"] + if buf: + _record({"role": "agent", "text": buf}) + _history_state["agent_buf"] = None + + def _record_display(msg): + """Fold a streamed chunk into the persistent display history.""" + t = msg.get("type") + if t == "user_message": + _flush_agent_buf() + _record({"role": "user", "text": msg.get("text", ""), + "author": msg.get("author")}) + elif t == "text": + _history_state["agent_buf"] = (_history_state["agent_buf"] or "") + msg.get("text", "") + elif t == "tool_call": + _flush_agent_buf() + _record({"role": "tool", "name": msg.get("tool_name"), + "duration": msg.get("duration"), + "summary": msg.get("result_summary")}) + elif t == "stream_end": + _flush_agent_buf() + + async def _broadcast(msg): + """Record to history + send a display message to ALL clients.""" + _record_display(msg) + for cid, ws in list(_raw_clients.items()): + try: + await ws.send_json(msg) + except Exception: + pass + async def _run_wizard(wizard, websocket, send_fn, _choice_futures, bridge=None, log_transcript=None): """Run the wizard's interactive loop. @@ -464,10 +541,20 @@ def choice_future_factory(choice_data: dict) -> asyncio.Future: # (only to clients allowed to drive — viewers never auto-hold). _clients[client_id] = send_fn _client_labels[client_id] = client_label + _raw_clients[client_id] = websocket if _control["holder"] is None and can_control: _control["holder"] = client_id await _broadcast_control_status() + # Replay the uniform session transcript so every client (and every + # reconnect/refresh) shows the same conversation. + _load_history_once(bridge) + if _history: + try: + await websocket.send_json({"type": "history", "items": list(_history)}) + except Exception: + pass + try: # ── Wizard phase ────────────────────────────────────── # Run startup wizard (if needed) before entering the REPL. @@ -606,11 +693,19 @@ async def _run_resolution_bootstrap(): if active_task and not active_task.done(): active_task.cancel() + # Echo the user's message to ALL clients (so observers see + # what was asked), then stream the reply to everyone. + await _broadcast({"type": "user_message", "text": text, + "author": client_label}) active_task = asyncio.create_task( - bridge.stream_response(text, send_fn, choice_future_factory) + bridge.stream_response(text, _broadcast, choice_future_factory) ) elif msg_type == "choice_response": + # Only the control holder answers pickers (observers see + # them read-only). + if _control["holder"] != client_id: + continue request_id = data.get("request_id", "") selected = data.get("selected", "") # Check if bridge owns this choice (e.g. /import-embryos picker) @@ -719,6 +814,7 @@ async def _run_resolution_bootstrap(): # to any remaining client (or free it) and resync everyone. _clients.pop(client_id, None) _client_labels.pop(client_id, None) + _raw_clients.pop(client_id, None) if _control["holder"] == client_id: _control["holder"] = next(iter(_clients), None) try: diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 8a60f3a0..577d18bd 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -81,9 +81,47 @@ const AgentChat = (() => { return content; } - function addUserMessage(text) { - const content = addTurn('user'); + function addUserMessage(text, author) { + const wrap = document.createElement('div'); + wrap.className = 'ac-turn ac-turn-user'; + if (author) { + const label = document.createElement('div'); + label.className = 'ac-role ac-role-user'; + label.textContent = author; + wrap.appendChild(label); + } + const content = document.createElement('div'); + content.className = 'ac-content'; content.textContent = text; + wrap.appendChild(content); + log.appendChild(wrap); + scrollToBottom(); + } + + /** Rebuild the transcript from a persisted/replayed history list. */ + function renderHistory(items) { + log.innerHTML = ''; + currentAgentEl = null; + activityEl = null; + (items || []).forEach(it => { + if (it.role === 'user') { + addUserMessage(it.text, it.author); + } else if (it.role === 'agent') { + const c = addTurn('agent'); + c._raw = it.text || ''; + c.innerHTML = mdToHtml(c._raw); + } else if (it.role === 'tool') { + const el = document.createElement('div'); + el.className = 'ac-tool ac-tool-done'; + const dur = it.duration ? ` · ${(it.duration.toFixed ? it.duration.toFixed(1) : it.duration)}s` : ''; + const summary = it.summary ? ` — ${escapeHtml(it.summary)}` : ''; + el.innerHTML = `${escapeHtml(it.name || 'tool')}${dur}${summary}`; + log.appendChild(el); + } else if (it.role === 'system') { + addSystemLine(it.text, it.level || 'info'); + } + }); + scrollToBottom(); } function addSystemLine(text, level = 'info') { @@ -108,6 +146,15 @@ const AgentChat = (() => { renderControl(); break; + case 'history': + renderHistory(msg.items || []); + break; + + case 'user_message': + hideActivity(); + addUserMessage(msg.text, msg.author); + break; + case 'stream_start': streaming = true; currentAgentEl = null; // created lazily on first text @@ -215,7 +262,7 @@ const AgentChat = (() => { (data.options || []).forEach(opt => { const btn = document.createElement('button'); btn.className = 'ac-choice-opt'; - btn.disabled = !!opt.disabled; + btn.disabled = !!opt.disabled || !hasControl; // observers see it read-only const desc = opt.description ? `${escapeHtml(opt.description)}` : ''; btn.innerHTML = `${escapeHtml(opt.label)}${desc}`; btn.addEventListener('click', () => { @@ -330,11 +377,11 @@ const AgentChat = (() => { const text = input.value.trim(); if (!text) return; if (!hasControl) { renderControl(); return; } - addUserMessage(text); if (text.startsWith('/')) { - send({ type: 'command', command: text }); // slash commands (e.g. /status) + addUserMessage(text); // commands aren't broadcast; echo locally + send({ type: 'command', command: text }); // slash commands (e.g. /status) } else { - send({ type: 'chat', text }); + send({ type: 'chat', text }); // echoed to all via 'user_message' } // Instant feedback before the first chunk arrives. setBusy(true); From f6aad7452fd1ef4b9e49ed1311709faa2a04f313 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:30:53 -0400 Subject: [PATCH 030/314] fix(store): crash-safe atomic YAML/JSON writes (V3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _write_yaml and save_conversation wrote to a temp file, then unlink()'d the target and rename()'d — a non-atomic window where a crash/power-loss between unlink and rename leaves the file (session.yaml, embryo.yaml, conversation .json, timelapse state) missing or truncated. The very files /resume depends on were the ones at risk. Use os.replace(tmp, path) (atomic + overwrites on Windows) and fsync the temp before the swap, so the target always reflects either the old or new content, never a gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/core/file_store.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/gently/core/file_store.py b/gently/core/file_store.py index 1abbc971..0231c14c 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -156,10 +156,11 @@ def _write_yaml(path: Path, data: Any) -> None: with os.fdopen(fd, "w", encoding="utf-8") as f: yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) - # On Windows, rename over an existing file requires removing it first. - if path.exists(): - path.unlink() - Path(tmp).rename(path) + f.flush() + os.fsync(f.fileno()) + # os.replace is atomic and overwrites on Windows — no unlink gap that + # a crash/power-loss could leave the target missing. + os.replace(tmp, path) except BaseException: # Clean up temp file on failure try: @@ -410,9 +411,9 @@ def save_session_snapshot(self, session_id: str, snapshot: dict) -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(snapshot, f, indent=2, ensure_ascii=False, default=str) - if path.exists(): - path.unlink() - Path(tmp).rename(path) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) except BaseException: try: os.unlink(tmp) From 7698decb4019bc7c924b6811a7a4d293a2c631f4 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:31:49 -0400 Subject: [PATCH 031/314] fix(tools): get_timelapse_status crashed with KeyError (V1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status tool read status_dict['next_embryo'], ['next_acquisition_in_seconds'] and per-embryo ['interval_seconds'] — none of which TimelapseState.to_dict() emits (it has next_round_time / seconds_until_next_round, and embryo_details carries timepoints / is_complete / completion_reason). Every successful call raised KeyError, so the biologist's primary "is it working?" tool was broken. Use the real keys: report seconds_until_next_round for the next acquisition and drop the unavailable per-embryo interval from the line. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/app/tools/timelapse_tools.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index fad0ff95..2e01fa60 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -142,16 +142,15 @@ def get_timelapse_status(context: Dict = None) -> str: lines.append(f"Completed embryos: {status_dict['completed_embryos']}") lines.append("") - if status_dict['next_embryo']: - lines.append(f"Next acquisition: {status_dict['next_embryo']} in {status_dict['next_acquisition_in_seconds']:.0f}s") + if status_dict.get('seconds_until_next_round') is not None: + lines.append(f"Next acquisition in {status_dict['seconds_until_next_round']:.0f}s") lines.append("") if status_dict['embryo_details']: lines.append("Embryo Details:") for eid, details in status_dict['embryo_details'].items(): status_marker = "[done]" if details['is_complete'] else "[active]" - lines.append(f" {status_marker} {eid}: t={details['timepoints']} " - f"(interval={details['interval_seconds']}s)") + lines.append(f" {status_marker} {eid}: t={details['timepoints']}") if details['is_complete']: lines.append(f" Completed: {details['completion_reason']}") From 8e3531b14709d4b05999f6c60efe2554a95243e0 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:32:07 -0400 Subject: [PATCH 032/314] =?UTF-8?q?fix(launcher):=20unify=20storage=20env?= =?UTF-8?q?=20var=20=E2=80=94=20logs=20follow=20data=20(V4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher read GENTLY_STORAGE for its log directory while the rest of the system uses GENTLY_STORAGE_PATH (via settings.storage.base_path). Setting only one silently split logs from data. Use settings.storage.base_path for the log dir too so both honor the same configured root. Co-Authored-By: Claude Opus 4.8 (1M context) --- launch_gently.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/launch_gently.py b/launch_gently.py index 37d4913c..6caa3cba 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -154,7 +154,11 @@ def run_ink_picker(tui_dist: Path, sessions_json: str) -> str | None: async def main(offline: bool = False, resume_session: str = None, show_sessions: bool = False, pick_session: bool = False, log_level: str = "WARNING", no_browser: bool = False): # Set up log file in storage directory - storage_base = Path(os.environ.get("GENTLY_STORAGE", "D:/Gently3")) + # Unified with FileStore: logs live under the same root as data + # (settings.storage.base_path reads GENTLY_STORAGE_PATH). Previously this + # read a separate GENTLY_STORAGE env var, so setting only one split logs + # from data. + storage_base = settings.storage.base_path log_dir = storage_base / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_file = str(log_dir / f"gently_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log") From 2f4e3e61f4288dfbcd4104d2c3f38630407ba0af Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:33:21 -0400 Subject: [PATCH 033/314] fix(web): escape event text to close events-table XSS (V6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highlightSearchTerms returned arbitrary event data (perception reasoning, file paths, agent free-text) unescaped, and callers assigned it via innerHTML — so any '<' in event data was interpreted as markup (a real injection surface). Escape the input before inserting the search tags; this closes the hole at every call site (event key/value, log message, logger name) since they all route through this function. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/js/events.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/gently/ui/web/static/js/events.js b/gently/ui/web/static/js/events.js index 3e37eb66..998431a1 100644 --- a/gently/ui/web/static/js/events.js +++ b/gently/ui/web/static/js/events.js @@ -101,12 +101,17 @@ function eventMatchesSearch(event) { } function highlightSearchTerms(text) { - if (!searchQuery || !text) return text; + // Escape first — event keys/values/messages are arbitrary text (perception + // prose, file paths, agent output) and are inserted via innerHTML by the + // callers. Escaping here closes the XSS hole at every call site; the + // injected tags are the only markup we add. + const safe = escapeHtml(text == null ? '' : String(text)); + if (!searchQuery) return safe; try { const regex = new RegExp(`(${escapeRegex(searchQuery)})`, 'gi'); - return String(text).replace(regex, '$1'); + return safe.replace(regex, '$1'); } catch (e) { - return text; + return safe; } } From 2b94ffe0275cb3dc5a63fb137eca9896b1b96a6d Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 00:33:54 -0400 Subject: [PATCH 034/314] =?UTF-8?q?docs:=20update=20progress=20=E2=80=94?= =?UTF-8?q?=20auth,=20gating,=20transcript,=20verified=20bugs=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- notes/biologist-readiness-plan.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/notes/biologist-readiness-plan.md b/notes/biologist-readiness-plan.md index 00aae259..701a4fc5 100644 --- a/notes/biologist-readiness-plan.md +++ b/notes/biologist-readiness-plan.md @@ -316,7 +316,11 @@ Incremental — the backend protocol for agent control already exists (`/ws/agen - **[done] Milestone A (start):** floating agent-chat window in the web UI (`static/js/agent-chat.js`, `static/css/agent-chat.css`, wired into `index.html`) connecting to `/ws/agent` with streaming + choice pickers + applied-spec cards + slash-command routing, XSS-safe. - **[done] Milestone B (seed):** single-driver control lock in `routes/agent_ws.py` (holder drives; observers get a "Take control" banner; control passes on disconnect). Fixes latent V7. *Not yet gated by auth — that's the next increment.* - **[done] Milestone E/F (TUI retired + launcher reshaped):** `launch_gently.py` no longer spawns the Node TUI — it starts the agent + viz server, prints a launch banner (URL · device status [fixes V5] · storage · Ctrl-C), auto-opens the browser (`--no-browser` to suppress), and serves until interrupted. Node/dist requirement removed; `--resume`/bare `--resume` resolves to most-recent (interactive picker deferred to the web). TUI source kept in-tree (reversible), `run_ink_picker` retained for reference. -- **[‼ NEXT] Auth gating:** with the TUI gone, the browser is the *only* control path and it is **unauthenticated on the LAN**. Bind viz to `127.0.0.1` or trust the LAN until self-managed accounts + viewer/operator roles land. This is now the top priority. +- **[done] Self-managed auth + roles:** `accounts.py` (PBKDF2 users, HMAC session cookies, first-run admin bootstrap), `auth.py` cookie-aware `resolve_role`, `/login` + `/api/auth/*`, `/ws/agent` authenticates and gates control by role. Viewing is **open** (anonymous watch); login elevates to control; admin/operator/viewer. `GENTLY_NO_AUTH=1` disables. +- **[done] Control-action gating pass:** perception-chat POST + `/ws` marking actions gated to control role (`require_control` / session cookie). Device-ingest + campaign-mesh routes deliberately left to their own machine/mesh auth (documented). +- **[done] Uniform session transcript:** user messages + agent stream broadcast to ALL `/ws/agent` clients (observers watch live); display history persisted to `/chat_display.json` and replayed on connect so refreshes/late-joiners see the full conversation. Choice pickers interactive only for the holder. +- **[done] Verified bugs:** V1 `get_timelapse_status` KeyError, V3 crash-safe atomic writes (`os.replace`+fsync), V4 storage env-var unify, V6 events-table XSS escape. Plus the Windows Ctrl-C launcher fix. +- **[remaining] Not yet done (need tests / live verification):** V2 acquisition auto-resume (add round-trip tests first), dead-calibration-code + SQLite-stack deletion (grep + test migration), device/perception transient-failure hardening, friendly organism/hardware config errors, machine-token auth for device-ingest endpoints. --- From 2038bf7846f1a9969c932cf3390ab73d15700e03 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 05:37:57 -0400 Subject: [PATCH 035/314] feat(web): resume a session from the Sessions tab The Sessions tab was empty: /api/sessions globbed sessions_dir/*.json, but Gently3's FileStore stores each session as a directory (session.yaml). And there was no way to switch the live agent's session from the browser after the TUI session picker was retired. - /api/sessions now lists from the live FileStore (via the agent), with an `active` flag for the current session. - New control-gated POST /api/sessions/{id}/resume calls agent.resume_session (the same machinery as CLI resume: saves current, loads target's embryos + conversation), then broadcasts `session_changed` so all browsers reload. - agent_ws transcript is now session-aware: _load_history reloads the display history when the session id changes, so a resumed session shows its own conversation instead of the previous one. - Sessions tab: a "Resume in agent" button per session (active one badged); /ws clients reload on `session_changed`. Note: resume restores embryos + conversation; restarting a previously-running timelapse on resume is the separate V2 auto-resume item (not yet wired). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/agent_ws.py | 21 ++++-- gently/ui/web/routes/sessions.py | 97 ++++++++++++++++++++++------ gently/ui/web/static/css/review.css | 27 ++++++++ gently/ui/web/static/js/review.js | 22 ++++++- gently/ui/web/static/js/websocket.js | 4 ++ 5 files changed, 143 insertions(+), 28 deletions(-) diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index 570c112a..0ffd9d76 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -48,7 +48,7 @@ def create_router(server) -> APIRouter: # Persisted to /chat_display.json so it survives reconnects and # restarts; broadcast live so all instances stay in sync. _history: list = [] - _history_state = {"loaded": False, "path": None, "agent_buf": None} + _history_state = {"sid": None, "path": None, "agent_buf": None} async def _broadcast_control_status(): """Tell every connected agent client who currently holds control.""" @@ -65,14 +65,23 @@ async def _broadcast_control_status(): except Exception: pass - def _load_history_once(bridge): - if _history_state["loaded"]: - return - _history_state["loaded"] = True + def _load_history_for_session(bridge): + """Load the current session's display history, reloading if the + session changed (e.g. after a resume from the Sessions tab).""" try: agent = bridge.agent store = getattr(agent, "store", None) sid = getattr(agent, "session_id", None) + except Exception: + return + if sid == _history_state["sid"]: + return # already loaded for this session + # Session changed (or first load): reset and reload from disk. + _history.clear() + _history_state["sid"] = sid + _history_state["path"] = None + _history_state["agent_buf"] = None + try: if store and sid: sdir = store._session_dir(sid) if sdir: @@ -548,7 +557,7 @@ def choice_future_factory(choice_data: dict) -> asyncio.Future: # Replay the uniform session transcript so every client (and every # reconnect/refresh) shows the same conversation. - _load_history_once(bridge) + _load_history_for_session(bridge) if _history: try: await websocket.send_json({"type": "history", "items": list(_history)}) diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index 69d70d47..9447738a 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -1,9 +1,11 @@ -"""Session routes - list and retrieve saved sessions.""" +"""Session routes - list, retrieve, and resume saved sessions.""" import json import logging -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException + +from gently.ui.web.auth import require_control logger = logging.getLogger(__name__) @@ -11,28 +13,83 @@ def create_router(server) -> APIRouter: router = APIRouter() + def _file_store(): + """The live FileStore (current Gently3 layout), via the agent.""" + bridge = getattr(server, "agent_bridge", None) + if bridge is not None and getattr(bridge, "agent", None) is not None: + st = getattr(bridge.agent, "store", None) + if st is not None: + return st + return getattr(server, "gently_store", None) + + def _active_session_id(): + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + return getattr(agent, "session_id", None) if agent is not None else None + @router.get("/api/sessions") async def list_sessions(): - """List available sessions with metadata""" + """List available sessions (from the live FileStore).""" + store = _file_store() + if store is None: + return {"sessions": []} + active_id = _active_session_id() sessions = [] - if server.sessions_dir.exists(): - for path in server.sessions_dir.glob("*.json"): + try: + for s in store.list_sessions(): + sid = s.get("session_id") try: - with open(path, encoding='utf-8') as f: - data = json.load(f) - sessions.append({ - 'session_id': data.get('session_id', path.stem), - 'name': data.get('name', path.stem), - 'created_at': data.get('created_at', ''), - 'last_active': data.get('last_active', ''), - 'embryo_count': len(data.get('embryo_states', {})), - 'description': data.get('description', '') - }) - except Exception as e: - logger.warning(f"Failed to read session {path}: {e}") - # Sort by created_at descending (newest first) - sessions.sort(key=lambda x: x.get('created_at', ''), reverse=True) - return {'sessions': sessions} + count = len(store.list_embryos(sid) or []) + except Exception: + count = 0 + sessions.append({ + "session_id": sid, + "name": s.get("name") or sid, + "created_at": s.get("created_at", ""), + "last_active": s.get("last_active", ""), + "embryo_count": count, + "description": s.get("description", ""), + "active": sid == active_id, + }) + except Exception as e: + logger.warning("Failed to list sessions from FileStore: %s", e) + return {"sessions": sessions} + + @router.post("/api/sessions/{session_id}/resume", + dependencies=[Depends(require_control)]) + async def resume_session(session_id: str): + """Switch the live agent to a different saved session. + + Reuses the same machinery as CLI resume (saves the current session, + loads the target's embryos + conversation). Then nudges all browser + clients to reload so they pick up the new session's state and + transcript. + """ + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + if agent is None: + raise HTTPException(status_code=503, detail="Agent not ready") + store = getattr(agent, "store", None) + if store is None or store.get_session(session_id) is None: + raise HTTPException(status_code=404, detail="Session not found") + if session_id == getattr(agent, "session_id", None): + return {"ok": True, "session_id": session_id, "active": True, + "note": "already active"} + try: + ok = agent.resume_session(session_id) + except Exception as e: + logger.exception("Session resume failed") + raise HTTPException(status_code=500, detail=f"resume failed: {e}") + if not ok: + raise HTTPException(status_code=500, detail="resume returned false") + # Tell every connected browser to reload — they'll reconnect to the + # new session's state (embryos, transcript). + try: + await server.manager.broadcast({"type": "session_changed", + "session_id": session_id}) + except Exception: + pass + return {"ok": True, "session_id": session_id, "active": True} @router.get("/api/sessions/{session_id}") async def get_session(session_id: str): diff --git a/gently/ui/web/static/css/review.css b/gently/ui/web/static/css/review.css index de2bd66d..735d42a5 100644 --- a/gently/ui/web/static/css/review.css +++ b/gently/ui/web/static/css/review.css @@ -443,3 +443,30 @@ color: var(--text-muted); } + +/* Resume-in-agent action on session list items */ +.session-resume-btn { + margin-top: 8px; + padding: 5px 10px; + border-radius: 7px; + border: 1px solid var(--accent, #60a5fa); + background: transparent; + color: var(--accent, #60a5fa); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.session-resume-btn:hover { background: var(--accent, #60a5fa); color: #fff; } +.session-active-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--accent-green, #4ade80); + border: 1px solid var(--accent-green, #4ade80); + border-radius: 999px; + padding: 1px 7px; + margin-left: 6px; + vertical-align: middle; +} +.session-item.active-session { border-left: 2px solid var(--accent-green, #4ade80); } diff --git a/gently/ui/web/static/js/review.js b/gently/ui/web/static/js/review.js index bd2c75fa..07dcf49e 100644 --- a/gently/ui/web/static/js/review.js +++ b/gently/ui/web/static/js/review.js @@ -86,17 +86,35 @@ const ReviewApp = { } list.innerHTML = filtered.map(s => ` -
-
${this.escapeHtml(s.name || s.session_id)}
+
+
${this.escapeHtml(s.name || s.session_id)}${s.active ? ' active' : ''}
${this.formatDate(s.created_at)} ${s.embryo_count ? `${s.embryo_count} embryo${s.embryo_count !== 1 ? 's' : ''}` : ''}
${s.description ? `
${this.escapeHtml(s.description)}
` : ''} + ${s.active ? '' : ``}
`).join(''); }, + async resumeSession(sessionId) { + if (!confirm('Switch the live agent to this session?\nThe current session is saved first.')) return; + try { + const resp = await fetch(`/api/sessions/${sessionId}/resume`, { method: 'POST' }); + if (resp.ok) { + // Server broadcasts session_changed to reload all clients; we + // navigate home as well so the operator lands on the new session. + window.location.href = '/'; + } else { + const d = await resp.json().catch(() => ({})); + alert('Resume failed: ' + (d.detail || ('HTTP ' + resp.status))); + } + } catch (e) { + alert('Resume failed: ' + e); + } + }, + renderSessionContent() { const content = document.getElementById('session-content'); const s = this.currentSession; diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index 38a2794c..1e3a534c 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -127,6 +127,10 @@ function handleMessage(msg) { // Switch to embryos tab if not already there if (state.tab !== 'embryos') switchTab('embryos'); } + } else if (msg.type === 'session_changed') { + // The live agent switched sessions (resume from the Sessions tab) — + // reload so every client picks up the new session's state + transcript. + window.location.href = '/'; } else if (msg.type === 'ping') { state.ws.send(JSON.stringify({type: 'pong'})); } else if (msg.type === 'presence') { From a14372dfd8a16ec2d13b1b1f204dd80ffb03c2d4 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 05:39:42 -0400 Subject: [PATCH 036/314] fix(config): friendly errors for unknown organism/hardware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_organism / load_hardware raised a raw ModuleNotFoundError on a typo'd config value. Now they raise a clear ValueError listing the available plugins ("Unknown organism 'celgans'. Available: celegans. Set 'organism:' in config/config.yml.") — while re-raising if a dependency *inside* a valid plugin module is what's actually missing, so real import errors aren't masked. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/hardware/__init__.py | 25 +++++++++++++++++++++++-- gently/organisms/__init__.py | 26 ++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/gently/hardware/__init__.py b/gently/hardware/__init__.py index 2f07e9ce..c8886da7 100644 --- a/gently/hardware/__init__.py +++ b/gently/hardware/__init__.py @@ -14,14 +14,24 @@ import importlib import logging +import pkgutil from types import ModuleType -from typing import Optional +from typing import List, Optional logger = logging.getLogger(__name__) _active_hardware: Optional[ModuleType] = None +def available_hardware() -> List[str]: + """Names of the hardware plugins shipped under gently.hardware.""" + import gently.hardware as _pkg + return sorted( + m.name for m in pkgutil.iter_modules(_pkg.__path__) + if m.ispkg and not m.name.startswith("_") + ) + + def load_hardware(name: str) -> ModuleType: """ Load a hardware module by name and set it as active. @@ -43,7 +53,18 @@ def load_hardware(name: str) -> ModuleType: If the hardware module cannot be found. """ global _active_hardware - module = importlib.import_module(f"gently.hardware.{name}") + try: + module = importlib.import_module(f"gently.hardware.{name}") + except ModuleNotFoundError as e: + # Only a missing hardware *package* is a config error; re-raise if a + # dependency inside the module is what's missing. + if e.name in (f"gently.hardware.{name}", name): + avail = ", ".join(available_hardware()) or "(none found)" + raise ValueError( + f"Unknown hardware '{name}'. Available: {avail}. " + f"Set 'hardware:' in config/config.yml." + ) from e + raise _active_hardware = module logger.info("Loaded hardware module: %s", name) return module diff --git a/gently/organisms/__init__.py b/gently/organisms/__init__.py index d388ab60..e4f9d522 100644 --- a/gently/organisms/__init__.py +++ b/gently/organisms/__init__.py @@ -14,14 +14,24 @@ import importlib import logging +import pkgutil from types import ModuleType -from typing import Optional +from typing import List, Optional logger = logging.getLogger(__name__) _active_organism: Optional[ModuleType] = None +def available_organisms() -> List[str]: + """Names of the organism plugins shipped under gently.organisms.""" + import gently.organisms as _pkg + return sorted( + m.name for m in pkgutil.iter_modules(_pkg.__path__) + if m.ispkg and not m.name.startswith("_") + ) + + def load_organism(name: str) -> ModuleType: """ Load an organism module by name and set it as active. @@ -43,7 +53,19 @@ def load_organism(name: str) -> ModuleType: If the organism module cannot be found. """ global _active_organism - module = importlib.import_module(f"gently.organisms.{name}") + try: + module = importlib.import_module(f"gently.organisms.{name}") + except ModuleNotFoundError as e: + # Only treat a missing organism *package* as a config error; if a + # dependency *inside* the organism module is missing, re-raise so the + # real ImportError isn't masked. + if e.name in (f"gently.organisms.{name}", name): + avail = ", ".join(available_organisms()) or "(none found)" + raise ValueError( + f"Unknown organism '{name}'. Available: {avail}. " + f"Set 'organism:' in config/config.yml." + ) from e + raise _active_organism = module logger.info("Loaded organism module: %s (%s)", name, module.ORGANISM_DISPLAY_NAME) return module From 638f827ed3106dbb66ea4c42f3ffd3e24f1760dc Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 05:41:16 -0400 Subject: [PATCH 037/314] fix(web): serve session review data from FileStore GET /api/sessions/{id} still read sessions_dir/{id}.json (old layout), so clicking a session in the Sessions tab 404'd. Map the FileStore session snapshot onto the review view's shape (embryo_states from experiment_data, conversation from conversation_history). detection_history is left empty for now (per-timepoint predictions live in a separate store). Drops the now-unused json import. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/sessions.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index 9447738a..d47a146b 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -1,6 +1,5 @@ """Session routes - list, retrieve, and resume saved sessions.""" -import json import logging from fastapi import APIRouter, Depends, HTTPException @@ -93,14 +92,29 @@ async def resume_session(session_id: str): @router.get("/api/sessions/{session_id}") async def get_session(session_id: str): - """Get full session state for review""" - path = server.sessions_dir / f"{session_id}.json" - if not path.exists(): + """Get session state for review, from the live FileStore. + + Maps the FileStore session snapshot onto the shape the Sessions review + view expects (embryo_states / conversation). detection_history isn't + reconstructed here (per-timepoint predictions live elsewhere). + """ + store = _file_store() + if store is None: + raise HTTPException(status_code=503, detail="Store not available") + info = store.get_session(session_id) + if info is None: raise HTTPException(status_code=404, detail="Session not found") - try: - with open(path, encoding='utf-8') as f: - return json.load(f) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to load session: {e}") + snapshot = store.load_session_snapshot(session_id) or {} + experiment = snapshot.get("experiment_data", {}) or {} + return { + "session_id": session_id, + "name": info.get("name") or session_id, + "description": info.get("description", ""), + "created_at": info.get("created_at", ""), + "last_active": info.get("last_active", ""), + "embryo_states": experiment.get("embryos", {}) or {}, + "conversation": snapshot.get("conversation_history", []) or [], + "detection_history": {}, + } return router From cf2c24e212cc6191120a39ffdc863fbb5f09e4c2 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 05:56:15 -0400 Subject: [PATCH 038/314] fix(web): resumed sessions show history + correct session id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the confusing "resume shows an empty session" experience (the data was always safe on disk; it just wasn't surfaced): - Chat history: when a session has no chat_display.json (created before the uniform-transcript feature, or resumed for the first time), derive a best-effort transcript from the saved Claude conversation (conversation_history) so the chat panel shows the prior conversation instead of nothing. - Header session id: the top-left session label is driven by the timelapse tracker's session_id, which goes stale after a resume with no active timelapse — so the header showed the launch session, not the resumed one. The /ws connect now overrides it with the live agent session_id. Still open (separate, larger): rehydrating the viz image/projection/trace stores from disk on resume so a resumed session's volumes/traces/calibration actually display, and the apparent embryo double-count on resume. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/agent_ws.py | 26 ++++++++++++++++++++++++++ gently/ui/web/routes/websocket.py | 11 +++++++++++ 2 files changed, 37 insertions(+) diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index 0ffd9d76..0d27a5d0 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -94,6 +94,32 @@ def _load_history_for_session(bridge): except Exception: logger.debug("Could not load chat history", exc_info=True) + # Fallback: sessions created before chat_display.json existed (or any + # session resumed for the first time) — derive a best-effort transcript + # from the saved Claude conversation so the chat still shows history. + if not _history and store and sid: + try: + snap = store.load_session_snapshot(sid) or {} + for m in (snap.get("conversation_history") or []): + role = m.get("role") + content = m.get("content") + if isinstance(content, list): + text = "".join( + b.get("text", "") for b in content + if isinstance(b, dict) and b.get("type") == "text" + ) + else: + text = content if isinstance(content, str) else "" + text = (text or "").strip() + if not text: + continue + if role == "user": + _history.append({"role": "user", "text": text}) + elif role == "assistant": + _history.append({"role": "agent", "text": text}) + except Exception: + logger.debug("Could not derive history from conversation", exc_info=True) + def _save_history(): p = _history_state["path"] if not p: diff --git a/gently/ui/web/routes/websocket.py b/gently/ui/web/routes/websocket.py index 3dce3320..3b35f3e1 100644 --- a/gently/ui/web/routes/websocket.py +++ b/gently/ui/web/routes/websocket.py @@ -56,6 +56,17 @@ async def websocket_endpoint(websocket: WebSocket): # Always send timelapse state on connect so client can reconcile # (if IDLE with no session_id, client will clear stale cached state) timelapse_state = server.timelapse_tracker.to_dict() + # The header's session id is driven by this payload; the tracker's + # session_id goes stale after a resume with no active timelapse, so + # override it with the live agent session (the source of truth). + try: + bridge = getattr(server, "agent_bridge", None) + if bridge is not None and getattr(bridge, "agent", None) is not None: + live_sid = bridge.agent.session_id + if live_sid: + timelapse_state["session_id"] = live_sid + except Exception: + pass await websocket.send_json({ "type": "timelapse_state", "data": timelapse_state From df3db33d5e0a50512e6e3efe37c3dfd3c415d89d Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 06:02:01 -0400 Subject: [PATCH 039/314] feat(web): rehydrate viz imagery from disk on session resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming a session restored embryo state but not the visual data: the viz image store is in-memory (filled by live acquisition events), so a resumed session's volumes/projections never appeared in the galleries/filmstrips even though they were safe on disk. - VisualizationServer.rehydrate_session(): enumerates the session's embryos x projection timepoints from the FileStore and adds lightweight ImageData entries (uid volume_{embryo}_t{NNNN}, no base64). JPEG pixels load lazily via /api/images/{uid}/png, which already falls back to the FileStore projection. Resets the store first so the previous session's images don't linger. - FileStore.list_projection_timepoints(): cheap glob-only timepoint listing (avoids list_projections' per-file PIL/meta cost — matters for sessions with thousands of projections). - _resolve_projection_path now uses the live agent session (via a new _current_session_id helper) instead of the stale timelapse-tracker session, so lazy projection serving targets the resumed session's folder. - Wired into the resume endpoint and CLI --resume startup. Verified against a real 256-projection session: all 256 rehydrate, filmstrip sequences resolve, and the on-disk JPEGs serve. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/core/file_store.py | 21 ++++++++++++ gently/ui/web/routes/sessions.py | 12 +++++-- gently/ui/web/server.py | 56 ++++++++++++++++++++++++++++++-- launch_gently.py | 7 ++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/gently/core/file_store.py b/gently/core/file_store.py index 0231c14c..4b33dedd 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -792,6 +792,27 @@ def get_projection_path( return proj_path return None + def list_projection_timepoints( + self, session_id: str, embryo_id: str + ) -> List[int]: + """Cheaply list projection timepoints (glob only, no PIL/meta reads). + + Used to rehydrate the viz image store on resume without paying the + per-file cost of list_projections(). + """ + sd = self._session_dir(session_id) + if sd is None: + return [] + proj_dir = sd / "embryos" / embryo_id / "projections" + if not proj_dir.exists(): + return [] + tps: List[int] = [] + for jpg in proj_dir.glob("t*.jpg"): + m = re.match(r"t(\d+)\.jpg$", jpg.name) + if m: + tps.append(int(m.group(1))) + return sorted(tps) + def get_projection_b64( self, session_id: str, embryo_id: str, timepoint: int ) -> Optional[str]: diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index d47a146b..58f3394d 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -81,14 +81,22 @@ async def resume_session(session_id: str): raise HTTPException(status_code=500, detail=f"resume failed: {e}") if not ok: raise HTTPException(status_code=500, detail="resume returned false") + # Rehydrate the viz image store from disk so the resumed session's + # projections/filmstrips show (pixels load lazily from the FileStore). + rehydrated = 0 + try: + rehydrated = server.rehydrate_session(session_id) + except Exception: + logger.exception("rehydrate_session failed") # Tell every connected browser to reload — they'll reconnect to the - # new session's state (embryos, transcript). + # new session's state (embryos, transcript, rehydrated imagery). try: await server.manager.broadcast({"type": "session_changed", "session_id": session_id}) except Exception: pass - return {"ok": True, "session_id": session_id, "active": True} + return {"ok": True, "session_id": session_id, "active": True, + "rehydrated_projections": rehydrated} @router.get("/api/sessions/{session_id}") async def get_session(session_id: str): diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index ff14f9bb..957e98fc 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -214,12 +214,24 @@ def _resolve_volume_path(self, embryo_id: str, timepoint: int) -> Optional[str]: return None + def _current_session_id(self) -> Optional[str]: + """The live agent session (source of truth), falling back to the + timelapse tracker. The tracker's session_id goes stale after a resume + with no active timelapse, so the live agent session is preferred.""" + bridge = getattr(self, "agent_bridge", None) + if bridge is not None and getattr(bridge, "agent", None) is not None: + sid = getattr(bridge.agent, "session_id", None) + if sid: + return sid + return self.timelapse_tracker.session_id + def _resolve_projection_path(self, embryo_id: str, timepoint: int) -> Optional[Path]: - """Resolve projection file path from FileStore.""" - if self.gently_store and self.timelapse_tracker.session_id: + """Resolve projection file path from FileStore (current session).""" + sid = self._current_session_id() + if self.gently_store and sid: try: proj_path = self.gently_store.get_projection_path( - self.timelapse_tracker.session_id, embryo_id, timepoint, + sid, embryo_id, timepoint, ) if proj_path and proj_path.exists(): return proj_path @@ -227,6 +239,44 @@ def _resolve_projection_path(self, embryo_id: str, timepoint: int) -> Optional[P logger.debug(f"FileStore projection path lookup failed: {e}") return None + def rehydrate_session(self, session_id: str) -> int: + """Repopulate the in-memory image store with the FileStore's persisted + projections for a (resumed) session, so galleries and filmstrips show + its historical data. + + Lightweight: only metadata-bearing ImageData entries are created (uid + ``volume_{embryo}_t{NNNN}``); the JPEG pixels load lazily on demand via + /api/images/{uid}/png (which falls back to the FileStore projection). + Resets the store first so the previous session's images don't linger. + Returns the number of projection entries added. + """ + if self.gently_store is None or not session_id: + return 0 + self.store = ImageStore() # drop the previous session's images + added = 0 + try: + embryos = self.gently_store.list_embryos(session_id) or [] + except Exception: + embryos = [] + for emb in embryos: + eid = emb.get("embryo_id") if isinstance(emb, dict) else getattr(emb, "embryo_id", None) + if not eid: + continue + try: + tps = self.gently_store.list_projection_timepoints(session_id, eid) + except Exception: + tps = [] + for tp in tps: + self.store.add_image(ImageData( + uid=f"volume_{eid}_t{tp:04d}", + data_type="volume_projection", + timestamp=f"{tp:06d}", # monotonic with timepoint for ordering + metadata={"embryo_id": eid, "timepoint": tp}, + )) + added += 1 + logger.info("Rehydrated %d projections for session %s", added, session_id) + return added + def _subscribe_to_events(self): """Subscribe to EventBus for automatic updates - broadcasts ALL events""" diff --git a/launch_gently.py b/launch_gently.py index 6caa3cba..b6523bb2 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -438,6 +438,13 @@ def _status_provider(): if agent.viz_server is not None: agent.viz_server.agent_bridge = bridge agent.viz_server.set_context_store(context_store) + # If launched into an existing session, rehydrate its persisted + # imagery so the galleries/filmstrips show data from the start. + if session_to_resume: + try: + agent.viz_server.rehydrate_session(session_to_resume) + except Exception: + logger.debug("Startup rehydrate failed", exc_info=True) # ── Banner + serve ────────────────────────────────────────────── # The viz server runs in-process (uvicorn in a background task). With From c316399948af24448c58e2f620872aabc85dfba7 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 06:13:30 -0400 Subject: [PATCH 040/314] feat(web): rehydrate perception reasoning on resume (populates the views) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projection rehydration made the imagery serve, but the Default/Film/reasoning views stayed empty — those are driven by the tracker's detection_reasoning (per-timepoint stage + reasoning + image uid), not the raw image store, and resume never repopulated it. Extend rehydrate_session to load predictions.jsonl per embryo and rebuild the timelapse tracker's detection_reasoning + projection_uids + per-embryo timepoints/current_stage. The reasoning items carry projection uids (volume_{embryo}_t{NNNN}) so filmstrip thumbnails resolve via the lazy FileStore projection path. to_dict() already emits detection_reasoning, so on the post-resume reload the client populates the filmstrip, status board, and reasoning panels. Verified against the real session: 246 predictions rehydrate across 5 embryos with stage progressions (early -> bean -> comma -> 1.5fold) and full reasoning. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/server.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index 957e98fc..fc584cbc 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -274,6 +274,57 @@ def rehydrate_session(self, session_id: str) -> int: metadata={"embryo_id": eid, "timepoint": tp}, )) added += 1 + + # Rehydrate the timelapse tracker's per-embryo perception state from + # predictions.jsonl so the Default / Film / reasoning views populate + # (those are driven by detection_reasoning, not the raw image store). + # Thumbnails resolve via the projection uids added above. + tracker = self.timelapse_tracker + try: + tracker.session_id = session_id + tracker.detection_reasoning = {} + tracker.projection_uids = {} + for emb in embryos: + eid = emb.get("embryo_id") if isinstance(emb, dict) else getattr(emb, "embryo_id", None) + if not eid: + continue + try: + preds = self.gently_store.get_predictions(session_id, eid) or [] + except Exception: + preds = [] + if not preds: + continue + items, puids, last_stage = [], {}, None + for p in preds: + tp = p.get("timepoint") + if tp is None: + continue + uid = f"volume_{eid}_t{tp:04d}" + puids[tp] = uid + stage = p.get("predicted_stage") + last_stage = stage or last_stage + items.append({ + "timepoint": tp, + "stage": stage, + "detected_stage": stage, + "reasoning": p.get("reasoning"), + "confidence": p.get("confidence"), + "projection_uid": uid, + "image_uid": uid, + "detector_name": "perception", + }) + tracker.detection_reasoning[eid] = items + tracker.projection_uids[eid] = puids + entry = tracker.embryos.setdefault(eid, { + "embryo_id": eid, "timepoints": 0, "is_complete": False, + "detections": {}, "current_stage": None, + }) + entry["timepoints"] = max((it["timepoint"] for it in items), default=0) + entry["current_stage"] = last_stage + tracker.total_timepoints = sum(len(v) for v in tracker.detection_reasoning.values()) + except Exception: + logger.exception("Tracker perception rehydration failed") + logger.info("Rehydrated %d projections for session %s", added, session_id) return added From 6d3cc12024a1db7a6af00c80d91ad388b41b0d4f Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:07:54 -0400 Subject: [PATCH 041/314] feat(web): filmstrip reasoning/detail panel on the side, not the bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Film view stacked the rows above the detail (reasoning) panel, so it sat at the bottom. Lay #view-filmstrip out as a flex row: scrollable rows on the left, the reasoning/detail panel pinned to the right (flex 0 0 360px, own scroll, border-left). The panel is hidden via :empty until a cell is clicked, so the rows use the full width when nothing is selected. Restores the side-panel layout (the weekend change appears to have been lost — no commit for it in history, likely uncommitted). CSS-only: a hard refresh picks it up. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/main.css | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index ea8dd142..fa584e30 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -3532,6 +3532,13 @@ kbd { padding: 0; } +/* Filmstrip: rows on the left, reasoning/detail panel pinned on the right. */ +.view-filmstrip { + display: flex; + flex-direction: row; + overflow: hidden; /* the two children scroll internally */ +} + /* ======================================== AMBIENT HEALTH PULSE ======================================== */ @@ -3714,8 +3721,11 @@ kbd { scrollbar shared by all rows. Labels pin to the left via position:sticky inside each row. */ display: block; + flex: 1 1 auto; /* take the space left of the detail panel */ + min-width: 0; /* allow shrinking so the panel keeps its width */ + min-height: 0; overflow-x: auto; - overflow-y: hidden; + overflow-y: auto; scrollbar-width: thin; scrollbar-gutter: stable; position: relative; @@ -3871,17 +3881,17 @@ kbd { } .filmstrip-detail { + /* Side panel: pinned to the right of the rows, full height, own scroll. + Hidden until a cell is clicked (see :empty below) so the rows use the + full width when nothing is selected. */ + flex: 0 0 360px; + max-width: 42%; + border-left: 1px solid var(--border); background: var(--bg-card); - /* Cap the detail panel so it always leaves the rows visible AND - has a scrollable body of its own. Without this, when an item is - expanded the detail panel can grow past the viewport bottom and - the parent's scroll is unintuitive (mouse wheel over the rows - converts to horizontal). max-height keeps it bounded; overflow-y - lets long VLM summaries scroll on their own. */ - max-height: 60vh; overflow-y: auto; overscroll-behavior: contain; } +.filmstrip-detail:empty { display: none; } /* ======================================== VITALS VIEW From 8e3d26643df4452624b65db52c50b95807742f9d Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:18:13 -0400 Subject: [PATCH 042/314] feat(web): restore the original filmstrip side-panel CSS (from lost WIP) Replace my simpler side-panel layout with the polished version recovered from the orphaned WIP commit 0269e18d (preserved as branch recovered-filmstrip-wip): responsive width clamp(360px, 32vw, 520px), slide-in animation, :empty collapse, and the #filmstrip-detail .detail-split vertical stack. The JS detail rendering was already present in 0.21-dev, so this is CSS-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/main.css | 33 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index fa584e30..aeb46a01 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -3532,11 +3532,13 @@ kbd { padding: 0; } -/* Filmstrip: rows on the left, reasoning/detail panel pinned on the right. */ +/* Filmstrip: rows on the left, reasoning/detail panel pinned on the right. + (Recovered from the lost WIP commit 0269e18d.) */ .view-filmstrip { display: flex; flex-direction: row; - overflow: hidden; /* the two children scroll internally */ + align-items: stretch; + overflow: hidden; } /* ======================================== @@ -3721,9 +3723,8 @@ kbd { scrollbar shared by all rows. Labels pin to the left via position:sticky inside each row. */ display: block; - flex: 1 1 auto; /* take the space left of the detail panel */ - min-width: 0; /* allow shrinking so the panel keeps its width */ - min-height: 0; + flex: 1 1 0; /* flex-1 child: shrinks/grows as the panel opens */ + min-width: 0; overflow-x: auto; overflow-y: auto; scrollbar-width: thin; @@ -3881,17 +3882,27 @@ kbd { } .filmstrip-detail { - /* Side panel: pinned to the right of the rows, full height, own scroll. - Hidden until a cell is clicked (see :empty below) so the rows use the - full width when nothing is selected. */ - flex: 0 0 360px; - max-width: 42%; - border-left: 1px solid var(--border); background: var(--bg-card); + /* Right-side panel: fixed-ish width that shrinks gracefully on narrow + viewports. Scrolls vertically inside itself so long VLM summaries + don't push the layout. When empty (no frame selected) it collapses + entirely so the rows get full width. (Recovered from WIP 0269e18d.) */ + flex: 0 0 auto; + width: clamp(360px, 32vw, 520px); + border-left: 1px solid var(--border); overflow-y: auto; overscroll-behavior: contain; + animation: filmstripDetailIn 0.18s ease-out; } .filmstrip-detail:empty { display: none; } +@keyframes filmstripDetailIn { + from { transform: translateX(8px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +/* In the narrow side panel, stack the image | VLM summary split vertically. */ +#filmstrip-detail .detail-split { + grid-template-columns: 1fr; +} /* ======================================== VITALS VIEW From 0c69e55cfb43c050f74aae9b369644fba5f7b5f9 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:26:35 -0400 Subject: [PATCH 043/314] fix(imaging): stored projection is the three-view of View A, not A|B MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate_jpeg_projection used extract_view_a_and_max_project, which only splits views for 4D input. Volumes are stored 3D (Z, Y, X) with the two diSPIM views concatenated along X (width = 2*height), so it max-projected BOTH views -> the A|B side-by-side image, as a flat XY max. Now: pick View A (index 0 of 4D, or the left half of a 3D dual-view), then build the three-orthogonal-view layout (projection_three_view: [XY|YZ] over [XZ]) — matching what the perceiver sees and what we intend to store. Also fixes near-black filmstrip thumbnails (a single cropped view fills the frame instead of a tiny embryo in a wide A|B canvas). 2D/other inputs fall back to a percentile-stretched max projection. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/core/imaging.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/gently/core/imaging.py b/gently/core/imaging.py index 6e830351..bc74ec26 100644 --- a/gently/core/imaging.py +++ b/gently/core/imaging.py @@ -282,9 +282,23 @@ def generate_jpeg_projection( return None try: - max_proj = extract_view_a_and_max_project(volume) - normalized = normalize_to_uint8(max_proj, method="percentile", - p_low=1, p_high=99.5) + # Reduce to a single 3D view, then build the three-orthogonal-view + # layout (the projection we actually want — matches what the + # perceiver sees). View A comes from index 0 of a 4D (Views,Z,Y,X) + # volume; a 3D dual-view volume stores the two views concatenated + # along X (width >= 2*height), so take the left half (View A) — + # otherwise the projection is an A|B side-by-side, not a clean view. + vol = np.squeeze(volume) + if vol.ndim == 4: + vol = vol[0] + if vol.ndim == 3: + z, h, w = vol.shape + if w >= 2 * h: + vol = vol[:, :, : w // 2] + normalized, _ = projection_three_view(vol) + else: + normalized = normalize_to_uint8(vol, method="percentile", + p_low=1, p_high=99.5) pil_image = Image.fromarray(normalized) From e6646fd3c9c8faa23c36a9da8a8e1745f50905ec Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:29:51 -0400 Subject: [PATCH 044/314] =?UTF-8?q?feat(scripts):=20regenerate=5Fprojectio?= =?UTF-8?q?ns=20=E2=80=94=20refresh=20a=20session's=20projections=20from?= =?UTF-8?q?=20volumes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-off tool to rewrite a session's projections/t*.jpg from its stored volume TIFFs using the current generate_jpeg_projection (e.g. after the three-view fix, to refresh older sessions whose on-disk projections are the stale A|B dual-view). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/regenerate_projections.py | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 scripts/regenerate_projections.py diff --git a/scripts/regenerate_projections.py b/scripts/regenerate_projections.py new file mode 100644 index 00000000..54e07281 --- /dev/null +++ b/scripts/regenerate_projections.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Regenerate JPEG projections for a session from its stored volumes. + +Use after the projection format changes (e.g. the three-view fix) to refresh +older sessions whose on-disk projections are stale (e.g. the old A|B +side-by-side max projection). Reads each volume TIFF and rewrites its +projections/t{NNNN}.jpg via the current generate_jpeg_projection. + +Usage: + python scripts/regenerate_projections.py + python scripts/regenerate_projections.py --embryo embryo_001 +""" + +import argparse +import re +import sys +from pathlib import Path + +from gently.settings import settings +from gently.core.imaging import load_volume, generate_jpeg_projection + + +def _session_dir(storage_base: Path, session_id: str) -> Path | None: + sessions = storage_base / "sessions" + if not sessions.exists(): + return None + # Folder names are {date}_{time}_{slug}_{id8}; match the id suffix. + matches = [p for p in sessions.glob(f"*{session_id}*") if p.is_dir()] + return matches[0] if matches else None + + +def regenerate(session_id: str, only_embryo: str | None = None) -> int: + base = Path(settings.storage.base_path) + sdir = _session_dir(base, session_id) + if sdir is None: + print(f"Session {session_id} not found under {base / 'sessions'}") + return 0 + embryos_dir = sdir / "embryos" + if not embryos_dir.exists(): + print(f"No embryos dir in {sdir}") + return 0 + + total = 0 + for emb_dir in sorted(d for d in embryos_dir.iterdir() if d.is_dir()): + if only_embryo and emb_dir.name != only_embryo: + continue + vol_dir = emb_dir / "volumes" + proj_dir = emb_dir / "projections" + if not vol_dir.exists(): + continue + tifs = sorted(vol_dir.glob("t*.tif")) + n = 0 + for vf in tifs: + m = re.match(r"(t\d+)", vf.stem) + if not m: + continue + proj_path = proj_dir / f"{m.group(1)}.jpg" + try: + vol = load_volume(vf) + if generate_jpeg_projection(vol, proj_path) is not None: + n += 1 + except Exception as e: + print(f" ! {vf.name}: {e}") + print(f" {emb_dir.name}: regenerated {n}/{len(tifs)} projections") + total += n + print(f"Done: {total} projections regenerated for {session_id}") + return total + + +if __name__ == "__main__": + ap = argparse.ArgumentParser(description="Regenerate session projections from volumes") + ap.add_argument("session_id") + ap.add_argument("--embryo", default=None, help="Only this embryo id") + args = ap.parse_args() + regenerate(args.session_id, args.embryo) From eead6384baac76fdc70697d6b6d55541c39619a5 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:30:07 -0400 Subject: [PATCH 045/314] fix(scripts): add project root to sys.path in regenerate_projections Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/regenerate_projections.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/regenerate_projections.py b/scripts/regenerate_projections.py index 54e07281..68110dbd 100644 --- a/scripts/regenerate_projections.py +++ b/scripts/regenerate_projections.py @@ -16,6 +16,11 @@ import sys from pathlib import Path +# Ensure the project root is importable when run as scripts/regenerate_projections.py +_ROOT = Path(__file__).resolve().parent.parent +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + from gently.settings import settings from gently.core.imaging import load_volume, generate_jpeg_projection From b2bf1f936b4e8947e1d8370f7440c2846d40ef3f Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:35:51 -0400 Subject: [PATCH 046/314] fix(web): content-aware cache for FileStore projections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-disk projections can be regenerated (e.g. after the three-view fix), but /api/images/{uid}/png served them with Cache-Control: immutable + a uid ETag, so the browser kept the stale image forever. For the FileStore path, use an mtime+size ETag and a short max-age so a regenerated projection is picked up without a hard refresh. (In-memory base64 images keep immutable — they don't change.) Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/images.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/routes/images.py b/gently/ui/web/routes/images.py index 2ecc805a..5d505b6a 100644 --- a/gently/ui/web/routes/images.py +++ b/gently/ui/web/routes/images.py @@ -102,15 +102,24 @@ async def get_image_png(uid: str): except Exception as e: logger.warning(f"Failed to load image {uid} from DataStore: {e}") - # Fallback to FileStore JPEG projections (persistent on-disk) + # Fallback to FileStore JPEG projections (persistent on-disk). + # Unlike the in-memory base64 images, an on-disk projection CAN change + # (e.g. regenerated after a projection-format fix), so we must NOT mark + # it immutable with a content-independent (uid) ETag — that pins the + # browser to the stale image. Use a content-aware ETag (mtime+size) + # and a short max-age so a regeneration is picked up. if server.gently_store and parsed: embryo_id, timepoint = parsed proj_path = server._resolve_projection_path(embryo_id, timepoint) if proj_path: + st = proj_path.stat() return FileResponse( str(proj_path), media_type="image/jpeg", - headers=cache_headers, + headers={ + "Cache-Control": "public, max-age=3600", + "ETag": f'"{uid}-{int(st.st_mtime)}-{st.st_size}"', + }, ) raise HTTPException(status_code=404, detail=f"Image {uid} not found") From 92406310cf68a35f6ff4d708d7cc9ce1588f821e Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:48:13 -0400 Subject: [PATCH 047/314] =?UTF-8?q?fix(imaging):=20don't=20split=203D=20vo?= =?UTF-8?q?lume=20by=20width=20=E2=80=94=20it=20halved=20centered=20embryo?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix split a 3D volume to the "left half" when width >= 2*height, assuming dual-view A|B concatenation. But the embryo is typically centered and straddles the X midline in a 2:1 (e.g. 512x1024) field, so the crop sliced it in half — the "XY rendered halfway" bug. Project the full 3D volume into the three orthogonal views instead; only an explicit 4D (Views,Z,Y,X) volume uses View A. Verified: the XY view now shows the complete embryo. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/core/imaging.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/gently/core/imaging.py b/gently/core/imaging.py index bc74ec26..9f5d2a96 100644 --- a/gently/core/imaging.py +++ b/gently/core/imaging.py @@ -282,19 +282,16 @@ def generate_jpeg_projection( return None try: - # Reduce to a single 3D view, then build the three-orthogonal-view - # layout (the projection we actually want — matches what the - # perceiver sees). View A comes from index 0 of a 4D (Views,Z,Y,X) - # volume; a 3D dual-view volume stores the two views concatenated - # along X (width >= 2*height), so take the left half (View A) — - # otherwise the projection is an A|B side-by-side, not a clean view. + # Build the three-orthogonal-view layout (the projection we actually + # want — matches what the perceiver sees). For an explicit 4D + # (Views, Z, Y, X) volume, use View A. For a 3D volume, project the + # whole thing — do NOT try to split views by aspect ratio: the embryo + # is often centered and straddles the X midline, so a width-based + # "dual-view" guess slices it in half (the XY-rendered-halfway bug). vol = np.squeeze(volume) if vol.ndim == 4: vol = vol[0] if vol.ndim == 3: - z, h, w = vol.shape - if w >= 2 * h: - vol = vol[:, :, : w // 2] normalized, _ = projection_three_view(vol) else: normalized = normalize_to_uint8(vol, method="percentile", From 95224f714dce2aba96776ed83b63ed42b6aa0298 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 07:52:24 -0400 Subject: [PATCH 048/314] docs: record dual-view readiness as a design constraint Co-Authored-By: Claude Opus 4.8 (1M context) --- notes/biologist-readiness-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/notes/biologist-readiness-plan.md b/notes/biologist-readiness-plan.md index 701a4fc5..03c25844 100644 --- a/notes/biologist-readiness-plan.md +++ b/notes/biologist-readiness-plan.md @@ -332,6 +332,8 @@ Incremental — the backend protocol for agent control already exists (`/ws/agen **Generalization:** per-sample **channels/wavelengths/per-line-power model** (+ objective/binning) into `EmbryoState`/volume metadata — the "looks like microscope software" deliverable *and* the stain generalizer; **parameterize `gently_perception`** for non-celegans (cross-repo); wire startup wizard → `load_organism()`; route `validation.py` through `get_organism()`; reconcile the two `DevelopmentalStage` enums. +**Dual-view readiness (design constraint).** All current data is single-view; dual-view is the intended general capability (single-view = `n_views == 1`). Today view B is dropped only at the explicit 4D `(Views,Z,Y,X)` → `vol[0]` path in `generate_jpeg_projection`. **Rule: view count/layout is declared by acquisition in volume metadata — never inferred from pixel shape** (an aspect-ratio "dual-view" guess sliced centered single-view embryos in half; fixed by removing it). To go dual-view later (additive): (1) device layer records `n_views`/layout in the volume sidecar; (2) uid scheme extends `volume_{embryo}_t{NNNN}` → optional `…_vA`/`…_vB` (bare form stays back-compat); (3) `generate_jpeg_projection` emits a three-view per present view; (4) filmstrip/detail panel + perception become view-aware (toggle / side-by-side / feed one or both). Nothing in the single-view path blocks this. + **Refactors (after their tests; medium risk):** `bridge.py` dispatch table + case-fold fix (high value for web control); `verifier.py` consensus dedup (capture truth-table fixtures first; preserve `ensemble_size=50` + conservative on-error defaults); `timelapse.py` rule `to_dict()/from_dict()` then `TimelapseStatePersister` + `RuleEngine`; `file_store.py` mixin split; `device_layer.py` `@json_endpoint`; `claude_client.py` `_vision_call`; consolidate pixel→stage math (duplicated 4×) into `gently.core.coordinates`; web `embryos.js` split (`copilot-chat.js`, `markdown.js`, `embryo-views.js`). --- From a356dfd43baeea43d553136cd6e16ec491e6d9dc Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 09:08:31 -0400 Subject: [PATCH 049/314] feat(launcher): open the web UI in Chrome (GENTLY_BROWSER override, default fallback) Co-Authored-By: Claude Opus 4.8 (1M context) --- launch_gently.py | 51 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/launch_gently.py b/launch_gently.py index b6523bb2..a2e590fa 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -122,6 +122,51 @@ def _print_banner(viz_url, device_connected, offline, storage_dir, log_file, res print() +def _open_browser(url: str) -> None: + """Open the web UI, preferring Google Chrome. + + Override with GENTLY_BROWSER (a webbrowser name like 'firefox', or a full + path to a browser executable). Falls back to the OS default browser if + Chrome can't be found, so this never blocks startup. + """ + import webbrowser + + override = os.environ.get("GENTLY_BROWSER", "").strip() + + # 1) Registered browser names (override first, then Chrome aliases). + for name in ([override] if override else []) + ["chrome", "google-chrome", "chromium"]: + try: + webbrowser.get(name).open(url) + return + except Exception: + pass + + # 2) Explicit executables (an override path, then known Chrome locations). + candidates = [override] if override else [] + candidates += [ + shutil.which("chrome"), + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + ] + for exe in candidates: + try: + if exe and Path(exe).exists(): + webbrowser.register( + "gently-browser", None, + webbrowser.BackgroundBrowser(exe), preferred=True, + ) + webbrowser.get("gently-browser").open(url) + return + except Exception: + pass + + # 3) Fall back to the OS default. + try: + webbrowser.open(url) + except Exception: + pass + + def run_ink_picker(tui_dist: Path, sessions_json: str) -> str | None: """ Spawn the Ink TUI in session-picker mode and capture the selection. @@ -467,11 +512,7 @@ def _status_provider(): print(" (Save this now. Add users via the admin API; GENTLY_NO_AUTH=1 disables auth.)\n") if viz_url and not no_browser: - try: - import webbrowser - webbrowser.open(viz_url) - except Exception: - pass + _open_browser(viz_url) # Keep the event loop alive so the in-process viz server keeps serving. # On Windows the Proactor loop won't surface Ctrl-C while blocked on a From 76505f4444d9c65cfb30e833e911f4b5d74277e9 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 09:34:49 -0400 Subject: [PATCH 050/314] feat(scripts): --all to regenerate projections for every session Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/regenerate_projections.py | 85 +++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/scripts/regenerate_projections.py b/scripts/regenerate_projections.py index 68110dbd..7b6241ff 100644 --- a/scripts/regenerate_projections.py +++ b/scripts/regenerate_projections.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -"""Regenerate JPEG projections for a session from its stored volumes. +"""Regenerate JPEG projections for a session (or all sessions) from volumes. Use after the projection format changes (e.g. the three-view fix) to refresh -older sessions whose on-disk projections are stale (e.g. the old A|B -side-by-side max projection). Reads each volume TIFF and rewrites its -projections/t{NNNN}.jpg via the current generate_jpeg_projection. +sessions whose on-disk projections are stale (e.g. the old flat-XY max +projection). Reads each volume TIFF and rewrites its projections/t{NNNN}.jpg +via the current generate_jpeg_projection. Usage: python scripts/regenerate_projections.py python scripts/regenerate_projections.py --embryo embryo_001 + python scripts/regenerate_projections.py --all """ import argparse @@ -25,26 +26,10 @@ from gently.core.imaging import load_volume, generate_jpeg_projection -def _session_dir(storage_base: Path, session_id: str) -> Path | None: - sessions = storage_base / "sessions" - if not sessions.exists(): - return None - # Folder names are {date}_{time}_{slug}_{id8}; match the id suffix. - matches = [p for p in sessions.glob(f"*{session_id}*") if p.is_dir()] - return matches[0] if matches else None - - -def regenerate(session_id: str, only_embryo: str | None = None) -> int: - base = Path(settings.storage.base_path) - sdir = _session_dir(base, session_id) - if sdir is None: - print(f"Session {session_id} not found under {base / 'sessions'}") - return 0 - embryos_dir = sdir / "embryos" +def _regen_folder(folder: Path, only_embryo: str | None = None) -> int: + embryos_dir = folder / "embryos" if not embryos_dir.exists(): - print(f"No embryos dir in {sdir}") return 0 - total = 0 for emb_dir in sorted(d for d in embryos_dir.iterdir() if d.is_dir()): if only_embryo and emb_dir.name != only_embryo: @@ -54,6 +39,8 @@ def regenerate(session_id: str, only_embryo: str | None = None) -> int: if not vol_dir.exists(): continue tifs = sorted(vol_dir.glob("t*.tif")) + if not tifs: + continue n = 0 for vf in tifs: m = re.match(r"(t\d+)", vf.stem) @@ -61,20 +48,62 @@ def regenerate(session_id: str, only_embryo: str | None = None) -> int: continue proj_path = proj_dir / f"{m.group(1)}.jpg" try: - vol = load_volume(vf) - if generate_jpeg_projection(vol, proj_path) is not None: + if generate_jpeg_projection(load_volume(vf), proj_path) is not None: n += 1 except Exception as e: - print(f" ! {vf.name}: {e}") - print(f" {emb_dir.name}: regenerated {n}/{len(tifs)} projections") + print(f" ! {vf.name}: {e}") + print(f" {emb_dir.name}: regenerated {n}/{len(tifs)} projections") total += n + return total + + +def _session_dir(storage_base: Path, session_id: str) -> Path | None: + sessions = storage_base / "sessions" + if not sessions.exists(): + return None + matches = [p for p in sessions.glob(f"*{session_id}*") if p.is_dir()] + return matches[0] if matches else None + + +def regenerate(session_id: str, only_embryo: str | None = None) -> int: + base = Path(settings.storage.base_path) + sdir = _session_dir(base, session_id) + if sdir is None: + print(f"Session {session_id} not found under {base / 'sessions'}") + return 0 + total = _regen_folder(sdir, only_embryo) print(f"Done: {total} projections regenerated for {session_id}") return total +def regenerate_all() -> int: + base = Path(settings.storage.base_path) + sessions = base / "sessions" + if not sessions.exists(): + print(f"No sessions dir at {sessions}") + return 0 + folders = sorted( + p for p in sessions.iterdir() + if p.is_dir() and any((p / "embryos").glob("*/volumes/t*.tif")) + ) + print(f"Regenerating projections for {len(folders)} session(s) with volumes...") + grand = 0 + for i, folder in enumerate(folders, 1): + print(f"[{i}/{len(folders)}] {folder.name}") + grand += _regen_folder(folder) + print(f"ALL DONE: {grand} projections regenerated across {len(folders)} sessions") + return grand + + if __name__ == "__main__": ap = argparse.ArgumentParser(description="Regenerate session projections from volumes") - ap.add_argument("session_id") + ap.add_argument("session_id", nargs="?", help="Session id (omit with --all)") + ap.add_argument("--all", action="store_true", help="Regenerate every session that has volumes") ap.add_argument("--embryo", default=None, help="Only this embryo id") args = ap.parse_args() - regenerate(args.session_id, args.embryo) + if args.all: + regenerate_all() + elif args.session_id: + regenerate(args.session_id, args.embryo) + else: + ap.error("provide a session_id or --all") From f87b3c9844d36efc1ee2b8dffda6250b865f2103 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 09:38:11 -0400 Subject: [PATCH 051/314] fix(web): filmstrip thumbnail crops to the XY embryo, not the black centre The stored projection is now a three-view ([XY|YZ] over [XZ]); object-fit:cover center-cropped the square thumb to the black XY|YZ divider, so thumbs looked empty. object-position: left center crops to the embryo-bearing XY/XZ column. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/main.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index aeb46a01..5fcab675 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -3859,6 +3859,10 @@ kbd { border-radius: 4px; border: 2px solid; object-fit: cover; + /* The stored projection is a three-view ([XY|YZ] over [XZ]); the embryo is + in the LEFT column (XY/XZ), and the centre is the black XY|YZ divider. + Crop to the left so the square thumbnail shows the embryo, not the gap. */ + object-position: left center; background: var(--bg-dark); } From 9d0790d8dc93b54d4f2f6f5d66e045110c17cc94 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 12:36:22 -0400 Subject: [PATCH 052/314] Add room-light toggle and device-layer terminal UI - Devices tab: SwitchBot room-light on/off button (web button + JS, gated route, device-layer handlers, and client methods). - Device layer: dependency-free curated console UI (console_ui.py) with starting/READY status panels and a plain-language hardware-failure diagnostic panel instead of a raw Python traceback when a device (e.g. a COM port) is powered off. ASCII-safe + color/Unicode capability detection for Windows consoles. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/hardware/console_ui.py | 187 +++++++++++++++++++++++++ gently/hardware/dispim/client.py | 14 ++ gently/hardware/dispim/device_layer.py | 151 ++++++++++++++++++++ gently/ui/web/routes/data.py | 41 ++++++ gently/ui/web/static/css/main.css | 42 ++++++ gently/ui/web/static/js/devices.js | 102 ++++++++++++++ gently/ui/web/templates/index.html | 12 ++ start_device_layer.py | 79 +++++++++-- 8 files changed, 617 insertions(+), 11 deletions(-) create mode 100644 gently/hardware/console_ui.py diff --git a/gently/hardware/console_ui.py b/gently/hardware/console_ui.py new file mode 100644 index 00000000..abc8ca64 --- /dev/null +++ b/gently/hardware/console_ui.py @@ -0,0 +1,187 @@ +"""Lightweight terminal styling for the device-layer console. + +Plain ``print`` to stdout, no third-party dependency. ``rich`` is deliberately +avoided here — it has caused Unicode/encoding issues on Windows consoles (see +the stdout-suppression note in ``dispim/device_layer.py``). + +The point of this module is to give the operator a readable, always-visible +picture of the device layer at the terminal — distinct from the file log. The +file log keeps the full INFO/DEBUG firehose; the console shows a curated set of +milestones and a status panel. + +Robust by construction, because the device layer runs on Windows consoles: + +* **Colour** (ANSI) is auto-disabled unless stdout is a TTY. On Windows we try + to enable virtual-terminal processing first; if that fails, colour is off so + raw escape codes never leak. ``NO_COLOR`` (https://no-color.org) and a + ``dumb`` ``TERM`` also disable it. +* **Box-drawing** glyphs are used only when stdout's encoding is UTF-based; + otherwise ASCII equivalents are used so a cp1252 console shows clean output. +* ``out()`` is defensive: any residual ``UnicodeEncodeError`` is caught and the + line re-emitted with ``errors="replace"`` rather than crashing startup. +""" +from __future__ import annotations + +import os +import sys + +# Visible width of the status panel (border rules). Content lines are written +# without a right border so coloured text never needs width arithmetic. +WIDTH = 64 + + +def _enable_windows_vt() -> bool: + """Best-effort: turn on ANSI escape handling for the Windows console. + + Returns True if VT processing is (now) enabled or we're not on Windows. + """ + if sys.platform != "win32": + return True + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.windll.kernel32 + ENABLE_VT = 0x0004 + handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + mode = wintypes.DWORD() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return False + return bool(kernel32.SetConsoleMode(handle, mode.value | ENABLE_VT)) + except Exception: + return False + + +def _detect_color() -> bool: + if sys.stdout is None or not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty(): + return False + if os.environ.get("NO_COLOR") is not None or os.environ.get("TERM") == "dumb": + return False + return _enable_windows_vt() + + +def _detect_unicode() -> bool: + enc = (getattr(sys.stdout, "encoding", None) or "").lower() + return "utf" in enc + + +_USE_COLOR = _detect_color() +_USE_UNICODE = _detect_unicode() + +# Glyphs: pretty (UTF) vs ASCII fallback. +if _USE_UNICODE: + _HEAVY, _LIGHT, _DOT, _CHECK, _MID, _BULLET = "═", "─", "●", "✓", "·", "•" +else: + _HEAVY, _LIGHT, _DOT, _CHECK, _MID, _BULLET = "=", "-", "*", "+", "-", "-" + +# Public separator for callers that build their own value strings. +MIDDOT = f" {_MID} " + +_CODES = { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "green": "\033[32m", + "cyan": "\033[36m", + "yellow": "\033[33m", + "red": "\033[31m", + "blue": "\033[34m", + "magenta": "\033[35m", + "grey": "\033[90m", +} + + +def supports_color() -> bool: + return _USE_COLOR + + +def c(text, *styles: str) -> str: + """Wrap *text* in ANSI styles, or return it unchanged when colour is off.""" + if not _USE_COLOR or not styles: + return str(text) + prefix = "".join(_CODES.get(s, "") for s in styles) + return f"{prefix}{text}{_CODES['reset']}" + + +def out(text: str = "") -> None: + """Print one line to stdout, flushing so it shows immediately. + + Never raises on encoding: a console that can't represent a character gets + a replacement rather than a crashed startup. + """ + try: + print(text, flush=True) + except UnicodeEncodeError: + enc = (getattr(sys.stdout, "encoding", None) or "ascii") + sys.stdout.write(text.encode(enc, "replace").decode(enc, "replace") + "\n") + sys.stdout.flush() + + +def rule(heavy: bool = True, style: str = "grey") -> None: + out(c((_HEAVY if heavy else _LIGHT) * WIDTH, style)) + + +def header(title: str, badge: str | None = None, badge_style: str = "yellow") -> None: + """Top of a panel: a heavy rule, a title row (optional right-aligned badge), + and a closing heavy rule.""" + rule(heavy=True) + line = " " + c(title, "bold", "cyan") + if badge: + # Right-align using uncoloured widths so padding ignores ANSI codes. + pad = max(1, WIDTH - len(" " + title) - len(badge) - 1) + line += " " * pad + c(badge, "bold", badge_style) + out(line) + rule(heavy=True) + + +def row(label: str, value: str, label_w: int = 12, label_style: str = "grey") -> None: + """A `` label value`` line inside a panel.""" + out(f" {c(label.ljust(label_w), label_style)}{value}") + + +def sub(label: str, value: str, label_w: int = 10) -> None: + """An indented sub-row, e.g. a device-group breakdown.""" + out(f" {c(label.ljust(label_w), 'grey')}{value}") + + +def step(n: int, total: int, label: str) -> None: + """A startup progress line: `` [2/5] Starting Micro-Manager core``""" + out(f" {c(f'[{n}/{total}]', 'cyan')} {label}") + + +def step_done(detail: str = "ok") -> None: + """A check-mark continuation under the most recent step.""" + out(f" {c(_CHECK, 'green')} {c(detail, 'grey')}") + + +def note(text: str, style: str = "grey") -> None: + out(f" {c(text, style)}") + + +def bullet(text: str) -> None: + out(f" {c(_BULLET, 'cyan')} {text}") + + +def error_panel(title: str, summary: str, details: str | None = None, + hints=None, log_file=None) -> None: + """A red FAILED panel: one-line summary, optional detail, fix hints, log path. + + Used at the top-level startup catch so an operator sees a plain-language + diagnosis instead of a Python traceback (which still goes to the log file). + """ + out() + header(title, badge="FAILED", badge_style="red") + note(summary, "yellow") + if details: + out() + row("Details", details, label_w=10) + if hints: + out() + note("Try this:", "bold") + for h in hints: + bullet(h) + if log_file: + out() + row("Full log", str(log_file), label_w=10) + rule(heavy=True) + out() diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 03ff8009..2490dc24 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -802,6 +802,20 @@ async def get_led_status(self) -> Dict: """Get current LED status.""" return await self._api_get('/api/led/status') + async def set_room_light(self, state: str = 'off') -> Dict: + """Switch the diSPIM room light on/off via the SwitchBot Bot. + + Hits ``POST /api/room_light/set`` directly (no Bluesky queue, no + experiment trace) — a setup accessory poke. ``state`` is + 'on' | 'off' | 'press'. Blocks at the device layer until the BLE + command lands (~1-2 s). + """ + return await self._api_post('/api/room_light/set', {'state': state}) + + async def get_room_light_status(self) -> Dict: + """Read the room light's cached on/off state (no BLE round-trip).""" + return await self._api_get('/api/room_light/status') + async def set_temperature(self, target_c: float) -> Dict: """Command the thermal-controller setpoint (Celsius). Non-blocking — the controller ramps; poll get_temperature() for the lock state.""" diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index a1af5e4a..3b50aded 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -44,6 +44,7 @@ from gently.exceptions import HardwareError, AcquisitionError from gently.log_config import configure_logging from gently.settings import settings +from gently.hardware import console_ui as cui # Bluesky imports from bluesky import RunEngine @@ -184,15 +185,21 @@ async def initialize(self): logger.info("GENTLY DEVICE LAYER") logger.info("=" * 60) + cui.out() + cui.note("Starting device layer...", "bold") + # [1/5] Load config + cui.step(1, 5, "Loading configuration") logger.info("[1/5] Loading configuration...") with open(self.config_path, 'r') as f: self.config = yaml.safe_load(f) logger.info("Config loaded from %s", self.config_path) + cui.step_done(str(self.config_path)) # [2/5] MMCore initialization, routed through the DiSPIMSystem facade # so this process never touches `core.*` directly outside the # devices/ package. + cui.step(2, 5, "Initializing Micro-Manager core") logger.info("[2/5] Initializing Micro-Manager Core (direct)...") from .devices.system import DiSPIMSystem @@ -215,6 +222,7 @@ async def initialize(self): self.system.load_system_configuration(mm_config_path) logger.info("MMCore initialized (direct, in-process)") logger.info("Loaded devices: %s", self.system.get_loaded_devices()) + cui.step_done(Path(mm_config_path).name) # Register MMCore event callback so we get push notifications for # property changes, stage moves, exposure changes, etc. — anything the @@ -223,6 +231,7 @@ async def initialize(self): self._register_mmcore_callbacks() # [3/5] Create Ophyd devices + cui.step(3, 5, "Creating devices") logger.info("[3/5] Creating Ophyd devices...") from .device_factory import create_devices_from_mmcore # Suppress rich console output to avoid Unicode issues on Windows @@ -270,6 +279,8 @@ async def initialize(self): except Exception as exc: logger.warning("Could not create temperature controller: %s", exc) + cui.step_done(f"{len(self.devices)} devices") + # Push XY safety bounds down to the ASI Tiger firmware so the joystick # can't drive past Layer-1 software limits. The XY_STAGE_*_UM constants # in devices/stage.py are the single source of truth — both the @@ -320,6 +331,7 @@ async def initialize(self): logger.error("Could not enable XY joystick: %s", exc) # [4/5] Initialize RunEngine + cui.step(4, 5, "Initializing RunEngine") logger.info("[4/5] Initializing RunEngine...") self.RE = RunEngine({}) @@ -381,10 +393,13 @@ def collect_docs(name, doc): self.RE.subscribe(collect_docs) logger.info("RunEngine ready") + cui.step_done("ready") # [5/5] Load plans + cui.step(5, 5, "Loading plans") logger.info("[5/5] Loading plans...") self._load_plans() + cui.step_done(f"{len(self.plans)} plans") logger.info("=" * 60) logger.info("Device layer initialized successfully") @@ -1327,6 +1342,77 @@ async def handle_set_led(self, request): 'traceback': traceback.format_exc() }, status=500) + def _room_light_device(self): + """Resolve the room-light SwitchBot from the device registry. + + Prefers the conventional 'room_light' key (config.yml name), but + falls back to scanning for any SwitchBot instance so a differently + named bot still works. Returns None when no bot is configured. + """ + bot = self.devices.get('room_light') + if bot is not None: + return bot + try: + from gently.hardware.switchbot import SwitchBot + except Exception: + return None + for dev in self.devices.values(): + if isinstance(dev, SwitchBot): + return dev + return None + + async def handle_get_room_light_status(self, request): + """GET /api/room_light/status - cached on/off state of the room light. + + Reads the SwitchBot's last-commanded state (no BLE round-trip, so it's + cheap to poll). 'unknown' until the first on/off command lands. + """ + try: + bot = self._room_light_device() + if bot is None: + return web.json_response({'success': False, 'available': False, + 'error': 'room_light device not configured'}) + state = bot.read().get(bot.name, {}).get('value', 'unknown') + return web.json_response({'success': True, 'available': True, 'state': state}) + except Exception as e: + import traceback + return web.json_response({'success': False, 'available': False, 'error': str(e), + 'traceback': traceback.format_exc()}, status=500) + + async def handle_set_room_light(self, request): + """POST /api/room_light/set - drive the room-light SwitchBot. + + Body: {"state": "on" | "off" | "press"}. Blocks until the BLE command + lands (the bot's servo move is ~0.5-1 s plus connect latency). + """ + try: + data = await request.json() + state = str(data.get('state', '')).lower() + if state not in ('on', 'off', 'press'): + return web.json_response({'success': False, + 'error': f"state {state!r} must be on, off, or press"}, status=400) + bot = self._room_light_device() + if bot is None: + return web.json_response({'success': False, + 'error': 'room_light device not configured'}, status=503) + + status = bot.set(state) + import time + timeout = float(getattr(bot, 'timeout', 20.0)) + 5 + start = time.time() + while not status.done and (time.time() - start) < timeout: + await asyncio.sleep(0.1) + + if status.done and status.success: + new_state = bot.read().get(bot.name, {}).get('value', state) + return web.json_response({'success': True, 'state': new_state}) + return web.json_response({'success': False, + 'error': f'failed to set room light to {state}'}, status=502) + 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_temperature_status(self, request): """GET /api/temperature/status - current temperature, setpoint, lock state.""" try: @@ -2339,6 +2425,8 @@ async def on_start(self): self._app.router.add_post('/api/led/set', self.handle_set_led) self._app.router.add_get('/api/temperature/status', self.handle_get_temperature_status) self._app.router.add_post('/api/temperature/set', self.handle_set_temperature) + self._app.router.add_get('/api/room_light/status', self.handle_get_room_light_status) + self._app.router.add_post('/api/room_light/set', self.handle_set_room_light) self._app.router.add_post('/api/camera/led_mode', self.handle_set_camera_led_mode) self._app.router.add_post('/api/camera/exposure', self.handle_set_camera_exposure) self._app.router.add_get('/api/camera/exposure', self.handle_get_camera_exposure) @@ -2390,10 +2478,72 @@ async def on_start(self): logger.info("Endpoints: GET /api/status, GET /api/devices, GET /api/plans, POST /api/queue/item/add, ...") await site.start() + self._print_ready_panel() + + def _categorize_devices(self): + """Group device names into human-readable buckets for the console panel. + + First-match-wins so 'room_light' lands in Accessory (not Light) and + 'volume_scanner' in Motion. Accessory entries carry live state. + """ + buckets = {"Motion": [], "Imaging": [], "Light": [], "Accessory": [], "Other": []} + for name in sorted(self.devices): + low = name.lower() + if low in ("room_light", "temperature"): + label = name + try: + dev = self.devices[name] + val = dev.read().get(dev.name, {}).get("value") + if val is not None: + label = f"{name} ({val})" + except Exception: + pass + buckets["Accessory"].append(label) + elif "cam" in low or "snap" in low: + buckets["Imaging"].append(name) + elif any(k in low for k in ("stage", "piezo", "galvo", "scanner")): + buckets["Motion"].append(name) + elif any(k in low for k in ("laser", "led", "light", "illum")): + buckets["Light"].append(name) + else: + buckets["Other"].append(name) + return list(buckets.items()) + + def _print_ready_panel(self): + """Curated, always-visible status summary at the terminal. + + Separate from the file log: the operator (often a biologist) gets the + URL the agent connects to, a grouped device inventory and accessory + states at a glance — instead of a silent console after the banner. + """ + def _fmt(names, limit=6): + if len(names) <= limit: + return " · ".join(names) + return " · ".join(names[:limit]) + cui.c(f" +{len(names) - limit} more", "grey") + + host = self.host or "0.0.0.0" + url_host = "localhost" if host in ("0.0.0.0", "::", "") else host + + cui.out() + cui.header(f"GENTLY{cui.MIDDOT}DEVICE LAYER", badge="READY", badge_style="green") + cui.row("URL", cui.c(f"http://{url_host}:{self.port}", "bold")) + cui.row("Hardware", str((self.config or {}).get("hardware", "dispim"))) + cui.row("Devices", f"{len(self.devices)} loaded") + for label, names in self._categorize_devices(): + if names: + cui.sub(label, _fmt(names)) + cui.row("Detection", f"SAM on {self._sam_device} (loads on first use)") + cui.row("Plans", f"{len(self.plans)} available") + cui.rule(heavy=False) + cui.note("Waiting for the agent to connect. Press Ctrl+C to stop.") + cui.rule(heavy=True) + cui.out() async def on_stop(self): """Shut down the HTTP server and plan executor.""" logger.info("Shutting down...") + cui.out() + cui.note("Shutting down device layer...", "yellow") self._running = False # Cancel any pending coalesced-broadcast timer. @@ -2446,6 +2596,7 @@ async def on_stop(self): if self._runner: await self._runner.cleanup() logger.info("Device layer stopped.") + cui.note("Device layer stopped.", "grey") async def health_check(self) -> Dict: """Return health status with device count, queue size, SAM status.""" diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 7bb5ad3b..3133ec60 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -263,6 +263,47 @@ async def stop_bottom_camera_stream(): raise HTTPException(status_code=500, detail=f"stop failed: {exc}") return {"streaming": False} + def _resolve_client(): + """Resolve the live microscope client from the agent bridge, or None.""" + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + return getattr(agent, "client", None) if agent else None + + @router.get("/api/devices/room_light/status") + async def get_room_light_status(): + """Cached on/off state of the room-light SwitchBot (cheap to poll).""" + client = _resolve_client() + if client is None: + return {"available": False, "state": "unknown"} + try: + res = await client.get_room_light_status() + except Exception as exc: + logger.debug("room light status fetch failed: %s", exc) + return {"available": False, "state": "unknown"} + return { + "available": bool(res.get("available", res.get("success", False))), + "state": res.get("state", "unknown"), + } + + @router.post("/api/devices/room_light/set", + dependencies=[Depends(require_control)]) + async def set_room_light(payload: dict = Body(...)): + """Switch the room light on/off. Body: {"state": "on"|"off"|"press"}.""" + state = str(payload.get("state", "")).lower() + if state not in ("on", "off", "press"): + raise HTTPException(status_code=400, detail="state must be on, off, or press") + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_room_light(state) + except Exception as exc: + logger.exception("Room light command failed") + raise HTTPException(status_code=502, detail=f"room light command failed: {exc}") + if not res.get("success"): + raise HTTPException(status_code=502, detail=res.get("error", "room light command failed")) + return {"state": res.get("state", state)} + @router.get("/api/calibration") async def list_calibration(embryo_id: Optional[str] = None): """Get calibration images""" diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 5fcab675..bd6ca7b9 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -9494,6 +9494,48 @@ body.modal-open { .devices-status-led.stale::before { background: var(--map-warm); } .devices-status-led.error::before { background: #f87171; } +/* --- Room-light toggle (header) -------------------------------------- */ +.devices-room-light { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.18rem 0.6rem 0.18rem 0.45rem; + border: 1px solid var(--map-overlay-edge); + background: var(--map-overlay-bg); + border-radius: 999px; + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.devices-room-light[hidden] { display: none; } +.devices-room-light:hover:not(:disabled) { + border-color: var(--map-accent); + color: var(--map-ink); +} +.devices-room-light:disabled { opacity: 0.5; cursor: default; } +.devices-room-light-bulb { + display: inline-flex; + align-items: center; + color: var(--map-ink-mute); + transition: color 0.15s, filter 0.15s; +} +/* "on" — warm glow on the bulb to read like a lit lamp */ +.devices-room-light.is-on { + border-color: rgba(255, 210, 74, 0.7); + color: #ffd24a; + background: rgba(255, 210, 74, 0.12); +} +.devices-room-light.is-on .devices-room-light-bulb { + color: #ffd24a; + filter: drop-shadow(0 0 5px rgba(255, 210, 74, 0.7)); +} +.devices-room-light.is-busy { opacity: 0.65; cursor: progress; } + /* --- Containers ------------------------------------------------------- */ .devices-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .devices-view-details { gap: 1rem; } diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index b4cb30d2..27bc12c3 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -72,6 +72,15 @@ const DevicesManager = (function () { const _CAM_ZOOM_MAX = 8; const _CAM_ZOOM_STEP = 1.15; // multiplicative per wheel notch + // 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. + let _roomLightToggle, _roomLightLabel; + let _roomLightState = 'unknown'; + let _roomLightAvailable = false; + let _roomLightBusy = false; + let _roomLightTimer = null; + let _lastTs = 0; let _previousTs = 0; let _lastWallTs = 0; @@ -135,6 +144,9 @@ const DevicesManager = (function () { _camLed = document.getElementById('devices-camera-led'); _camMeta = document.getElementById('devices-camera-meta'); + _roomLightToggle = document.getElementById('devices-room-light-toggle'); + _roomLightLabel = document.getElementById('devices-room-light-label'); + // Recompute the scale bar caption whenever the canvas resizes. if (_mapSvg && window.ResizeObserver) { new ResizeObserver(() => updateScalebar()).observe(_mapSvg); @@ -1268,6 +1280,95 @@ const DevicesManager = (function () { } } + // ===================================================================== + // Room-light toggle + // ===================================================================== + + function applyRoomLight(state, available) { + _roomLightState = state || 'unknown'; + _roomLightAvailable = !!available; + if (!_roomLightToggle) return; + _roomLightToggle.hidden = !_roomLightAvailable; + _roomLightToggle.disabled = !_roomLightAvailable || _roomLightBusy; + const on = _roomLightState === 'on'; + _roomLightToggle.classList.toggle('is-on', on); + _roomLightToggle.setAttribute('aria-pressed', on ? 'true' : 'false'); + if (_roomLightLabel && !_roomLightBusy) { + _roomLightLabel.textContent = on ? 'Room light: on' + : (_roomLightState === 'off' ? 'Room light: off' : 'Room light'); + } + } + + async function loadRoomLightStatus() { + if (!_roomLightToggle || _roomLightBusy) return; + try { + const res = await fetch('/api/devices/room_light/status'); + if (!res.ok) { applyRoomLight('unknown', false); return; } + const data = await res.json(); + applyRoomLight(data.state, data.available); + } catch (err) { + console.debug('room light status fetch failed:', err); + applyRoomLight('unknown', false); + } + } + + async function toggleRoomLight() { + if (!_roomLightToggle || _roomLightBusy || !_roomLightAvailable) return; + const next = _roomLightState === 'on' ? 'off' : 'on'; + _roomLightBusy = true; + _roomLightToggle.classList.add('is-busy'); + _roomLightToggle.disabled = true; + if (_roomLightLabel) { + _roomLightLabel.textContent = next === 'on' ? 'Turning on…' : 'Turning off…'; + } + + // Settle back to the resolved state, or surface a transient message + // (insufficient control / error) for 2 s before reverting. + const finish = (msg) => { + _roomLightBusy = false; + _roomLightToggle.classList.remove('is-busy'); + if (msg) { + if (_roomLightLabel) _roomLightLabel.textContent = msg; + _roomLightToggle.disabled = false; + setTimeout(() => applyRoomLight(_roomLightState, _roomLightAvailable), 2000); + } else { + applyRoomLight(_roomLightState, _roomLightAvailable); + } + }; + + try { + const res = await fetch('/api/devices/room_light/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: next }), + }); + if (res.status === 401 || res.status === 403) { finish('Need control'); return; } + if (!res.ok) { + console.error('room light set failed:', await res.text()); + finish('Error'); + return; + } + const data = await res.json(); + _roomLightState = data.state || next; + finish(null); + } catch (err) { + console.error('room light toggle failed:', err); + finish('Error'); + } + } + + function setupRoomLight() { + if (!_roomLightToggle) return; + _roomLightToggle.addEventListener('click', toggleRoomLight); + loadRoomLightStatus(); + // Light periodic refresh: state can also change from agent plans + // (e.g. brightfield imaging turns it on). Status read is cached at the + // device layer (no BLE), so polling is cheap; it also makes the toggle + // appear automatically once the device layer connects. + if (_roomLightTimer) clearInterval(_roomLightTimer); + _roomLightTimer = setInterval(loadRoomLightStatus, 15000); + } + // ===================================================================== // View switching // ===================================================================== @@ -1327,6 +1428,7 @@ const DevicesManager = (function () { cacheDom(); setupViewSwitcher(); setupCameraWiring(); + setupRoomLight(); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index b66893da..528189f9 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -258,6 +258,18 @@

Device

+ disconnected no data
diff --git a/start_device_layer.py b/start_device_layer.py index 5d4d7486..1ce25931 100644 --- a/start_device_layer.py +++ b/start_device_layer.py @@ -28,6 +28,58 @@ sys.path.insert(0, str(project_root)) +def _render_startup_failure(exc, log_file): + """Turn a startup exception into a plain-language console panel. + + The full traceback goes to the log file; the operator (often a biologist) + sees a diagnosis and concrete things to check. Recognises the common + "hardware powered off / COM port" case from the MMCore error text. + """ + import re + import traceback + from gently.hardware import console_ui as cui + + logging.getLogger("gently.device_layer").error( + "Startup failed:\n%s", "".join(traceback.format_exception(exc)) + ) + + text = str(exc) + low = text.lower() + first_line = next((ln.strip() for ln in text.splitlines() if ln.strip()), "Unknown error") + + summary = "The device layer could not start." + details = first_line + hints = [] + + dev_m = re.search(r'device "([^"]+)"', text) + device = dev_m.group(1) if dev_m else None + + if device and "initialize" in low: + # MMCore failed to initialize a hardware device — almost always the + # instrument is off, unplugged, or its COM port is held by another app. + summary = "Can't reach the microscope - it looks powered off or disconnected." + details = f'Device "{device}" failed to initialize.' + if device.upper().startswith("COM"): + hints = [ + "Is the microscope / stage controller powered on?", + "Are the USB / serial cables connected?", + f"Is another program using {device}? (e.g. Micro-Manager still open)", + ] + else: + hints = [ + f'Check that "{device}" is powered on and connected.', + "Is another program (e.g. Micro-Manager) holding the hardware?", + ] + elif "access is denied" in low or "system error code 5" in low or "already" in low: + summary = "A hardware port is busy — another program may be holding it." + hints = [ + "Close Micro-Manager or any other app using the microscope.", + "Then start the device layer again.", + ] + + cui.error_panel("GENTLY DEVICE LAYER", summary, details, hints, log_file) + + def main(): parser = argparse.ArgumentParser( description="Gently Device Layer - Hardware Server", @@ -100,15 +152,15 @@ def main(): hardware_name = config.get("hardware", "dispim") - print("\n" + "=" * 60) - print("GENTLY DEVICE LAYER") - print("=" * 60) - print(f"\nConfiguration:") - print(f" Hardware: {hardware_name}") - print(f" HTTP Port: {args.port}") - print(f" SAM Device: {args.sam_device}") - print(f" Config: {args.config}") - print() + from gently.hardware import console_ui as cui + cui.out() + cui.header(f"GENTLY{cui.MIDDOT}DEVICE LAYER", badge="starting", badge_style="cyan") + cui.row("Hardware", str(hardware_name)) + cui.row("HTTP port", str(args.port)) + cui.row("SAM device", str(args.sam_device)) + cui.row("Config", str(args.config)) + cui.row("Log file", str(log_file)) + cui.rule(heavy=True) # Load hardware module and create device layer from gently.hardware import load_hardware @@ -129,7 +181,8 @@ async def run_server(): loop = asyncio.get_running_loop() def request_shutdown(): - print("\n\nReceived interrupt signal...") + cui.out() + cui.note("Interrupt received - stopping...", "yellow") logging.getLogger("gently.device_layer.signal").warning( "Interrupt signal received — initiating shutdown" ) @@ -154,7 +207,11 @@ def win_signal_handler(sig, frame): try: asyncio.run(run_server()) except KeyboardInterrupt: - print("\n\nDevice layer stopped.") + cui.note("Device layer stopped.", "grey") + except Exception as exc: + # Hardware/init failure — show a diagnosis, not a raw traceback. + _render_startup_failure(exc, log_file) + sys.exit(1) if __name__ == "__main__": From cdeabb8ecd483790f9aed1d5924b2413ed487499 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 12:36:40 -0400 Subject: [PATCH 053/314] Retire napari from the agent; add web-chat autocomplete; prune dead tools Visualization (web-only): - view_volume and batch_lightsheet no longer launch a blocking napari window. view_volume broadcasts an open_volume message so the existing in-browser ProjectionViewer opens for every connected client and the tool returns immediately (removes the Qt-loop freeze behind the SSE watchdog). batch_lightsheet pushes captures to the web image strip. - New server.open_volume_in_browser() + websocket.js open_volume handler; bridge connect frame now ships the tool registry too. - Removed the broken napari-only example; updated docs and the watchdog comment. No napari left in the agent runtime. Chat UX: - Slash-command and @tool autocomplete in the web chat (the command/tool registries were already on the connect frame, just unused). - Tool rows now show arguments, a one-line result summary, and a warning icon when a tool errored or returned a failure string. - conversation.py tool_call stream now carries result_summary + is_error. Tool surface: - Removed 4 permanently-dead databroker tools (no databroker is wired). - calibrate_embryo use_v04_plan returns gracefully instead of raising. - Allowlisted snapshot_plan/list_plan_versions/restore_plan_version in plan mode. Fixes from adversarial review: - open_volume resolves the volume against the live agent session (not the stale timelapse tracker), so it doesn't 404 after a web /resume. - ProjectionViewer clears stale volume state on open and falls back to the projection grid when the 3D view is requested without volume data. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/TOOLS.md | 15 +- docs/guides/capabilities.md | 1 - examples/README.md | 2 - examples/example_napari_visualization.py | 522 ------------------- gently/app/device_state_monitor.py | 8 +- gently/app/tools/__init__.py | 1 - gently/app/tools/acquisition_tools.py | 48 +- gently/app/tools/calibration_tools.py | 22 +- gently/app/tools/data_tools.py | 203 -------- gently/app/tools/volume_tools.py | 136 ++--- gently/harness/bridge.py | 29 ++ gently/harness/conversation.py | 19 +- gently/harness/prompts/manager.py | 1 + gently/ui/web/server.py | 34 +- gently/ui/web/static/css/agent-chat.css | 38 ++ gently/ui/web/static/js/agent-chat.js | 181 ++++++- gently/ui/web/static/js/projection-viewer.js | 8 + gently/ui/web/static/js/websocket.js | 14 + 18 files changed, 410 insertions(+), 872 deletions(-) delete mode 100644 examples/example_napari_visualization.py delete mode 100644 gently/app/tools/data_tools.py diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 6d9e020c..feafbfbe 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -13,7 +13,7 @@ Source: `gently/agent/tools/` (run mode) and `gently/agent/plan_mode/tools/` (pl |------|-------------| | `acquire_volume` | Acquire a single 3D lightsheet volume for a specific embryo with calibration data | | `capture_lightsheet` | Capture a single 2D lightsheet fluorescence image at specified piezo/galvo position | -| `batch_lightsheet` | Capture lightsheet images from ALL embryos and display as a stack | +| `batch_lightsheet` | Capture lightsheet images from ALL embryos and show them in the web UI viewer | ### Analysis (`analysis_tools.py`) @@ -29,22 +29,13 @@ Source: `gently/agent/tools/` (run mode) and `gently/agent/plan_mode/tools/` (pl | `calibrate_embryo` | Run full piezo-galvo calibration for a specific embryo using Claude vision | | `calibrate_all_embryos` | Run piezo-galvo calibration for all detected embryos sequentially | -### Data (`data_tools.py`) - -| Tool | Description | -|------|-------------| -| `list_runs` | List recent Bluesky runs from Databroker | -| `get_run_data` | Get data from a specific Bluesky run | -| `get_run_image` | Get an image from a Bluesky run for analysis | -| `search_runs` | Search Databroker runs by metadata criteria | - ### Detection (`detection_tools.py`) | Tool | Description | |------|-------------| | `detect_embryos` | Automatically detect embryos using brightness detection and SAM segmentation | | `manual_mark_embryos` | Open interactive window to manually mark embryos by clicking | -| `edit_embryos` | Open napari editor to add/remove/move embryo positions | +| `edit_embryos` | Add/remove/move embryo positions in the web map view | | `show_detected_embryos` | Capture fresh image and display all tracked embryos with labeled bounding boxes | ### Detectors (`detector_tools.py`) @@ -145,7 +136,7 @@ Source: `gently/agent/tools/` (run mode) and `gently/agent/plan_mode/tools/` (pl | Tool | Description | |------|-------------| | `view_image` | Capture and display current bottom camera widefield image | -| `view_volume` | Open a volume in napari for 3D visualization | +| `view_volume` | Open a volume in the in-browser 3D viewer | | `list_volumes` | List available volumes for an embryo or all embryos | --- diff --git a/docs/guides/capabilities.md b/docs/guides/capabilities.md index d3b097c7..846d662c 100644 --- a/docs/guides/capabilities.md +++ b/docs/guides/capabilities.md @@ -133,7 +133,6 @@ This design means experimental AI code — perception systems, coding agents, no | **Analysis** | analyze_volume, classify_embryo_stage | No | | **Experiment** | get_experiment_summary, query_embryo_status | No | | **Session** | list_sessions, import_embryos_from_session | No | -| **Data** | list_runs, get_run_data, search_runs | No | | **Planning** | create_campaign, propose_plan, search_literature | No | | **Research** | search_literature, read_paper, search_strains | No | diff --git a/examples/README.md b/examples/README.md index c538fbc0..cb6f6b06 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,11 +5,9 @@ Working examples of Gently's Bluesky plan system and visualization pipeline. | Example | Description | |---------|-------------| | `example_dispim_workflows.py` | Complete DiSPIM workflows: atomic plans, autofocus, two-point calibration, embryo detection, multi-embryo acquisition | -| `example_napari_visualization.py` | Real-time napari visualization: focus sweeps, embryo detection, dual-sided DiSPIM, custom configurations | ## Requirements ```bash pip install gently[device] # Bluesky + Ophyd for hardware plans -pip install napari[all] # For visualization examples ``` diff --git a/examples/example_napari_visualization.py b/examples/example_napari_visualization.py deleted file mode 100644 index cfbad522..00000000 --- a/examples/example_napari_visualization.py +++ /dev/null @@ -1,522 +0,0 @@ -#!/usr/bin/env python -""" -DiSPIM Napari Visualization Examples -=================================== - -Demonstrates real-time image visualization for DiSPIM experiments using napari. -Shows different visualization patterns for various experiment types. - -This example shows: -1. Basic napari setup with Bluesky RunEngine -2. Focus sweep visualization (3D image stacks) -3. Embryo detection visualization (2D image sequences) -4. Dual-sided DiSPIM visualization (multi-channel) -5. Custom visualization configurations -6. Integration with complete DiSPIM workflows - -Requirements: - pip install napari[all] - # or with specific backend: pip install napari[pyqt5] -""" - -import logging -import numpy as np -from bluesky import RunEngine -from bluesky.callbacks import LiveTable - -# Import gently components -from gently import ( - # Device classes - create_dispim_system, - DiSPIMSystem, - - # Plan functions - focus_sweep, - dispim_piezo_autofocus, - find_embryos_with_bottom_camera, - full_dispim_workflow, - - # Configuration classes - AutofocusConfig, - CalibrationConfig, - - # Analysis utilities - FocusAlgorithm, - FitFunction -) - -# Import visualization utilities -from gently.ui.web import ( - EmbryoMarker, - mark_embryos_napari, - generate_focus_curve_plot, - generate_calibration_summary_plot, - generate_edge_detection_plot, -) - -# Napari availability check -try: - import napari - NAPARI_AVAILABLE = True -except ImportError: - NAPARI_AVAILABLE = False - - -def check_napari_installation(): - """Check if napari is available and provide installation instructions""" - if not NAPARI_AVAILABLE: - print("❌ Napari not available!") - print("\nTo enable image visualization, install napari:") - print(" pip install napari[all]") - print("\nOr with specific backend:") - print(" pip install napari[pyqt5]") - print(" # or napari[pyside2]") - print("\nAfter installation, restart and run this example again.") - return False - - print("✅ Napari is available - image visualization enabled!") - return True - - -def setup_demo_system(): - """Setup demo DiSPIM system for visualization examples""" - print("Setting up demo DiSPIM system...") - - # Create RunEngine - RE = RunEngine({}) - - # For demonstration, we'll use mock system - # In practice: system = create_dispim_system("/path/to/micromanager", "config.cfg") - print(" [Note: Using mock system for demonstration]") - system = None # Would be actual DiSPIMSystem - light_sheet = None # Would be system.side_a - - return RE, system, light_sheet - - -def demonstrate_basic_napari_setup(RE): - """Demonstrate basic napari visualization setup""" - print("\n" + "="*60) - print("1. BASIC NAPARI SETUP - Real-time Image Visualization") - print("="*60) - - print("\nSetting up napari for DiSPIM visualization:") - - # Create napari callback with default settings - napari_callback = setup_napari_callback() - - if not napari_callback.enabled: - print(" ❌ Napari callback disabled (napari not available)") - return None - - # Subscribe to RunEngine - RE.subscribe(napari_callback) - - print(" ✅ Napari callback created and subscribed") - print(f" ✅ Viewer title: {napari_callback.viewer.title}") - print(f" ✅ Focus sweeps: {napari_callback.show_focus_sweeps}") - print(f" ✅ Embryo detection: {napari_callback.show_embryo_detection}") - print(f" ✅ Dual channel: {napari_callback.dual_channel_mode}") - - print("\nBasic usage pattern:") - print(" RE = RunEngine({})") - print(" napari_callback = setup_napari_callback()") - print(" RE.subscribe(napari_callback)") - print(" # Now any plan with images will display in napari!") - - return napari_callback - - -def demonstrate_focus_sweep_visualization(RE, light_sheet, napari_callback): - """Demonstrate focus sweep visualization""" - print("\n" + "="*60) - print("2. FOCUS SWEEP VISUALIZATION - 3D Image Stacks") - print("="*60) - - if not napari_callback or not napari_callback.enabled: - print(" ⚠ Skipping - napari not available") - return - - print("\nFocus sweep creates 3D image stacks visualized in real-time:") - - # Configure autofocus - config = AutofocusConfig( - num_positions=15, # Fewer positions for faster demo - step_size_um=1.0, - algorithm=FocusAlgorithm.VOLATH.value, - fit_function=FitFunction.GAUSSIAN.value - ) - - print(f"\nAutofocus configuration:") - print(f" Positions: {config.num_positions}") - print(f" Step size: {config.step_size_um} μm") - print(f" Total range: ±{config.num_positions * config.step_size_um / 2} μm") - - if light_sheet is not None: - print(f"\nExecuting autofocus with napari visualization:") - print(f" RE(dispim_piezo_autofocus(light_sheet, config))") - - # This would display images in napari as they're acquired - # RE(dispim_piezo_autofocus(light_sheet, config)) - - print(f"\nNapari display:") - print(f" ✅ Images stream to napari as they're acquired") - print(f" ✅ 3D stack builds up in real-time") - print(f" ✅ Can scrub through Z positions") - print(f" ✅ Focus curve visible as image stack") - - else: - print(f"\n[Would execute: RE(dispim_piezo_autofocus(light_sheet, config))]") - print(f"\nExpected napari behavior:") - print(f" - New layer: 'Focus Sweep (Side A)'") - print(f" - Green colormap for side A data") - print(f" - 3D stack: shape (15, height, width)") - print(f" - Real-time updates as images acquired") - - print(f"\nVisualization features:") - print(f" - Real-time focus quality assessment") - print(f" - Immediate feedback on scan progress") - print(f" - Visual validation of focus curve") - - -def demonstrate_embryo_detection_visualization(RE, system, napari_callback): - """Demonstrate embryo detection visualization""" - print("\n" + "="*60) - print("3. EMBRYO DETECTION VISUALIZATION - 2D Image Sequences") - print("="*60) - - if not napari_callback or not napari_callback.enabled: - print(" ⚠ Skipping - napari not available") - return - - print("\nEmbryo detection creates sequences of 2D images from XY scanning:") - - # Configure embryo detection - detection_config = { - 'scan_area': { - 'x_start': -500, 'x_stop': 500, # Smaller area for demo - 'y_start': -500, 'y_stop': 500, - 'step_size': 100 # μm between positions - }, - 'detection': { - 'min_size_pixels': 50, - 'max_size_pixels': 500, - 'brightness_threshold': 0.3 - } - } - - print(f"\nDetection configuration:") - print(f" Scan area: {detection_config['scan_area']['x_start']} to {detection_config['scan_area']['x_stop']} μm") - print(f" Step size: {detection_config['scan_area']['step_size']} μm") - print(f" Grid size: 11x11 = 121 positions") - - if system is not None: - print(f"\nExecuting embryo detection with napari visualization:") - print(f" RE(find_embryos_with_bottom_camera(system, detection_config))") - - # This would display images in napari as XY scan progresses - # RE(find_embryos_with_bottom_camera(system, detection_config)) - - print(f"\nNapari display:") - print(f" ✅ Each XY position shows in napari immediately") - print(f" ✅ Can see scan progress across sample") - print(f" ✅ Potential embryos highlighted as found") - print(f" ✅ Final mosaic view of scanned area") - - else: - print(f"\n[Would execute: RE(find_embryos_with_bottom_camera(system, detection_config))]") - print(f"\nExpected napari behavior:") - print(f" - New layer: 'Embryo Detection (Side A)'") - print(f" - Updates with each XY position") - print(f" - 121 total images in sequence") - print(f" - Detected embryos marked/highlighted") - - print(f"\nVisualization benefits:") - print(f" - Real-time quality control of scan") - print(f" - Immediate feedback on embryo locations") - print(f" - Visual verification of detection algorithm") - - -def demonstrate_dual_channel_visualization(RE, system, napari_callback): - """Demonstrate dual-sided DiSPIM visualization""" - print("\n" + "="*60) - print("4. DUAL-CHANNEL VISUALIZATION - Multi-Camera Display") - print("="*60) - - if not napari_callback or not napari_callback.enabled: - print(" ⚠ Skipping - napari not available") - return - - print("\nDual-sided DiSPIM generates images from two cameras simultaneously:") - - if system is not None: - print(f"\nSimulating dual-sided acquisition:") - print(f" # Both sides acquire simultaneously") - print(f" side_a_image = system.side_a.camera.read()") - print(f" side_b_image = system.side_b.camera.read()") - - print(f"\nNapari display:") - print(f" ✅ Side A: Green channel") - print(f" ✅ Side B: Magenta channel") - print(f" ✅ Additive blending for overlay") - print(f" ✅ Separate layers for independent control") - print(f" ✅ Synchronized updates") - - else: - print(f"\n[Would show both camera feeds simultaneously]") - - print(f"\nColor scheme:") - print(f" - Side A (illumination from left): Green") - print(f" - Side B (illumination from right): Magenta") - print(f" - Overlaid: Shows complementary information") - - print(f"\nVisualization advantages:") - print(f" - Compare image quality from both sides") - print(f" - See complementary sample information") - print(f" - Identify optimal viewing angle") - print(f" - Real-time feedback for dual-sided experiments") - - -def demonstrate_custom_visualization_configs(RE): - """Demonstrate custom visualization configurations""" - print("\n" + "="*60) - print("5. CUSTOM CONFIGURATIONS - Tailored Visualization") - print("="*60) - - if not NAPARI_AVAILABLE: - print(" ⚠ Skipping - napari not available") - return - - print("\nCustom configurations for different experiment needs:") - - # Configuration 1: Focus-only visualization - print(f"\n1. Focus-Only Configuration:") - print(f" config = {{'show_focus_sweeps': True, 'show_embryo_detection': False}}") - print(f" napari_callback = setup_napari_callback(config)") - - focus_config = { - 'show_focus_sweeps': True, - 'show_embryo_detection': False, - 'show_single_images': False, - 'update_interval': 0.05 # Faster updates - } - - print(f" - Only shows focus sweep experiments") - print(f" - Faster update rate (0.05s)") - print(f" - Optimized for autofocus development") - - # Configuration 2: High-throughput visualization - print(f"\n2. High-Throughput Configuration:") - print(f" config = {{'show_single_images': False, 'update_interval': 1.0}}") - - throughput_config = { - 'show_focus_sweeps': True, - 'show_embryo_detection': True, - 'show_single_images': False, # Skip individual images - 'update_interval': 1.0 # Slower updates for performance - } - - print(f" - Skip individual images to reduce overhead") - print(f" - Slower update rate (1.0s) for performance") - print(f" - Better for automated, high-throughput experiments") - - # Configuration 3: Development/debugging - print(f"\n3. Development/Debugging Configuration:") - print(f" config = {{'show_single_images': True, 'update_interval': 0.01}}") - - debug_config = { - 'show_focus_sweeps': True, - 'show_embryo_detection': True, - 'show_single_images': True, - 'update_interval': 0.01 # Very fast updates - } - - print(f" - Show every image for detailed inspection") - print(f" - Very fast updates (0.01s)") - print(f" - Maximum detail for troubleshooting") - - print(f"\nUsage pattern:") - print(f" # Choose configuration for your needs") - print(f" config = focus_config # or throughput_config, debug_config") - print(f" napari_callback = setup_napari_callback(config)") - print(f" RE.subscribe(napari_callback)") - - -def demonstrate_convenience_functions(RE): - """Demonstrate convenience functions for common patterns""" - print("\n" + "="*60) - print("6. CONVENIENCE FUNCTIONS - Common Usage Patterns") - print("="*60) - - if not NAPARI_AVAILABLE: - print(" ⚠ Skipping - napari not available") - return - - print("\nConvenience functions for common visualization needs:") - - print(f"\n1. Focus Sweep Only:") - print(f" from gently.visualization import enable_focus_sweep_visualization") - print(f" enable_focus_sweep_visualization(RE)") - print(f" # Optimized for autofocus experiments") - - print(f"\n2. Embryo Detection Only:") - print(f" from gently.visualization import enable_embryo_detection_visualization") - print(f" enable_embryo_detection_visualization(RE)") - print(f" # Optimized for sample detection") - - print(f"\n3. Full Visualization:") - print(f" from gently.visualization import enable_full_visualization") - print(f" enable_full_visualization(RE)") - print(f" # Shows everything - good for general use") - - print(f"\n4. Custom Viewer:") - print(f" from gently.visualization import create_napari_viewer") - print(f" viewer = create_napari_viewer('My DiSPIM Experiment')") - print(f" callback = setup_napari_callback(viewer=viewer)") - print(f" # Use your own configured viewer") - - print(f"\nBenefits:") - print(f" - One-line setup for common patterns") - print(f" - Pre-configured for specific experiment types") - print(f" - Easy to integrate into existing workflows") - - -def demonstrate_complete_workflow_visualization(RE, system): - """Demonstrate visualization with complete DiSPIM workflow""" - print("\n" + "="*60) - print("7. COMPLETE WORKFLOW VISUALIZATION - Full Experiment") - print("="*60) - - if not NAPARI_AVAILABLE: - print(" ⚠ Skipping - napari not available") - return - - print("\nVisualization during complete multi-embryo workflow:") - - # Setup full visualization - print(f"\nSetting up comprehensive visualization:") - print(f" napari_callback = enable_full_visualization(RE)") - print(f" # Will show all stages of the workflow") - - # Complete workflow configuration - workflow_config = { - 'system_setup': { - 'center_devices': True, - 'run_calibration': True - }, - 'calibration': { - 'point1_um': 25.0, - 'point2_um': 75.0, - 'autofocus_each_point': True - }, - 'embryo_detection': { - 'x_start': -1000, 'x_stop': 1000, - 'y_start': -1000, 'y_stop': 1000, - 'step_size': 200 - }, - 'acquisition': { - 'z_stack': {'range_um': 50, 'step_size_um': 1.0}, - 'dual_sided': True, - 'time_points': 3 - } - } - - print(f"\nWorkflow stages with visualization:") - - if system is not None: - print(f"\n RE(full_dispim_workflow(system, workflow_config))") - print(f"\n Expected napari display sequence:") - - print(f" 1. Calibration stage:") - print(f" - Focus sweeps at calibration points") - print(f" - Real-time focus quality assessment") - - print(f" 2. Embryo detection stage:") - print(f" - XY scan images streaming in") - print(f" - Detected embryo positions highlighted") - - print(f" 3. Multi-embryo acquisition:") - print(f" - Focus sweeps for each embryo") - print(f" - Z-stack acquisitions (dual-channel)") - print(f" - Time series progression") - - print(f"\nVisualization benefits for complete workflow:") - print(f" ✅ Monitor entire experiment progress") - print(f" ✅ Quality control at each stage") - print(f" ✅ Early detection of issues") - print(f" ✅ Real-time data assessment") - print(f" ✅ Immediate feedback on results") - - -def main(): - """Main napari visualization demonstration""" - print("DiSPIM Napari Visualization Examples") - print("=" * 60) - print() - print("This example demonstrates real-time image visualization") - print("for DiSPIM experiments using napari and Bluesky callbacks.") - print() - - # Check napari installation - if not check_napari_installation(): - return - - # Setup demo system - RE, system, light_sheet = setup_demo_system() - - # Run demonstrations - napari_callback = demonstrate_basic_napari_setup(RE) - demonstrate_focus_sweep_visualization(RE, light_sheet, napari_callback) - demonstrate_embryo_detection_visualization(RE, system, napari_callback) - demonstrate_dual_channel_visualization(RE, system, napari_callback) - demonstrate_custom_visualization_configs(RE) - demonstrate_convenience_functions(RE) - demonstrate_complete_workflow_visualization(RE, system) - - # Summary - print("\n" + "="*60) - print("SUMMARY - Napari Visualization Integration") - print("="*60) - - print(f"\n✅ Napari integration complete:") - print(f" - Real-time image streaming from Bluesky plans") - print(f" - Automatic 3D stack visualization for focus sweeps") - print(f" - 2D image sequences for embryo detection") - print(f" - Dual-channel support for two-sided DiSPIM") - print(f" - Configurable visualization options") - - print(f"\n✅ Key benefits:") - print(f" - Immediate visual feedback during experiments") - print(f" - Quality control and error detection") - print(f" - Interactive data exploration") - print(f" - Non-intrusive - works with existing plans") - print(f" - Optional - graceful fallback if napari not available") - - print(f"\n✅ Usage patterns:") - print(f" - Basic: setup_napari_callback() → RE.subscribe()") - print(f" - Custom: setup_napari_callback(config) for specific needs") - print(f" - Convenience: enable_focus_sweep_visualization(RE)") - print(f" - Integration: Works with all existing DiSPIM plans") - - print(f"\nNext steps:") - print(f" 1. Install napari: pip install napari[all]") - print(f" 2. Add visualization to your DiSPIM experiments") - print(f" 3. Customize configurations for your needs") - print(f" 4. Enjoy real-time image feedback!") - - if napari_callback and napari_callback.enabled: - print(f"\nNapari viewer is open - explore the interface!") - print(f" - Layer controls for each image type") - print(f" - Color/brightness adjustments") - print(f" - 3D visualization controls") - print(f" - Screenshot and movie export options") - - print(f"\nGently DiSPIM + Napari: Real-time microscopy visualization! 🔬✨") - - -if __name__ == "__main__": - # Setup logging - logging.basicConfig(level=logging.INFO) - - # Run demonstration - main() \ No newline at end of file diff --git a/gently/app/device_state_monitor.py b/gently/app/device_state_monitor.py index beff9a54..55e61daf 100644 --- a/gently/app/device_state_monitor.py +++ b/gently/app/device_state_monitor.py @@ -14,9 +14,11 @@ Watchdog -------- -The SSE iterator can silently stall in the agent process — most reliably -when a Qt window (napari) freezes the asyncio loop synchronously during a -tool call, but in principle any half-open TCP path can cause it. aiohttp's +The SSE iterator can silently stall in the agent process whenever a +half-open TCP path or a long synchronous tool call wedges the asyncio loop. +(Historically the worst offender was a Qt window — napari — blocking the +loop during a tool call; that path is gone now that all visualization is +in-browser, but the watchdog stays for general robustness.) aiohttp's async iterator won't raise on a stalled socket; the ``async for`` just waits forever. To recover, a sibling watchdog task tracks the timestamp of the last received event; if no event arrives within ``stale_timeout_sec`` diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index 4dd1b776..99c91e34 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -15,7 +15,6 @@ from . import acquisition_tools from . import volume_tools from . import analysis_tools -from . import data_tools from . import timelapse_tools from . import session_tools from . import focus_tools diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py index 2500fe41..0f429982 100644 --- a/gently/app/tools/acquisition_tools.py +++ b/gently/app/tools/acquisition_tools.py @@ -302,10 +302,11 @@ async def capture_lightsheet( @tool( name="batch_lightsheet", - description="""Capture lightsheet images from ALL embryos and display them together in a single napari viewer. + description="""Capture lightsheet images from ALL embryos and show them together in the web UI. Use when user says "lightsheet all embryos", "capture all embryos", "show me all embryos in lightsheet". -Moves to each embryo, captures a lightsheet image, then opens napari with all images as separate layers. -Much more efficient than capturing one at a time.""", +Moves to each embryo, captures a lightsheet image, saves it, and pushes it to the +web viewer (live image strip) for everyone watching. Much more efficient than +capturing one at a time.""", category=ToolCategory.HARDWARE, requires_microscope=True, examples=[ @@ -317,7 +318,7 @@ async def batch_lightsheet( galvo_position: float = 0.0, context: Dict = None ) -> str: - """Capture lightsheet images from all embryos and show in single napari viewer""" + """Capture lightsheet images from all embryos and show them in the web UI""" agent = context.get('agent') client = context.get('client') @@ -403,32 +404,25 @@ async def batch_lightsheet( logger.info("Saved %d images to %s", len(images), save_dir) - # Open single napari viewer with all images as a stack - import napari - import numpy as np - logger.info("Opening napari with %d embryo images as stack...", len(images)) - - # Stack images into a single array for slider navigation - image_stack = np.stack(images, axis=0) - - viewer = napari.Viewer(title=f"Batch Lightsheet - {len(images)} embryos") - - # Add as single stack with slider (grayscale) - viewer.add_image( - image_stack, - name='Embryos', - colormap='gray', - ) - - # Print embryo ID mapping for reference - logger.info("Slider index -> Embryo ID:") - for i, eid in enumerate(embryo_ids): - logger.info(" %d: %s", i, eid) - - napari.run() + # Push each captured image to the web UI \u2014 no blocking desktop window. + # They appear in the live viewer / recent strip for everyone watching. + pushed = 0 + if agent.viz_server is not None: + for img, eid in zip(images, embryo_ids): + uid = f"batch_lightsheet_{eid}_{timestamp}" + agent.push_viz( + img, uid, "image", + {"embryo_id": eid, "source": "batch_lightsheet", "label": eid}, + ) + pushed += 1 + logger.info("Pushed %d batch-lightsheet images to the web UI", pushed) # Summary summary = f"\u2713 Captured {len(images)} embryos: {', '.join(embryo_ids)}" + if pushed: + summary += f"\nShowing {pushed} image(s) in the web UI viewer." + elif agent.viz_server is None: + summary += "\n(Web UI not running \u2014 images saved to disk only.)" if errors: summary += f"\n\u26a0 Errors: {'; '.join(errors)}" summary += f"\nSaved to: {save_dir}" diff --git a/gently/app/tools/calibration_tools.py b/gently/app/tools/calibration_tools.py index 963dc2d0..f827b8f8 100644 --- a/gently/app/tools/calibration_tools.py +++ b/gently/app/tools/calibration_tools.py @@ -973,18 +973,16 @@ async def calibrate_embryo( return f"Error: Not connected to microscope server. Cannot calibrate {embryo_id}." if use_v04_plan: - # Escape hatch hook. Not wired yet - delegating to the real Bluesky - # plan requires a RunEngine and device objects that live on the device - # layer, so the caller would have to submit the plan through the queue - # server. Since the surgical path already mirrors v0.4.0's behavior, - # this is a placeholder for a future hardware-regression follow-up. - raise NotImplementedError( - "use_v04_plan=True is not wired yet. The default surgical path " - "in calibrate_embryo already replicates the v0.4.0 calibration " - "plan's behavior (edge detection + inset + wide adaptive sweep). " - "If that path regresses on hardware, wire this branch to submit " - "gently.hardware.dispim.plans.calibration.calibrate_embryo_piezo_galvo " - "through the queue server's plan-submission API." + # Escape hatch reserved for a future hardware-regression follow-up. It is + # intentionally unwired (delegating to the real Bluesky plan needs a + # RunEngine + device objects that live on the device layer). Return a + # clear message instead of raising, so a model that sets this flag gets a + # graceful answer rather than a hard NotImplementedError — the default + # surgical path already mirrors v0.4.0 behavior. + return ( + "use_v04_plan is not available: the default calibration path already " + "replicates the v0.4.0 plan (edge detection + inset + wide adaptive " + "sweep). Re-run calibrate_embryo without use_v04_plan." ) logger.info("calibration path: surgical (v0.4.0-equivalent inset + adaptive sweep)") diff --git a/gently/app/tools/data_tools.py b/gently/app/tools/data_tools.py deleted file mode 100644 index 9435a433..00000000 --- a/gently/app/tools/data_tools.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Databroker Tools - -Tools for querying and retrieving data from Bluesky/Databroker. -""" - -from typing import Dict, List - -from gently.harness.tools.registry import tool, ToolCategory -from gently.harness.tools.helpers import require_agent - - -@tool( - name="list_runs", - description="List recent Bluesky runs from Databroker", - category=ToolCategory.DATA, -) -def list_runs( - limit: int = 10, - embryo_id: str = None, - plan_name: str = None, - context: Dict = None -) -> str: - """List recent runs""" - agent = context.get('agent') - - if not agent or not agent.databroker: - return "Databroker not available" - - try: - db = agent.databroker - - query = {} - if embryo_id: - query['embryo_id'] = embryo_id - if plan_name: - query['plan_name'] = plan_name - - runs = list(db(**query))[:limit] - - if not runs: - return "No runs found" - - lines = [f"Recent runs ({len(runs)}):", ""] - - for run_uid in runs: - run = db[run_uid] - start = run.metadata.get('start', {}) - lines.append(f"* {run_uid[:8]}...") - lines.append(f" Plan: {start.get('plan_name', 'unknown')}") - lines.append(f" Time: {start.get('time', 'unknown')}") - if 'embryo_id' in start: - lines.append(f" Embryo: {start['embryo_id']}") - lines.append("") - - return "\n".join(lines) - - except Exception as e: - return f"Error listing runs: {str(e)}" - - -@tool( - name="get_run_data", - description="Get data from a specific Bluesky run", - category=ToolCategory.DATA, -) -def get_run_data( - run_id: str, - data_keys: List[str] = None, - stream: str = "primary", - context: Dict = None -) -> str: - """Get run data""" - agent = context.get('agent') - - if not agent or not agent.databroker: - return "Databroker not available" - - try: - db = agent.databroker - - if run_id.startswith('-'): - run = db[int(run_id)] - else: - run = db[run_id] - - data = run.primary.read() - - if data_keys: - data = {k: data[k] for k in data_keys if k in data} - - lines = [f"Run: {run.metadata['start']['uid'][:8]}...", ""] - lines.append(f"Available keys: {list(data.keys())}") - - for key, values in data.items(): - shape = values.shape if hasattr(values, 'shape') else 'scalar' - lines.append(f" {key}: shape={shape}") - - return "\n".join(lines) - - except Exception as e: - return f"Error getting run data: {str(e)}" - - -@tool( - name="get_run_image", - description="Get an image from a Bluesky run for analysis", - category=ToolCategory.DATA, -) -async def get_run_image( - run_id: str, - detector: str = None, - analyze: bool = False, - analysis_prompt: str = None, - context: Dict = None -) -> str: - """Get run image""" - agent = context.get('agent') - - if not agent or not agent.databroker: - return "Databroker not available" - - try: - db = agent.databroker - - if run_id.startswith('-'): - run = db[int(run_id)] - else: - run = db[run_id] - - data = run.primary.read() - - if not detector: - for key in ['bottom_camera', 'camera', 'detector']: - if key in data: - detector = key - break - - if detector not in data: - return f"Detector '{detector}' not found. Available: {list(data.keys())}" - - image = data[detector] - shape = image.shape if hasattr(image, 'shape') else 'unknown' - - result = f"Retrieved image from {detector}\nShape: {shape}" - - if analyze and analysis_prompt: - analysis = await agent._analyze_image_with_vision( - image=image, - prompt=analysis_prompt - ) - result += f"\n\nAnalysis:\n{analysis}" - - return result - - except Exception as e: - return f"Error getting image: {str(e)}" - - -@tool( - name="search_runs", - description="Search Databroker runs by metadata criteria", - category=ToolCategory.DATA, -) -def search_runs( - since: str = None, - until: str = None, - metadata: Dict = None, - limit: int = 20, - context: Dict = None -) -> str: - """Search runs""" - agent = context.get('agent') - - if not agent or not agent.databroker: - return "Databroker not available" - - try: - db = agent.databroker - - query = metadata or {} - - if since: - query['since'] = since - if until: - query['until'] = until - - runs = list(db(**query))[:limit] - - if not runs: - return "No matching runs found" - - lines = [f"Found {len(runs)} runs:", ""] - - for run_uid in runs: - run = db[run_uid] - start = run.metadata.get('start', {}) - lines.append(f"* {run_uid[:8]}: {start.get('plan_name', 'unknown')}") - - return "\n".join(lines) - - except Exception as e: - return f"Error searching runs: {str(e)}" diff --git a/gently/app/tools/volume_tools.py b/gently/app/tools/volume_tools.py index 080e56dc..b1b69e59 100644 --- a/gently/app/tools/volume_tools.py +++ b/gently/app/tools/volume_tools.py @@ -118,15 +118,17 @@ async def view_image( @tool( name="view_volume", - description="""Open a volume in napari for 3D visualization. -Can open a volume by file path OR by embryo ID (opens latest volume or specific timepoint). -Use when user says "open volume", "view volume", "show volume in napari", or "look at the 3D data".""", + description="""Open an acquired volume in the in-browser 3D viewer. +Opens by embryo ID \u2014 the latest volume, or a specific timepoint. The volume +appears in the web UI's volume viewer (interactive 3D raymarcher + projections) +for everyone watching the session; nothing pops up on the instrument desktop. +Use when the user says "open volume", "view volume", "show the 3D data", or +"look at timepoint N of embryo X".""", category=ToolCategory.ANALYSIS, requires_microscope=False, examples=[ ToolExample("Open latest volume for embryo 2", {"embryo_id": "embryo_2"}), ToolExample("Open specific timepoint", {"embryo_id": "embryo_2", "timepoint": 5}), - ToolExample("Open volume file", {"file_path": "D:/Gently/volumes/embryo_1_t0001.tif"}), ], ) async def view_volume( @@ -135,95 +137,71 @@ async def view_volume( file_path: str = None, context: Dict = None ) -> str: - """Open a volume in napari for visualization""" - import napari - import tifffile - import numpy as np + """Open a volume in the browser-based viewer (no blocking desktop window).""" from pathlib import Path agent, err = require_agent(context) if err: return err - volume = None - volume_path = None - title = "Volume Viewer" + session_id = agent.session_id - # Determine which volume to open - if file_path: - # Open from file path - volume_path = Path(file_path) - if not volume_path.exists(): + # file_path is legacy. In-browser viewing is addressed by embryo + timepoint, + # so map a FileStore path (embryos/{embryo_id}/volumes/t{NNNN}.tif) back to + # those when possible. + if file_path and not embryo_id: + p = Path(file_path) + if not p.exists(): return f"Error: File not found: {file_path}" - title = f"Volume: {volume_path.name}" - - elif embryo_id: - # Get volume for embryo from FileStore - session_id = agent.session_id - - if timepoint is not None: - # Try to find specific timepoint via FileStore - volume_path = agent.store.get_volume_path(session_id, embryo_id, timepoint) - if volume_path and volume_path.exists(): - title = f"{embryo_id} - t{timepoint:04d}" - else: - # Check recent_images as fallback - embryo, err = get_embryo_or_error(agent, embryo_id) - if err: - return err - if embryo.recent_images: - matching = [img for img in embryo.recent_images if img.timepoint == timepoint] - if matching: - volume_path = Path(matching[0].volume_path) - title = f"{embryo_id} - t{timepoint:04d}" - - if not volume_path or not volume_path.exists(): - # List available timepoints from store - volumes = agent.store.list_volumes(session_id, embryo_id) - available = sorted([v['timepoint'] for v in volumes]) - return f"Timepoint {timepoint} not found for {embryo_id}. Available: {available}" - else: - # Find latest volume from store + stem = p.stem # e.g. "t0005" + try: + if stem.startswith("t"): + timepoint = int(stem[1:]) + # .../embryos/{embryo_id}/volumes/t{NNNN}.tif \u2192 embryo dir is parent of "volumes" + embryo_id = p.parent.parent.name + except (ValueError, IndexError): + pass + if not embryo_id or timepoint is None: + return ("Volume viewing is now in-browser and addressed by embryo + " + "timepoint. Please specify embryo_id (and optionally timepoint) " + "rather than a raw file path.") + + if not embryo_id: + return "Error: Specify embryo_id (and optionally timepoint)." + + # Resolve the timepoint (specific or latest) and confirm the volume exists. + if timepoint is not None: + volume_path = agent.store.get_volume_path(session_id, embryo_id, timepoint) + if not volume_path or not Path(volume_path).exists(): volumes = agent.store.list_volumes(session_id, embryo_id) - if not volumes: + available = sorted(v['timepoint'] for v in volumes) + if not available: return f"No volumes found for {embryo_id} in session {session_id}" - - # Find highest timepoint - latest = max(volumes, key=lambda v: v['timepoint']) - latest_tp = latest['timepoint'] - volume_path = agent.store.get_volume_path(session_id, embryo_id, latest_tp) - - title = f"{embryo_id} - t{latest_tp:04d}" - + return f"Timepoint {timepoint} not found for {embryo_id}. Available: {available}" else: - return "Error: Specify either embryo_id or file_path" + volumes = agent.store.list_volumes(session_id, embryo_id) + if not volumes: + return f"No volumes found for {embryo_id} in session {session_id}" + timepoint = max(v['timepoint'] for v in volumes) + + # Drive the in-browser viewer \u2014 no blocking Qt/desktop window. + viz = getattr(agent, "viz_server", None) + if viz is None: + return (f"Resolved {embryo_id} t{timepoint:04d}, but the web UI isn't running, " + f"so there's nowhere to display it. Start the web UI and try again.") - # Load the volume try: - volume = tifffile.imread(str(volume_path)) - logger.info("Loaded volume: %s, dtype=%s", volume.shape, volume.dtype) + n_clients = await viz.open_volume_in_browser(embryo_id, timepoint) except Exception as e: - return f"Error loading volume: {e}" - - # Open in napari - logger.info("Opening napari viewer...") - viewer = napari.Viewer(title=title) - - # Add volume with appropriate settings - viewer.add_image( - volume, - name='Volume', - colormap='gray', - rendering='mip', # Maximum intensity projection for 3D - ) - - # Add scale bar info - viewer.scale_bar.visible = True - viewer.scale_bar.unit = "um" - - napari.run() - - return f"\u2713 Opened volume in napari: {volume_path.name} (shape: {volume.shape})" + logger.exception("open_volume_in_browser failed") + return f"Error opening volume in the web viewer: {e}" + + url = f"http://localhost:{getattr(viz, 'port', 8080)}/" + if n_clients <= 0: + return (f"Resolved {embryo_id} t{timepoint:04d}, but no browser is connected. " + f"Open {url} and select that embryo/timepoint to view it.") + return (f"\u2713 Opening {embryo_id} t{timepoint:04d} in the web volume viewer " + f"({n_clients} view(s) connected) \u2014 {url}") @tool( diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py index 4561b0f5..4ac10f90 100644 --- a/gently/harness/bridge.py +++ b/gently/harness/bridge.py @@ -1364,6 +1364,34 @@ def get_commands_json(self) -> list: }) return commands + def get_tools_json(self) -> list: + """Serialize the agent tool registry for client-side autocomplete. + + Trimmed on purpose (first description line + lightweight param list) so + the connect frame stays small. The web chat uses this for @tool-name + completion and to show a tool's arguments inline. + """ + try: + from gently.harness.tools.registry import get_tool_registry + registry = get_tool_registry() + except Exception: + return [] + tools = [] + for t in registry.list_all(): + desc = (t.description or "").strip().split("\n", 1)[0][:200] + category = getattr(t.category, "name", None) or str(t.category) + tools.append({ + "name": t.name, + "description": desc, + "category": category, + "params": [ + {"name": p.name, "type": p.type, "required": bool(p.required)} + for p in t.parameters if p.name != "context" + ], + }) + tools.sort(key=lambda x: x["name"]) + return tools + # ------------------------------------------------------------------ # Private helpers for structured command data # ------------------------------------------------------------------ @@ -1430,6 +1458,7 @@ def get_connect_metadata(self) -> dict: meta = { "session_id": self.agent.session_id, "commands": self.get_commands_json(), + "tools": self.get_tools_json(), "version": getattr(gently, "__version__", "dev"), "tokens": self._get_token_snapshot(), "embryo_count": len(exp.embryos), diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py index 765d16b2..e5de53d9 100644 --- a/gently/harness/conversation.py +++ b/gently/harness/conversation.py @@ -604,6 +604,8 @@ def stream_and_collect(): 'tool_label': tool_label_fn(block.name, block.input), } + is_error_flag = False + result_text = "" try: tool_result = await self._execute_single_tool(block.name, block.input) @@ -620,24 +622,39 @@ def stream_and_collect(): except (json.JSONDecodeError, TypeError): pass + result_text = tool_result if isinstance(tool_result, str) else str(tool_result) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": tool_result }) except Exception as e: + is_error_flag = True + result_text = f"Error: {str(e)}" tool_results.append({ "type": "tool_result", "tool_use_id": block.id, - "content": f"Error: {str(e)}", + "content": result_text, "is_error": True }) + # First non-empty line of the result, trimmed — gives the chat + # UI a one-line summary so the operator can see what a tool did + # (or didn't do), not just that it ran. + result_summary = next( + (ln.strip() for ln in (result_text or "").splitlines() if ln.strip()), + "", + ) + if len(result_summary) > 140: + result_summary = result_summary[:139] + "…" + yield { 'type': 'tool_call', 'tool_name': block.name, 'tool_input': block.input, 'duration': time.time() - start_time, + 'result_summary': result_summary, + 'is_error': is_error_flag, } self.conversation_history.append({ diff --git a/gently/harness/prompts/manager.py b/gently/harness/prompts/manager.py index 23cb9291..2d2f6f42 100644 --- a/gently/harness/prompts/manager.py +++ b/gently/harness/prompts/manager.py @@ -152,6 +152,7 @@ def get_tools_for_mode(self, mode: str, has_microscope: bool) -> list: "search_literature", "search_strains", "validate_plan", "batch_update_status", "batch_update_spec", + "snapshot_plan", "list_plan_versions", "restore_plan_version", "save_plan_template", "list_templates", "apply_template", "ask_user_choice", } diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index fc584cbc..58cfbd65 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -201,11 +201,15 @@ def _resolve_volume_path(self, embryo_id: str, timepoint: int) -> Optional[str]: if path: return path - # 2. Try FileStore (file-based, persistent) - if self.gently_store and self.timelapse_tracker.session_id: + # 2. Try FileStore (file-based, persistent). Key on the LIVE agent + # session, not the tracker's (which goes stale after a resume with no + # active timelapse) — mirrors _resolve_projection_path so an agent-driven + # open_volume hand-off doesn't 404 after a /resume. + sid = self._current_session_id() + if self.gently_store and sid: try: vol_path = self.gently_store.get_volume_path( - self.timelapse_tracker.session_id, embryo_id, timepoint, + sid, embryo_id, timepoint, ) if vol_path and vol_path.exists(): return str(vol_path) @@ -682,6 +686,30 @@ async def push_volume_3d( logger.info(f"Pushed 3D volume {uid} ({volume.shape}) to {len(self.manager.active_connections)} clients") + async def open_volume_in_browser( + self, + embryo_id: str, + timepoint: int, + view: str = "3d_viewer", + ) -> int: + """Ask every connected browser to open the in-browser volume viewer. + + This is the web-native replacement for the old napari ``view_volume``: + the agent triggers the existing ProjectionViewer (WebGL raymarcher + + projections) instead of launching a desktop Qt window that would block + the shared agent/web event loop. Returns the number of clients notified. + """ + await self.manager.broadcast({ + "type": "open_volume", + "embryo_id": embryo_id, + "timepoint": timepoint, + "view": view, + }) + n = len(self.manager.active_connections) + logger.info("Requested browser open_volume for %s t%s (%d client(s))", + embryo_id, timepoint, n) + return n + async def on_start(self): """Start the visualization server""" # Set the event loop on the event bus so async handlers work diff --git a/gently/ui/web/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css index 3fa05eb5..777582b2 100644 --- a/gently/ui/web/static/css/agent-chat.css +++ b/gently/ui/web/static/css/agent-chat.css @@ -170,6 +170,14 @@ .ac-tool-name { color: var(--text); } .ac-tool-meta { color: var(--text-muted); } .ac-tool-check { color: var(--accent-green); } + +/* Multi-line tool rows: head (icon + name + meta) over args / summary. */ +.ac-tool { flex-direction: column; align-items: stretch; gap: 4px; } +.ac-tool-head { display: flex; align-items: center; gap: 8px; } +.ac-tool-args { color: var(--text-muted); padding-left: 19px; word-break: break-word; } +.ac-tool-summary { color: var(--text-muted); padding-left: 19px; word-break: break-word; } +.ac-tool-summary-err, .ac-tool-warn { color: var(--accent-orange, #fb923c); } +.ac-tool-err { border-color: rgba(251, 146, 60, 0.35); } .ac-tool-spin { width: 11px; height: 11px; border-radius: 50%; border: 1.6px solid var(--border); @@ -234,6 +242,36 @@ display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border); + position: relative; /* anchor for the autocomplete dropdown */ +} + +/* ── Autocomplete dropdown ──────────────────────────────── */ +.ac-complete { + position: absolute; + left: 12px; right: 12px; bottom: calc(100% + 4px); + max-height: 240px; overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 9px; + box-shadow: 0 -8px 28px rgba(0, 0, 0, 0.45); + padding: 4px; + z-index: 5; +} +.ac-complete.hidden { display: none; } +.ac-complete-item { + display: flex; flex-direction: column; gap: 1px; + padding: 6px 9px; border-radius: 6px; + cursor: pointer; +} +.ac-complete-item.active, +.ac-complete-item:hover { background: var(--bg-hover, rgba(127, 127, 127, 0.12)); } +.ac-complete-name { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12.5px; color: var(--accent); +} +.ac-complete-desc { + font-size: 11.5px; color: var(--text-muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .agent-chat-input textarea { flex: 1 1 auto; resize: none; diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 577d18bd..3b10d8be 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -22,8 +22,16 @@ const AgentChat = (() => { let activityEl = null; // the persistent "working…" indicator (reused) let me = null; // { authenticated, username, role, can_control } + // Autocomplete: slash-command + @tool registries (pushed by the server on + // connect) and the live dropdown state. + let commands = []; // [{name, description, aliases, ...}] + let tools = []; // [{name, description, params, ...}] + let acItems = []; // current completion items shown in the dropdown + let acIdx = -1; // highlighted item index + // DOM refs (resolved in init) let fab, panel, log, input, sendBtn, conn, banner, closeBtn, userEl, signoutBtn; + let acComplete = null; // the autocomplete dropdown element // ── Safe rendering ──────────────────────────────────────── function escapeHtml(s) { @@ -138,6 +146,11 @@ const AgentChat = (() => { case 'connected': reconnectDelay = 1000; setConn(true, msg.version ? `Connected · v${msg.version}` : 'Connected'); + // The bridge ships the command + tool registries on connect. + // Capture them so the composer can offer autocomplete — the + // data was always on the wire; we just never used it. + commands = Array.isArray(msg.commands) ? msg.commands : []; + tools = Array.isArray(msg.tools) ? msg.tools : []; break; case 'control_status': @@ -182,10 +195,14 @@ const AgentChat = (() => { hideActivity(); // the running tool row is the signal now currentAgentEl = null; // text after a tool starts a fresh bubble const label = msg.tool_label || msg.tool_name || 'tool'; + const args = fmtArgs(msg.tool_input); const el = document.createElement('div'); el.className = 'ac-tool ac-tool-running'; el.dataset.tool = msg.tool_name || ''; - el.innerHTML = `${escapeHtml(label)}`; + el.innerHTML = + `
` + + `${escapeHtml(label)}
` + + (args ? `
${escapeHtml(args)}
` : ''); log.appendChild(el); scrollToBottom(); break; @@ -198,10 +215,29 @@ const AgentChat = (() => { const label = msg.tool_name || 'tool'; const dur = msg.duration ? ` · ${(msg.duration.toFixed ? msg.duration.toFixed(1) : msg.duration)}s` : ''; - const summary = msg.result_summary ? ` — ${escapeHtml(msg.result_summary)}` : ''; + const args = fmtArgs(msg.tool_input); + const summary = msg.result_summary || ''; + // Show ⚠ instead of ✓ when the tool errored or its result reads + // like a failure — so the operator can tell when a tool did nothing. + const isErr = !!msg.is_error || looksLikeError(summary); + const icon = isErr + ? `` + : ``; + const html = + `
${icon}` + + `${escapeHtml(label)}` + + `${dur}
` + + (args ? `
${escapeHtml(args)}
` : '') + + (summary ? `
${escapeHtml(summary)}
` : ''); if (el) { - el.className = 'ac-tool ac-tool-done'; - el.innerHTML = `${escapeHtml(label)}${dur}${summary}`; + el.className = 'ac-tool ac-tool-done' + (isErr ? ' ac-tool-err' : ''); + el.innerHTML = html; + } else { + // No matching running row (e.g. after a reconnect) — append fresh. + const fresh = document.createElement('div'); + fresh.className = 'ac-tool ac-tool-done' + (isErr ? ' ac-tool-err' : ''); + fresh.innerHTML = html; + log.appendChild(fresh); } if (streaming) setActivity('Working…'); // agent continues after the tool scrollToBottom(); @@ -296,6 +332,118 @@ const AgentChat = (() => { scrollToBottom(); } + // ── Tool argument formatting ────────────────────────────── + /** Compact, escaped "key=value" rendering of a tool's input for the chat. */ + function fmtArgs(input) { + if (!input || typeof input !== 'object') return ''; + const parts = []; + for (const [k, v] of Object.entries(input)) { + if (k === 'context' || v === null || v === undefined || v === '') continue; + let val = (typeof v === 'object') ? JSON.stringify(v) : String(v); + if (val.length > 48) val = val.slice(0, 47) + '…'; + parts.push(`${k}=${val}`); + } + return parts.join(' '); + } + + /** Heuristic: does a tool's result summary read like a failure? + * Used to show ⚠ for tools that return an error STRING (the agent only + * flags raised exceptions). Avoids false alarms like "No errors found". */ + function looksLikeError(s) { + if (!s) return false; + const t = s.trim(); + if (/^no\s+(errors?|issues?|problems?|anomal|changes?|warnings?)\b/i.test(t)) return false; + if (/^(error|failed|failure|unable|cannot|can'?t|could\s?n'?t|could not|denied|invalid|no |not )/i.test(t)) return true; + // mid-string failure markers, e.g. "Timepoint 7 not found for embryo_2". + return /\bnot (found|available|connected|recognized|valid|supported)\b/i.test(t); + } + + // ── Autocomplete ────────────────────────────────────────── + /** The whitespace-delimited token immediately left of the caret. */ + function currentToken() { + const v = input.value; + const pos = (input.selectionStart != null) ? input.selectionStart : v.length; + const before = v.slice(0, pos); + const m = before.match(/(\S+)$/); + return { token: m ? m[1] : '', start: m ? pos - m[1].length : pos, pos }; + } + + /** Compute completion items for the current input/caret, or []. */ + function computeCompletions() { + const trimmed = input.value.trimStart().toLowerCase(); + // Slash commands: whole-input prefix (mirrors the TUI). A trailing space + // (i.e. typing args) naturally yields no matches and hides the menu. + if (trimmed.startsWith('/')) { + return commands.filter(c => + (c.name && c.name.toLowerCase().startsWith(trimmed)) || + (c.aliases || []).some(a => String(a).toLowerCase().startsWith(trimmed)) + ).slice(0, 8).map(c => ({ kind: 'command', name: c.name, desc: c.description || '' })); + } + // @tool mention: complete the token under the caret against tool names. + const tok = currentToken(); + if (tok.token.startsWith('@') && tools.length) { + const q = tok.token.slice(1).toLowerCase(); + return tools.filter(t => t.name.toLowerCase().includes(q)) + .slice(0, 8) + .map(t => ({ kind: 'tool', name: t.name, desc: t.description || '', token: tok })); + } + return []; + } + + function renderCompletions(items) { + acItems = items || []; + acIdx = acItems.length ? 0 : -1; + if (!acComplete) return; + if (!acItems.length) { hideCompletions(); return; } + acComplete.innerHTML = ''; + acItems.forEach((it, i) => { + const row = document.createElement('div'); + row.className = 'ac-complete-item' + (i === acIdx ? ' active' : ''); + row.innerHTML = + `${escapeHtml(it.name)}` + + (it.desc ? `${escapeHtml(it.desc)}` : ''); + // mousedown (not click) so it fires before the textarea blurs. + row.addEventListener('mousedown', (e) => { e.preventDefault(); acceptCompletion(it); }); + acComplete.appendChild(row); + }); + acComplete.classList.remove('hidden'); + } + + function hideCompletions() { + acItems = []; + acIdx = -1; + if (acComplete) { acComplete.classList.add('hidden'); acComplete.innerHTML = ''; } + } + + function updateCompletions() { + renderCompletions(computeCompletions()); + } + + function moveCompletion(delta) { + if (!acItems.length || !acComplete) return; + acIdx = (acIdx + delta + acItems.length) % acItems.length; + [...acComplete.children].forEach((c, i) => c.classList.toggle('active', i === acIdx)); + } + + function acceptCompletion(item) { + if (!item) return; + if (item.kind === 'command') { + input.value = item.name + ' '; + const p = input.value.length; + try { input.setSelectionRange(p, p); } catch (_) {} + } else if (item.kind === 'tool') { + const tok = item.token || currentToken(); + const v = input.value; + const insert = '@' + item.name + ' '; + input.value = v.slice(0, tok.start) + insert + v.slice(tok.pos); + const p = tok.start + insert.length; + try { input.setSelectionRange(p, p); } catch (_) {} + } + hideCompletions(); + input.focus(); + autosize(); + } + // ── Control / UI state ──────────────────────────────────── function renderControl() { if (hasControl) { @@ -303,7 +451,7 @@ const AgentChat = (() => { banner.innerHTML = ''; input.disabled = false; sendBtn.disabled = false; - input.placeholder = 'Message Gently…'; + input.placeholder = 'Message Gently… ( / commands · @ tools )'; } else { banner.classList.remove('hidden'); const who = holderLabel || 'another session'; @@ -374,6 +522,7 @@ const AgentChat = (() => { // ── Input handling ──────────────────────────────────────── function submit() { if (streaming) { send({ type: 'cancel' }); return; } // Send doubles as Stop + hideCompletions(); const text = input.value.trim(); if (!text) return; if (!hasControl) { renderControl(); return; } @@ -456,9 +605,29 @@ const AgentChat = (() => { window.location.reload(); }); fetchMe(); + + // Build the autocomplete dropdown inside the composer (positioned above + // the textarea via CSS). + const inputWrap = input.parentNode; + if (inputWrap) { + acComplete = document.createElement('div'); + acComplete.className = 'ac-complete hidden'; + inputWrap.insertBefore(acComplete, inputWrap.firstChild); + } + sendBtn.addEventListener('click', submit); - input.addEventListener('input', autosize); + input.addEventListener('input', () => { autosize(); updateCompletions(); }); + // Close the menu shortly after blur (delay lets a mousedown selection land). + input.addEventListener('blur', () => setTimeout(hideCompletions, 120)); input.addEventListener('keydown', (e) => { + // While the completion menu is open it owns the navigation keys. + if (acItems.length) { + if (e.key === 'ArrowDown') { e.preventDefault(); moveCompletion(1); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); moveCompletion(-1); return; } + if (e.key === 'Tab') { e.preventDefault(); acceptCompletion(acItems[acIdx]); return; } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); acceptCompletion(acItems[acIdx]); return; } + if (e.key === 'Escape') { e.preventDefault(); hideCompletions(); return; } + } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } if (e.key === 'Escape' && streaming) { e.preventDefault(); send({ type: 'cancel' }); } }); diff --git a/gently/ui/web/static/js/projection-viewer.js b/gently/ui/web/static/js/projection-viewer.js index 1f5f530e..7360f2d6 100644 --- a/gently/ui/web/static/js/projection-viewer.js +++ b/gently/ui/web/static/js/projection-viewer.js @@ -118,6 +118,10 @@ const ProjectionViewer = { this.projections = []; this.selectedMethod = null; this.isOpen = true; + // Clear any volume from a previous open so a failed /api/volume-raw fetch + // can't leave the prior embryo/timepoint's 3D data bound (stale-render). + this.volumeData = null; + this.volumeShape = null; const modal = document.getElementById('projection-viewer-modal'); const loading = document.getElementById('pv-loading'); @@ -280,6 +284,10 @@ const ProjectionViewer = { }, selectMethod(method) { + // If the 3D view is requested but no volume loaded (e.g. /api/volume-raw + // failed while projections succeeded), fall back to the projections grid + // rather than showing an empty, never-initialized 3D panel. + if (method === '3d_viewer' && !this.volumeData) method = null; this.selectedMethod = method; this.renderProjections(); this.renderTabs(); diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index 1e3a534c..069724a2 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -127,6 +127,20 @@ function handleMessage(msg) { // Switch to embryos tab if not already there if (state.tab !== 'embryos') switchTab('embryos'); } + } else if (msg.type === 'open_volume') { + // The agent asked us to open the in-browser volume viewer — the + // web-native replacement for the old desktop napari window. + if (typeof ProjectionViewer !== 'undefined' && msg.embryo_id != null) { + const view = msg.view || '3d_viewer'; + Promise.resolve(ProjectionViewer.open(msg.embryo_id, msg.timepoint)) + .then(() => { + // Default to the 3D viewer tab when the agent opens it. + if (view && typeof ProjectionViewer.selectMethod === 'function') { + ProjectionViewer.selectMethod(view); + } + }) + .catch((e) => console.warn('open_volume failed', e)); + } } else if (msg.type === 'session_changed') { // The live agent switched sessions (resume from the Sessions tab) — // reload so every client picks up the new session's state + transcript. From c5009acf497e8624b9820fa278bda3113ca166cd Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 17:42:01 -0400 Subject: [PATCH 054/314] Integrate the agent with perception: pull tool, prompt context, event bridge, wake-router Closes the gap where perception ran as a fire-and-forget tool the agent never saw. agent.perceiver is the same Perceiver the orchestrator drives, so all reads are direct and side-effect-free. B1 (read-only): - get_recent_perceptions tool returns live per-embryo stage / stability / arrest / trajectory + the perceiver's reasoning. - A deterministic '## Perception (live)' section is injected into the system prompt (build_perception_snapshot), bypassing the AI context-summary cache so stage data is never stale. B2 (bridge + unify): - The perception path's DETECTOR_EVALUATED now mirrors into EmbryoState (latest_developmental_stage) on stage CHANGE only; role=test pseudo-stages, recheck-skips, and the 'no_object' sentinel are filtered out. This fixes the long-standing dead wiring (the agent subscribed to STAGE_DETECTED, which the perception loop never emits). - get_stage_history / predict_hatching now read the live Perceiver (hatching time computed from gently_perception's own organism stage durations), falling back to the DevelopmentalTracker. B3 (decision-moment wake-router, opt-in / default OFF): - gently/app/wake_router.py wakes the agent on stage transitions + critical events (hatching / arrest / embryo-terminated / errors), coalesced and throttled (critical bypasses the throttle); deferred events are re-armed, not dropped. Enabled via the set_autonomy tool. Full autonomy on a wake; device limits still bound it. - A new agent turn-lock serializes wake turns against user turns on the shared conversation history; run_wake_turn drives the normal streaming pipeline. Review fixes (adversarial pass): - bridge.stream_response now closes the agent generator in a finally, so the turn-lock always releases on cancel/error (was: stalled the next turn). - wake-router evaluates its guards before draining _pending so co-pending critical events survive an in-flight turn. - 'no_object' no longer mirrors as a developmental stage or triggers a wake; autonomous turns log when they auto-cancel an interactive picker. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/app/agent.py | 155 +++++++++++++++--- gently/app/tools/analysis_tools.py | 88 ++++++++++- gently/app/tools/timelapse_tools.py | 128 ++++++++++++++- gently/app/wake_router.py | 235 ++++++++++++++++++++++++++++ gently/harness/bridge.py | 10 ++ gently/harness/prompts/manager.py | 3 +- gently/harness/prompts/templates.py | 55 +++++++ 7 files changed, 646 insertions(+), 28 deletions(-) create mode 100644 gently/app/wake_router.py diff --git a/gently/app/agent.py b/gently/app/agent.py index 01c7371d..4d83b4b9 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -143,6 +143,10 @@ def __init__( self.on_message_callback: Optional[Callable] = None self.choice_handler: Optional[Callable] = None + # Serializes conversation turns: user turns and autonomous wake turns + # must not interleave on the shared conversation_history. + self._turn_lock = asyncio.Lock() + # Interaction logger for structured logging (research data collection) self.interaction_logger: Optional[InteractionLogger] = None @@ -241,6 +245,16 @@ def __init__( # Subscribe to CV result events for EmbryoState integration self._subscribe_to_cv_events() + # Decision-moment wake-router (opt-in, default OFF). Wakes the agent on + # wake-worthy perception/lifecycle events so it can adapt acquisition + # autonomously; enabled via the set_autonomy tool. + try: + from gently.app.wake_router import WakeRouter + self.wake_router = WakeRouter(self, self._event_bus) + except Exception: + logger.exception("Failed to init wake-router") + self.wake_router = None + # Build initial system prompt self._update_system_prompt() @@ -420,7 +434,8 @@ def exit_plan_mode(self) -> str: def _update_system_prompt(self, context_summary: str = None): """Rebuild system prompt via PromptManager.""" self.system_prompt = self.prompts.update_system_prompt( - self.experiment, self.client, self.mode, context_summary + self.experiment, self.client, self.mode, context_summary, + perceiver=getattr(self, "perceiver", None), ) def _get_active_plan_summary(self) -> Optional[str]: @@ -637,6 +652,45 @@ def on_stage_detected(event): unsub = self._event_bus.subscribe(EventType.STAGE_DETECTED, on_stage_detected) self._cv_subscriptions.append(unsub) + def on_perception(event): + # Bridge the perception loop's DETECTOR_EVALUATED into EmbryoState so + # the prompt/display developmental stage reflects the live Perceiver. + # (The STAGE_DETECTED wiring above is never emitted by the perception + # path — this closes that long-standing gap.) Record only on an + # actual stage CHANGE to keep cv_analyses a clean transition log and + # avoid per-timepoint disk/cache churn; live stability/timing is read + # straight from the Perceiver by the prompt snapshot + pull tool. + try: + data = event.data + if data.get("skipped") or data.get("detector_name") != "perception": + return # ignore recheck-skips and role=test pseudo-stages + embryo_id = data.get("embryo_id") + stage = data.get("stage") + # 'no_object' is an empty-field sentinel, not a developmental + # stage — don't mirror it into latest_developmental_stage. + if (not stage or stage == "no_object" or not embryo_id + or embryo_id not in self.experiment.embryos): + return + embryo = self.experiment.embryos[embryo_id] + if stage == getattr(embryo, "latest_developmental_stage", None): + return # steady state — nothing new to mirror + embryo.add_cv_result("stage_classification", { + "stage": stage, + "timepoint": data.get("timepoint"), + "stability": data.get("stability"), + "temporal_analysis": data.get("temporal_analysis"), + "detector_name": "perception", + }) + self.invalidate_context_cache() + self._auto_save() + logger.info("Perception: %s -> stage %s (t%s)", + embryo_id, stage, data.get("timepoint")) + except Exception as e: + logger.warning(f"Error handling perception event: {e}") + + unsub = self._event_bus.subscribe(EventType.DETECTOR_EVALUATED, on_perception) + self._cv_subscriptions.append(unsub) + logger.debug("Subscribed to CV result events") except Exception as e: @@ -877,35 +931,86 @@ async def handle_message_stream(self, user_message: str): yield {'type': 'text', 'text': quick_response} return - context_summary = await self.prompts.get_cached_context_summary( - self.experiment, self.timelapse_orchestrator, self.timeline_manager - ) - self._update_system_prompt(context_summary) + # Hold the turn-lock for the whole streamed turn so an autonomous wake + # turn cannot interleave on the shared conversation_history. + lock = getattr(self, "_turn_lock", None) + acquired = False + if lock is not None: + await lock.acquire() + acquired = True + try: + context_summary = await self.prompts.get_cached_context_summary( + self.experiment, self.timelapse_orchestrator, self.timeline_manager + ) + self._update_system_prompt(context_summary) - self.conversation.conversation_history.append({ - "role": "user", - "content": user_message - }) + self.conversation.conversation_history.append({ + "role": "user", + "content": user_message + }) - tools = self._get_tools_for_mode() - cached_prompt = self._get_cached_system_prompt() + tools = self._get_tools_for_mode() + cached_prompt = self._get_cached_system_prompt() - inner_gen = self.conversation.call_claude_stream( - cached_prompt, tools, - tool_label_fn=self.conversation.tool_label, - auto_save_fn=self._auto_save, - ) - sent_value = None + inner_gen = self.conversation.call_claude_stream( + cached_prompt, tools, + tool_label_fn=self.conversation.tool_label, + auto_save_fn=self._auto_save, + ) + sent_value = None + try: + while True: + if sent_value is None: + chunk = await inner_gen.__anext__() + else: + chunk = await inner_gen.asend(sent_value) + sent_value = yield chunk + except StopAsyncIteration: + return + finally: + if acquired: + lock.release() + + async def run_wake_turn(self, wake_note: str): + """Drive one autonomous (no-user) turn for the wake-router. + + Runs through the normal streaming pipeline (so it acquires the turn-lock + and is recorded to conversation history / auto-saved). Surfaces text via + on_message_callback if a UI wired one; always runs so the decision and + any tool actions are persisted even with no client attached. Only acts in + run mode. + """ + if self.mode != "run": + logger.info("Wake turn skipped — agent not in run mode (mode=%s)", self.mode) + return "" + text_parts = [] try: - while True: - if sent_value is None: - chunk = await inner_gen.__anext__() - else: - chunk = await inner_gen.asend(sent_value) - sent_value = yield chunk - except StopAsyncIteration: - return + async for chunk in self.handle_message_stream(wake_note): + if isinstance(chunk, dict) and chunk.get("type") == "text": + text_parts.append(chunk.get("text", "")) + elif isinstance(chunk, dict) and chunk.get("type") == "choice_request": + # A plain async-for resumes the generator with None, which the + # picker resolves as 'cancelled'. Make that visible — there is + # no operator to answer during an autonomous turn. + cd = chunk.get("choice_data", {}) + logger.warning( + "Wake turn invoked an interactive picker (%s); auto-cancelling " + "— no operator present.", cd.get("question", "?")) + cb = getattr(self, "on_message_callback", None) + if cb is not None: + try: + res = cb(chunk) + if asyncio.iscoroutine(res): + await res + except Exception: + logger.debug("on_message_callback failed for wake chunk", exc_info=True) + except Exception: + logger.exception("run_wake_turn error") + summary = "".join(text_parts).strip() + if summary: + logger.info("Autonomous wake turn result: %s", summary[:500]) + return summary async def get_tool_call(self, user_message: str) -> Optional[Dict]: """Dry-run tool call (for benchmarking).""" diff --git a/gently/app/tools/analysis_tools.py b/gently/app/tools/analysis_tools.py index 75422e77..ff813ad3 100644 --- a/gently/app/tools/analysis_tools.py +++ b/gently/app/tools/analysis_tools.py @@ -6,7 +6,7 @@ from typing import Dict, Optional -from gently.harness.tools.registry import tool, ToolCategory +from gently.harness.tools.registry import tool, ToolCategory, ToolExample from gently.harness.tools.helpers import require_agent, get_embryo_or_error @@ -43,6 +43,92 @@ async def analyze_volume( return f"Error analyzing volume: {str(e)}" +@tool( + name="get_recent_perceptions", + description="""Get the latest perception state for one embryo or all embryos: +current developmental stage, how many consecutive observations it has held that stage +(stability), a possible-arrest signal, the recent stage trajectory, and the +perceiver's reasoning. Source: the LIVE perception loop (reads accumulated state, +does not trigger a fresh capture). +Use when the user asks "what stage is embryo X", "is anything stuck/arrested", +"how are the embryos developing", or before deciding whether to adapt acquisition.""", + category=ToolCategory.ANALYSIS, + examples=[ + ToolExample("What stage is embryo_1 at?", {"embryo_id": "embryo_1"}), + ToolExample("How is everything developing?", {}), + ToolExample("Is anything arrested?", {}), + ], +) +def get_recent_perceptions( + embryo_id: Optional[str] = None, + n: int = 5, + context: Dict = None, +) -> str: + """Read live per-embryo perception state from the perception sessions. + + All reads here (get_session / summary / attribute access) are synchronous and + side-effect-free — they never trigger a VLM call. + """ + agent, err = require_agent(context) + if err: + return err + + perceiver = getattr(agent, "perceiver", None) + if perceiver is None: + return "Perception system not available." + + def _one(eid: str) -> str: + try: + session = perceiver.get_session(eid) + except Exception as e: + return f"{eid}: perception read failed ({e})" + if session is None or not getattr(session, "current_stage", None): + return f"{eid}: no perceptions recorded yet" + summary = session.summary() + lines = [ + f"{eid}: stage={summary.get('current_stage')} " + f"(stable for {summary.get('stability', 0)} obs, " + f"{summary.get('observation_count', 0)} total)" + ] + seq = summary.get("stage_sequence") or [] + if seq: + lines.append(f" trajectory: {' -> '.join(seq)}") + temporal = summary.get("temporal") # TemporalContext dataclass or None + if temporal is not None: + tmin = getattr(temporal, "time_in_stage_min", 0.0) + exp = getattr(temporal, "expected_duration_min", None) + seg = f" time in stage: {tmin:.0f} min" + if exp: + seg += (f" (expected ~{exp:.0f} min, " + f"{getattr(temporal, 'overtime_ratio', 0.0):.1f}x)") + lines.append(seg) + if getattr(temporal, "is_potentially_arrested", False): + lines.append(" ** potentially ARRESTED **") + observations = getattr(session, "observations", None) or [] + if observations and n > 0: + recent = observations[-n:] + lines.append(f" recent observations (last {len(recent)}):") + for o in recent: + reason = (getattr(o, "reasoning", "") or "").strip().replace("\n", " ") + if len(reason) > 160: + reason = reason[:159] + "…" + lines.append(f" t{getattr(o, 'timepoint', '?')}: " + f"{getattr(o, 'stage', '?')} - {reason}") + return "\n".join(lines) + + if embryo_id: + return _one(embryo_id) + + embryos = getattr(agent.experiment, "embryos", {}) or {} + if not embryos: + return "No embryos in the experiment." + out = ["Perception state (all embryos):", ""] + for eid in sorted(embryos): + out.append(_one(eid)) + out.append("") + return "\n".join(out).rstrip() + + @tool( name="get_detection_summary", description="Get summary of all detections across all embryos", diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index 2e01fa60..62b846f5 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -6,7 +6,7 @@ from typing import Dict, List, Optional -from gently.harness.tools.registry import tool, ToolCategory +from gently.harness.tools.registry import tool, ToolCategory, ToolExample from gently.harness.tools.helpers import ( require_agent, get_embryo_or_error, require_timelapse_orchestrator, require_developmental_tracker @@ -535,6 +535,32 @@ def get_stage_history( if err: return err + # Prefer the live perception session (the orchestrator's Perceiver, which the + # agent shares). The DevelopmentalTracker below is only populated by manual + # classify_embryo_stage calls, so it is usually empty in autonomous runs. + perceiver = getattr(agent, "perceiver", None) + session = perceiver.get_session(embryo_id) if perceiver else None + if session is not None and getattr(session, "current_stage", None): + s = session.summary() + lines = [ + f"Stage progression for {embryo_id} (live perception):", + f" Current stage: {s.get('current_stage')} (stable for {s.get('stability', 0)} obs)", + f" Observations: {s.get('observation_count', 0)}", + ] + seq = s.get("stage_sequence") or [] + if seq: + lines.append(f" Trajectory: {' -> '.join(seq)}") + t = s.get("temporal") # TemporalContext dataclass or None + if t is not None: + exp = getattr(t, "expected_duration_min", None) + seg = f" Time in current stage: {getattr(t, 'time_in_stage_min', 0.0):.0f} min" + if exp: + seg += f" (expected ~{exp:.0f} min)" + lines.append(seg) + if getattr(t, "is_potentially_arrested", False): + lines.append(" ** potentially ARRESTED **") + return "\n".join(lines) + tracker, err = require_developmental_tracker(agent) if err: return err @@ -558,6 +584,42 @@ def get_stage_history( return "\n".join(lines) +def _perceiver_hatching_estimate(session) -> Optional[float]: + """Estimate minutes until the 'hatching' stage from the perception session. + + Uses gently_perception's own organism stage ordering + typical durations, so + no DevelopmentalStage enum mapping is needed. Returns None when unknown + (no_object / off-vocabulary stage), 0.0 when already hatching/hatched. + """ + try: + from gently_perception.organism import CELEGANS + except Exception: + return None + stage = getattr(session, "current_stage", None) + if not stage or stage == "no_object": + return None + stages = list(CELEGANS.stages) + durations = dict(CELEGANS.stage_durations) + if stage in ("hatching", "hatched"): + return 0.0 + if stage not in stages or "hatching" not in stages: + return None + idx = stages.index(stage) + target = stages.index("hatching") + if idx >= target: + return 0.0 + # Remaining time in the current stage (expected minus already-elapsed). + elapsed = 0.0 + t = session.summary().get("temporal") + if t is not None: + elapsed = getattr(t, "time_in_stage_min", 0.0) or 0.0 + remaining = max(0.0, durations.get(stage, 0.0) - elapsed) + # Plus the full expected duration of each stage between current and hatching. + for s in stages[idx + 1:target]: + remaining += durations.get(s, 0.0) + return remaining + + @tool( name="predict_hatching", description="Predict time-to-hatching for an embryo with confidence intervals based on developmental stage", @@ -573,6 +635,39 @@ def predict_hatching( if err: return err + # Prefer the live perception session; the DevelopmentalTracker is usually + # empty in autonomous runs (only manual classify_embryo_stage feeds it). + perceiver = getattr(agent, "perceiver", None) + + def _perc_line(eid: str): + session = perceiver.get_session(eid) if perceiver else None + if session is None or not getattr(session, "current_stage", None): + return None + stage = session.current_stage + if stage in ("hatching", "hatched"): + return f" {eid}: stage={stage} (hatching now / already hatched)" + est = _perceiver_hatching_estimate(session) + if est is None: + return f" {eid}: stage={stage} (time-to-hatching unknown)" + return f" {eid}: stage={stage}, ~{est / 60:.1f}h to hatching ({est:.0f} min)" + + if perceiver is not None: + if all_embryos: + ids = list(agent.experiment.embryos.keys()) + perc = [_perc_line(e) for e in ids] + if any(perc): + out = ["Hatching predictions (live perception):", ""] + out += [p for p in perc if p] + missing = [e for e, p in zip(ids, perc) if not p] + if missing: + out.append("") + out.append(f"(no perception yet for: {', '.join(missing)})") + return "\n".join(out) + elif embryo_id: + line = _perc_line(embryo_id) + if line: + return f"Hatching prediction for {embryo_id} (live perception):\n{line}" + tracker, err = require_developmental_tracker(agent) if err: return err @@ -629,6 +724,37 @@ def predict_hatching( return "\n".join(lines) +@tool( + name="set_autonomy", + description="""Enable or disable autonomous mode (the decision-moment wake-router). +When ON, the agent wakes itself between user messages on important perception events +(developmental stage transitions, potential arrest, hatching, embryo termination, or +errors) and may adjust acquisition on its own (interval, power, stop conditions, +bursts). Default is OFF. Device-layer safety limits still bound any action. +Use when the user says "enable autopilot", "watch and adapt on your own", "go +autonomous", or "turn off autonomy".""", + category=ToolCategory.ANALYSIS, + examples=[ + ToolExample("Enable autonomous mode", {"enabled": True}), + ToolExample("Turn off autopilot", {"enabled": False}), + ], +) +def set_autonomy(enabled: bool = True, context: Dict = None) -> str: + """Toggle the decision-moment wake-router.""" + agent, err = require_agent(context) + if err: + return err + router = getattr(agent, "wake_router", None) + if router is None: + return "Autonomy is not available (wake-router failed to initialize)." + state = router.set_enabled(enabled) + if state: + return ("Autonomous mode ENABLED. I'll wake on stage transitions, potential " + "arrest, hatching, termination, and errors — and adapt acquisition as " + "needed. Say 'turn off autonomy' to stop.") + return "Autonomous mode disabled. I'll only act when you message me." + + # --------------------------------------------------------------------------- # Reactive monitoring modes (Phase 5) — high-level "install canonical # detector → cadence + power reactive rules" entry points. Without one of diff --git a/gently/app/wake_router.py b/gently/app/wake_router.py new file mode 100644 index 00000000..344e9d71 --- /dev/null +++ b/gently/app/wake_router.py @@ -0,0 +1,235 @@ +"""Decision-moment wake-router for autonomous agent turns. + +Subscribes to wake-worthy perception/lifecycle events and, when enabled, wakes +the conversational agent between user messages so it can re-decide acquisition +(cadence, power, stop conditions) in response to what perception sees — the +closed loop. + +Design (opt-in, default OFF): + * Triggers: critical events (hatching / arrest / embryo-terminated / errors) + plus developmental stage transitions. No periodic heartbeat. + * Debounce: a burst of events inside COALESCE_WINDOW collapses into ONE wake. + * Throttle: non-critical wakes are rate-limited by MIN_WAKE_INTERVAL; critical + events bypass the throttle. + * Serialization: the wake turn runs through the agent's normal streaming + pipeline, which holds the agent turn-lock, so it never races a user turn. + A wake therefore waits for any in-progress user turn — including an open + choice picker — to finish before it runs; "critical bypasses the throttle" + means it skips MIN_WAKE_INTERVAL, not that it preempts an active user turn + (preempting would interleave on the shared conversation history). + +Nothing fires until ``set_enabled(True)`` (e.g. via the set_autonomy tool). +""" +from __future__ import annotations + +import asyncio +import logging + +from gently.core.event_bus import EventType + +logger = logging.getLogger(__name__) + +# Tunables (seconds). +COALESCE_WINDOW = 20.0 # collapse a burst of events into one wake +MIN_WAKE_INTERVAL = 120.0 # throttle non-critical wakes + +# Events that always wake immediately (bypass MIN_WAKE_INTERVAL). +CRITICAL_EVENTS = frozenset({ + EventType.HATCHING_DETECTED, + EventType.EMBRYO_TERMINATED, + EventType.ERROR_OCCURRED, + EventType.ACQUISITION_FAILED, + EventType.ANOMALY_DETECTED, +}) +# Non-critical events we also inspect (filtered for real transitions / arrest). +WATCH_EVENTS = frozenset({EventType.DETECTOR_EVALUATED}) + + +class WakeRouter: + """Routes wake-worthy events into coalesced, throttled autonomous agent turns.""" + + def __init__(self, agent, bus): + self.agent = agent + self.bus = bus + self.enabled = False + self._loop = None + self._pending = [] # list[(EventType, dict)] + self._flush_handle = None # TimerHandle for the coalesce window + self._last_wake = 0.0 # loop.time() of the last fired wake + self._last_stage = {} # embryo_id -> last stage seen (transition detection) + self._in_flight = False + self._unsubs = [] + self._subscribe() + + # -- public control ------------------------------------------------- + def set_enabled(self, enabled: bool) -> bool: + self.enabled = bool(enabled) + if not self.enabled: + self._cancel_flush() + self._pending.clear() + logger.info("Wake-router %s", "ENABLED" if self.enabled else "disabled") + return self.enabled + + def is_enabled(self) -> bool: + return self.enabled + + def shutdown(self): + self._cancel_flush() + for unsub in self._unsubs: + try: + unsub() + except Exception: + pass + self._unsubs.clear() + + # -- subscription --------------------------------------------------- + def _subscribe(self): + for et in (CRITICAL_EVENTS | WATCH_EVENTS): + try: + self._unsubs.append( + self.bus.subscribe(et, lambda e, _et=et: self._on_event(_et, e)) + ) + except Exception: + logger.exception("wake-router failed to subscribe %s", et) + + # -- event intake --------------------------------------------------- + def _on_event(self, event_type, event): + # Synchronous handler (the bus calls it inline). Cheap-filter, then + # schedule a coalesced flush on the running loop. Never raise — the bus + # swallows handler exceptions, so failures would otherwise vanish. + try: + if not self.enabled: + return + if self._loop is None: + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + return # no running loop -> can't schedule a wake; drop + data = getattr(event, "data", None) or {} + if not self._is_wake_worthy(event_type, data): + return + self._pending.append((event_type, data)) + self._schedule_flush(critical=event_type in CRITICAL_EVENTS) + except Exception: + logger.exception("wake-router _on_event error") + + def _is_wake_worthy(self, event_type, data) -> bool: + if event_type in CRITICAL_EVENTS: + return True + if event_type == EventType.DETECTOR_EVALUATED: + if data.get("skipped"): + return False + if data.get("detector_name") != "perception": + return False # role=test pseudo-stages are not developmental + stage = data.get("stage") + if not stage or stage == "no_object": + return False # empty-field sentinel — not a developmental change + ta = data.get("temporal_analysis") or {} + if ta.get("is_potentially_arrested"): + return True + eid = data.get("embryo_id") + last = self._last_stage.get(eid) + self._last_stage[eid] = stage + return stage != last # only a real transition wakes + return False + + # -- coalescing / flush -------------------------------------------- + def _schedule_flush(self, critical: bool): + loop = self._loop + if loop is None: + return + delay = 0.0 if critical else COALESCE_WINDOW + if self._flush_handle is None: + self._flush_handle = loop.call_later(delay, self._fire_flush) + elif critical: + # bring a pending window-flush forward + self._flush_handle.cancel() + self._flush_handle = loop.call_later(0.0, self._fire_flush) + + def _cancel_flush(self): + if self._flush_handle is not None: + try: + self._flush_handle.cancel() + except Exception: + pass + self._flush_handle = None + + def _fire_flush(self): + self._flush_handle = None + loop = self._loop + if loop is not None: + asyncio.ensure_future(self._flush(), loop=loop) + + async def _flush(self): + if not self._pending or not self.enabled: + self._pending.clear() + return + # Evaluate the guards BEFORE draining so a deferral can't lose events. + critical = any(et in CRITICAL_EVENTS for et, _ in self._pending) + now = self._loop.time() if self._loop else 0.0 + if self._in_flight or (not critical and (now - self._last_wake) < MIN_WAKE_INTERVAL): + # A wake is already running, or we're inside the non-critical throttle + # window. Keep _pending intact and re-arm so these events — including + # any CRITICAL ones — are retried once the turn finishes / window + # elapses, rather than being dropped. + logger.debug("wake deferred (in_flight=%s critical=%s)", self._in_flight, critical) + # Retry on the coalesce window (not delay 0) so a critical event + # deferred behind an in-flight turn doesn't busy-spin call_later(0). + self._schedule_flush(critical=False) + return + events = self._pending + self._pending = [] + self._in_flight = True + self._last_wake = now + try: + note = self._build_wake_note(events) + logger.info("Wake-router firing autonomous turn (%d event(s))", len(events)) + await self.agent.run_wake_turn(note) + except Exception: + logger.exception("wake turn failed") + finally: + self._in_flight = False + + # -- wake package --------------------------------------------------- + def _build_wake_note(self, events) -> str: + from gently.harness.prompts.templates import build_perception_snapshot + triggers = [] + for et, data in events: + name = getattr(et, "name", str(et)) + eid = data.get("embryo_id", "?") + stage = data.get("stage") + if et == EventType.HATCHING_DETECTED: + triggers.append(f"{eid}: hatching detected") + elif et == EventType.EMBRYO_TERMINATED: + triggers.append(f"{eid}: terminated ({data.get('completion_reason', '?')})") + elif et in (EventType.ERROR_OCCURRED, EventType.ACQUISITION_FAILED, + EventType.ANOMALY_DETECTED): + triggers.append(f"{eid}: {name.lower().replace('_', ' ')}") + elif et == EventType.DETECTOR_EVALUATED: + ta = data.get("temporal_analysis") or {} + if ta.get("is_potentially_arrested"): + triggers.append(f"{eid}: potential arrest at stage {stage}") + else: + triggers.append(f"{eid}: stage -> {stage}") + else: + triggers.append(f"{eid}: {name.lower()}") + triggers = list(dict.fromkeys(triggers)) # dedupe, preserve order + + try: + snap = build_perception_snapshot( + getattr(self.agent, "perceiver", None), + getattr(getattr(self.agent, "experiment", None), "embryos", {}) or {}, + ) + except Exception: + snap = "" + snap = snap or "(no live perception data)" + + return ( + "[AUTONOMOUS WAKE] Something changed while no one was typing — decide if " + "any acquisition change is warranted.\n\n" + f"What triggered this: {'; '.join(triggers)}\n\n" + f"{snap}\n\n" + "If a change helps (adjust interval/power, add a stop condition, queue a " + "burst, or stop an embryo), do it now using your tools. If nothing needs " + "doing, say so briefly and take no action." + ) diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py index 4ac10f90..028ce2d7 100644 --- a/gently/harness/bridge.py +++ b/gently/harness/bridge.py @@ -623,6 +623,16 @@ async def stream_response( except Exception as e: logger.error(f"Stream error: {e}", exc_info=True) await send_fn({"type": "error", "error": str(e)}) + finally: + # Deterministically close the agent generator so its turn-lock (and + # any other resources) release immediately. Without this, a cancelled + # or aborted stream leaves the generator suspended at a `yield` still + # holding self._turn_lock until non-deterministic GC, stalling the + # next user turn and any autonomous wake turn on lock.acquire(). + try: + await stream_iter.aclose() + except Exception: + pass async def handle_command( self, diff --git a/gently/harness/prompts/manager.py b/gently/harness/prompts/manager.py index 2d2f6f42..cde5db44 100644 --- a/gently/harness/prompts/manager.py +++ b/gently/harness/prompts/manager.py @@ -47,7 +47,7 @@ def __init__(self, claude_client, model): self.memory = None # AgentMemory instance def update_system_prompt(self, experiment, client, mode: str, - context_summary: str = None) -> str: + context_summary: str = None, perceiver=None) -> str: """ Rebuild system prompt with current experiment state and connection status. @@ -96,6 +96,7 @@ def update_system_prompt(self, experiment, client, mode: str, experiment, connection_status, context_summary, memory_awareness=memory_awareness, microscope=client, + perceiver=perceiver, ) def get_tools_for_mode(self, mode: str, has_microscope: bool) -> list: diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index 55894c22..a2f7634f 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -290,12 +290,57 @@ """ +def build_perception_snapshot(perceiver, embryos) -> str: + """One compact line per embryo of live perception state for the system prompt. + + Reads straight from the perception sessions (current stage, stability, time in + stage, arrest signal, short trajectory). Every read here is synchronous and + side-effect-free — it never triggers a VLM call. Returns '' when there is + nothing to show, so callers can drop the section entirely. + """ + if not perceiver or not embryos: + return "" + lines = [] + for embryo_id in sorted(embryos): + try: + session = perceiver.get_session(embryo_id) + summary = session.summary() if session is not None else None + except Exception: + summary = None + if not summary or not summary.get("current_stage"): + lines.append(f"- {embryo_id}: no perception yet") + continue + parts = [ + f"stage={summary['current_stage']}", + f"stable={summary.get('stability', 0)}x", + ] + temporal = summary.get("temporal") # TemporalContext dataclass or None + if temporal is not None: + tmin = getattr(temporal, "time_in_stage_min", None) + exp = getattr(temporal, "expected_duration_min", None) + if tmin is not None: + seg = f"in_stage={tmin:.0f}min" + if exp: + seg += f"/{exp:.0f}" + parts.append(seg) + if getattr(temporal, "is_potentially_arrested", False): + parts.append("ARRESTED?") + seq = summary.get("stage_sequence") or [] + if len(seq) > 1: + parts.append("traj=" + "->".join(seq[-4:])) + lines.append(f"- {embryo_id}: " + " ".join(parts)) + if not lines: + return "" + return "## Perception (live)\n\n" + "\n".join(lines) + + def build_system_prompt( experiment_state: ExperimentState, connection_status: dict = None, context_summary: str = None, memory_awareness: str = None, microscope=None, + perceiver=None, ) -> str: """ Build complete system prompt for Claude @@ -357,6 +402,15 @@ def build_system_prompt( else: context_section = "" + # Live per-embryo perception snapshot (deterministic, read straight from the + # perception sessions — bypasses the AI context-summary cache so stage data is + # never stale). + perception_section = "" + if perceiver is not None and experiment_state.embryos: + snap = build_perception_snapshot(perceiver, experiment_state.embryos) + if snap: + perception_section = f"\n{snap}\n" + # Pull organism-specific content from the active organism module organism = get_organism() organism_display = organism.ORGANISM_DISPLAY_NAME @@ -404,6 +458,7 @@ def build_system_prompt( # Current Experiment State {embryo_summary} +{perception_section} {context_section} # Tool Use Guidelines From c4bfac2d57fbe03744358831ec5916baa338b53e Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 19:13:57 -0400 Subject: [PATCH 055/314] Live acquisition control, observable + permissioned autonomy, and a refreshed prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the perception integration: gives the agent real live control over acquisition, makes its autonomous decisions visible and gated, fixes message interleaving, and updates the long-stale system prompt. Live control (A/B): - modify_timelapse_interval (whole run) + set_embryo_cadence (one embryo) change cadence on a running timelapse and correctly re-anchor next_due_at (closes the gap where no working live-interval tool existed). set_embryo_cadence reports a no-op instead of a misleading reschedule message. - set_photodose_budget (caps cumulative exposure; resumes budget-paused embryos on a raise, only when they're back under the cap) + get_photodose_status. - (snap-mode hardware plumbing deferred — needs real-hardware validation.) Observability (C): - Autonomous wake turns now stream to every web chat client and persist to the transcript, rendered distinctly: a 'Gently woke up — ' banner + a 'Gently · autonomous' bubble. bridge.register_display_broadcaster wires the previously-dead on_message_callback; run_wake_turn brackets the turn with autonomous_start/stream_end. Previously autonomous turns were invisible. Interleaving (D): - Typing while the agent is busy now QUEUES (with per-message remove + clear-all + auto-drain on idle) instead of cancelling; a separate Stop button replaces Send-as-Stop; the composer shows 'working' vs 'acting autonomously'. Slash commands no longer wedge the composer busy. Autonomy modes (E): - OFF / ASK / AUTO tri-state via set_autonomy, switchable mid-run. ASK proposes a change and waits for Approve/Modify/Skip in the chat (round-tripped through the wake choice channel, bounded by a timeout->Skip, lock released via aclose). Hybrid backstop: a few irreversible tools (set_laser_power, remove_embryo, stop_timelapse) can never run during an autonomous turn, enforced in the registry regardless of mode. System prompt (F): - Replaced the fictional cv_analyze 'CV subagent' block with an accurate Perception & Analysis section; added an Adapting-Acquisition (gentleness-first) + Autonomy (OFF/ASK/AUTO) section; removed the nonexistent enable_preset_detector reference and the stale interval tool name. Review fixes: turn-lock released on disconnect-cancel (CancelledError handled as 'cancelled'); picker futures discarded on timeout/cancel; deferred critical wakes re-fire promptly after a turn; Escape-cancel + the autonomous history flag reset cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/app/agent.py | 124 +++++++++--- gently/app/tools/timelapse_tools.py | 238 ++++++++++++++++++++++-- gently/app/wake_router.py | 68 +++++-- gently/harness/bridge.py | 42 +++++ gently/harness/prompts/templates.py | 150 ++++++++------- gently/harness/tools/registry.py | 14 ++ gently/ui/web/routes/agent_ws.py | 29 ++- gently/ui/web/static/css/agent-chat.css | 69 ++++++- gently/ui/web/static/js/agent-chat.js | 158 ++++++++++++++-- 9 files changed, 752 insertions(+), 140 deletions(-) diff --git a/gently/app/agent.py b/gently/app/agent.py index 4d83b4b9..9b144f92 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -147,6 +147,17 @@ def __init__( # must not interleave on the shared conversation_history. self._turn_lock = asyncio.Lock() + # Autonomy backstop: while a wake turn runs, _autonomous_active is True + # and the registry refuses these irreversible tools (they require a + # human). User turns are unaffected. _wake_choice_factory is set by the + # web bridge so ASK-mode wake turns can round-trip an approval picker. + self._autonomous_active = False + self._autonomous_blocked_tools = frozenset({ + "set_laser_power", "remove_embryo", "stop_timelapse", + }) + self._wake_choice_factory = None + self._wake_choice_discard = None + # Interaction logger for structured logging (research data collection) self.interaction_logger: Optional[InteractionLogger] = None @@ -972,46 +983,113 @@ async def handle_message_stream(self, user_message: str): if acquired: lock.release() - async def run_wake_turn(self, wake_note: str): + async def run_wake_turn(self, wake_note: str, trigger: str = None, interactive: bool = False): """Drive one autonomous (no-user) turn for the wake-router. Runs through the normal streaming pipeline (so it acquires the turn-lock - and is recorded to conversation history / auto-saved). Surfaces text via - on_message_callback if a UI wired one; always runs so the decision and - any tool actions are persisted even with no client attached. Only acts in - run mode. + and is recorded to conversation history / auto-saved). Brackets the turn + with an 'autonomous_start' (carrying the wake trigger) and a synthesized + 'stream_end' so it streams to the web chat distinctly. Sets + _autonomous_active so the registry backstop refuses irreversible tools. + When interactive (ASK mode) a choice_request round-trips through the + operator; otherwise it is auto-cancelled. Run mode only. """ if self.mode != "run": logger.info("Wake turn skipped — agent not in run mode (mode=%s)", self.mode) return "" + + async def _emit(chunk): + cb = getattr(self, "on_message_callback", None) + if cb is None: + return + try: + res = cb(chunk) + if asyncio.iscoroutine(res): + await res + except Exception: + logger.debug("on_message_callback failed for wake chunk", exc_info=True) + + await _emit({"type": "autonomous_start", "trigger": trigger or ""}) text_parts = [] + self._autonomous_active = True + agen = self.handle_message_stream(wake_note) + sent_value = None try: - async for chunk in self.handle_message_stream(wake_note): - if isinstance(chunk, dict) and chunk.get("type") == "text": + while True: + try: + if sent_value is None: + chunk = await agen.__anext__() + else: + chunk = await agen.asend(sent_value) + sent_value = None + except StopAsyncIteration: + break + ctype = chunk.get("type") if isinstance(chunk, dict) else None + if ctype == "text": text_parts.append(chunk.get("text", "")) - elif isinstance(chunk, dict) and chunk.get("type") == "choice_request": - # A plain async-for resumes the generator with None, which the - # picker resolves as 'cancelled'. Make that visible — there is - # no operator to answer during an autonomous turn. - cd = chunk.get("choice_data", {}) - logger.warning( - "Wake turn invoked an interactive picker (%s); auto-cancelling " - "— no operator present.", cd.get("question", "?")) - cb = getattr(self, "on_message_callback", None) - if cb is not None: - try: - res = cb(chunk) - if asyncio.iscoroutine(res): - await res - except Exception: - logger.debug("on_message_callback failed for wake chunk", exc_info=True) + if ctype == "choice_request": + # Resolve via the operator (ASK) or auto-cancel (AUTO). + sent_value = await self._resolve_wake_choice(chunk, _emit, interactive) + continue # don't re-emit the raw choice_request + await _emit(chunk) except Exception: logger.exception("run_wake_turn error") + finally: + self._autonomous_active = False + try: + # Release the turn-lock even if a picker hung / timed out. + await agen.aclose() + except Exception: + pass + await _emit({"type": "stream_end"}) summary = "".join(text_parts).strip() if summary: logger.info("Autonomous wake turn result: %s", summary[:500]) return summary + async def _resolve_wake_choice(self, chunk, emit, interactive): + """Resolve a choice_request raised during a wake turn. + + AUTO (or no operator channel) -> 'cancelled'. ASK -> register a future via + the web choice-factory, broadcast the picker to clients, and await the + operator's selection (timeout -> 'skip' so an unanswered picker can't hold + the turn-lock forever).""" + choice_data = chunk.get("choice_data", {}) if isinstance(chunk, dict) else {} + factory = getattr(self, "_wake_choice_factory", None) + if not interactive or factory is None: + logger.info("Wake picker auto-cancelled (interactive=%s, channel=%s)", + interactive, factory is not None) + return "cancelled" + try: + future = factory(choice_data) # registers future + sets request_id + except Exception: + logger.exception("wake choice factory failed") + return "cancelled" + request_id = choice_data.get("request_id", "") + await emit({**chunk, "origin": "wake", "request_id": request_id}) + from gently.app.wake_router import ASK_TIMEOUT_SEC + try: + selected = await asyncio.wait_for(future, timeout=ASK_TIMEOUT_SEC) + except asyncio.TimeoutError: + logger.info("Wake ASK timed out (%.0fs) -> skip", ASK_TIMEOUT_SEC) + selected = "skip" + except asyncio.CancelledError: + # The picker future was cancelled (e.g. the operator disconnected) — + # treat as a cancelled proposal so the turn finishes cleanly. + logger.info("Wake ASK future cancelled -> cancelled") + selected = "cancelled" + except Exception: + selected = "cancelled" + finally: + # Don't leak the future in the router-scoped registry on timeout/cancel. + discard = getattr(self, "_wake_choice_discard", None) + if discard is not None and request_id: + try: + discard(request_id) + except Exception: + pass + return selected or "skip" + async def get_tool_call(self, user_message: str) -> Optional[Dict]: """Dry-run tool call (for benchmarking).""" context_summary = await self.prompts.get_cached_context_summary( diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index 62b846f5..316913a8 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -726,33 +726,237 @@ def _perc_line(eid: str): @tool( name="set_autonomy", - description="""Enable or disable autonomous mode (the decision-moment wake-router). -When ON, the agent wakes itself between user messages on important perception events -(developmental stage transitions, potential arrest, hatching, embryo termination, or -errors) and may adjust acquisition on its own (interval, power, stop conditions, -bursts). Default is OFF. Device-layer safety limits still bound any action. -Use when the user says "enable autopilot", "watch and adapt on your own", "go -autonomous", or "turn off autonomy".""", + description="""Set the autonomy mode of the decision-moment wake-router (default OFF). +Modes: + 'off' — never act on its own; only respond to your messages. + 'ask' — on a notable event (stage transition, arrest, hatching, termination, + errors) the agent PROPOSES a change and waits for you to Approve / + Modify / Skip in the chat before acting. + 'auto' — the agent adapts acquisition on its own (still bounded by device + limits; a few irreversible actions always require your confirmation). +You can switch modes mid-run. Use when the user says "enable autopilot/autonomous", +"ask me before changing things", "go fully autonomous", or "turn off autonomy".""", category=ToolCategory.ANALYSIS, examples=[ - ToolExample("Enable autonomous mode", {"enabled": True}), - ToolExample("Turn off autopilot", {"enabled": False}), + ToolExample("Ask me before adapting", {"mode": "ask"}), + ToolExample("Go fully autonomous", {"mode": "auto"}), + ToolExample("Turn off autonomy", {"mode": "off"}), ], ) -def set_autonomy(enabled: bool = True, context: Dict = None) -> str: - """Toggle the decision-moment wake-router.""" +def set_autonomy(mode: str = None, enabled: bool = None, context: Dict = None) -> str: + """Set the wake-router mode (off/ask/auto). `enabled` kept for back-compat.""" agent, err = require_agent(context) if err: return err router = getattr(agent, "wake_router", None) if router is None: return "Autonomy is not available (wake-router failed to initialize)." - state = router.set_enabled(enabled) - if state: - return ("Autonomous mode ENABLED. I'll wake on stage transitions, potential " - "arrest, hatching, termination, and errors — and adapt acquisition as " - "needed. Say 'turn off autonomy' to stop.") - return "Autonomous mode disabled. I'll only act when you message me." + if mode is not None: + m = str(mode).strip().lower() + if m not in ("off", "ask", "auto"): + return "mode must be 'off', 'ask', or 'auto'." + router.set_mode(m) + elif enabled is not None: + router.set_enabled(bool(enabled)) + else: + return "Specify mode ('off', 'ask', or 'auto')." + cur = router.mode + if cur == "auto": + return ("Autonomy set to AUTO. I'll wake on stage transitions, arrest, " + "hatching, termination, and errors and adapt acquisition on my own " + "(irreversible actions still need your okay). Say 'ask mode' or " + "'turn off autonomy' to change.") + if cur == "ask": + return ("Autonomy set to ASK. On a notable event I'll propose a change and " + "wait for your Approve / Modify / Skip before doing anything.") + return "Autonomy OFF. I'll only act when you message me." + + +# --------------------------------------------------------------------------- +# Live cadence / dose modulation — direct knobs for a running timelapse. +# --------------------------------------------------------------------------- + + +@tool( + name="modify_timelapse_interval", + description="""Change the base acquisition interval for ALL embryos on a running timelapse, effective immediately. +Re-anchors every embryo's next acquisition to now + the new interval and notifies the UI. +Lower interval = more frequent imaging = more photodose; raise it to be gentler. +Use when the user says "image every N minutes/seconds now", "speed up/slow down the whole run". +For a single embryo use set_embryo_cadence instead.""", + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample("Image every 2 minutes now", {"new_interval_seconds": 120}), + ToolExample("Slow everything down to 10 minutes", {"new_interval_seconds": 600}), + ], +) +def modify_timelapse_interval(new_interval_seconds: float, context: Dict = None) -> str: + """Globally re-anchor the timelapse interval (live).""" + agent, err = require_agent(context) + if err: + return err + orchestrator, err = require_timelapse_orchestrator(agent) + if err: + return err + return orchestrator.modify_interval(new_interval_seconds) + + +@tool( + name="set_embryo_cadence", + description="""Change ONE embryo's acquisition cadence on a running timelapse, effective immediately. +Set new_interval_seconds to re-anchor that embryo's next acquisition to now + interval (lower = more frequent = more dose). +Set new_phase to 'normal' to resume a paused embryo, or 'paused' to pause it. +NOTE: re-issuing the SAME interval with the SAME phase is a no-op (it won't re-anchor). +Use for per-embryo tuning, e.g. speed up the one that's developing fastest.""", + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample("Image embryo_2 every minute", {"embryo_id": "embryo_2", "new_interval_seconds": 60}), + ToolExample("Resume embryo_3", {"embryo_id": "embryo_3", "new_phase": "normal"}), + ], +) +def set_embryo_cadence( + embryo_id: str, + new_interval_seconds: float = None, + new_phase: str = None, + context: Dict = None, +) -> str: + """Per-embryo cadence change routed through the re-anchoring path.""" + agent, err = require_agent(context) + if err: + return err + orchestrator, err = require_timelapse_orchestrator(agent) + if err: + return err + embryo, err = get_embryo_or_error(agent, embryo_id) + if err: + return err + if new_interval_seconds is None and new_phase is None: + return "Specify new_interval_seconds and/or new_phase." + if new_interval_seconds is not None and new_interval_seconds < 1: + return "Interval must be >= 1 second." + if new_phase is not None and new_phase not in ("normal", "fast", "burst", "paused"): + return "new_phase must be one of: normal, fast, burst, paused." + # Detect the no-op (transition_cadence silently does nothing, and would NOT + # re-anchor next_due_at, if neither interval nor phase actually changes). + cur_interval = getattr(embryo, "interval_seconds", None) + cur_phase = getattr(embryo, "cadence_phase", None) + interval_change = new_interval_seconds is not None and new_interval_seconds != cur_interval + phase_change = new_phase is not None and new_phase != cur_phase + if not interval_change and not phase_change: + shown = f"{cur_interval:.0f}s" if cur_interval is not None else "default" + return f"{embryo.id}: no change (already interval={shown}, phase={cur_phase})." + orchestrator.transition_cadence( + embryo, + new_interval_seconds=new_interval_seconds if interval_change else None, + new_phase=new_phase if phase_change else None, + reason="agent:set_embryo_cadence", + ) + bits = [] + if interval_change: + bits.append(f"interval={new_interval_seconds:.0f}s") + if phase_change: + bits.append(f"phase={new_phase}") + due = getattr(embryo, "next_due_at", None) + tail = f"; next acquisition ~{due.strftime('%H:%M:%S')}" if due else "" + return f"{embryo.id}: {', '.join(bits)}{tail}" + + +@tool( + name="set_photodose_budget", + description="""Set or clear the per-embryo photodose budget (a hard cap on cumulative laser exposure). +base_dose_budget_ms is the ceiling for a 1x-role (test) embryo; calibration embryos get 10x. +When an embryo's cumulative exposure exceeds its budget it is auto-PAUSED to protect the sample. +Pass null/None to DISABLE the cap. Raising the budget also resumes embryos that were paused for the old cap. +Use to enforce gentleness on precious samples, or to lift the cap when the user okays more dose.""", + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample("Cap each embryo at 5 seconds of light", {"base_dose_budget_ms": 5000}), + ToolExample("Remove the photodose cap", {"base_dose_budget_ms": None}), + ], +) +def set_photodose_budget( + base_dose_budget_ms: float = None, + resume_paused: bool = True, + context: Dict = None, +) -> str: + """Set/clear the photodose budget; optionally resume budget-paused embryos.""" + agent, err = require_agent(context) + if err: + return err + orchestrator, err = require_timelapse_orchestrator(agent) + if err: + return err + # Capture who was budget-paused BEFORE set_photodose_budget clears the set, + # so we only resume embryos paused for the budget (not manual pauses/bursts). + prev_exceeded = set(getattr(orchestrator, "_dose_budget_exceeded", set()) or set()) + msg = orchestrator.set_photodose_budget(base_dose_budget_ms) + resumed = [] + if resume_paused: + states = getattr(orchestrator, "_embryo_states", {}) or {} + try: + from gently.harness.roles import REGISTRY as ROLE_REGISTRY + except Exception: + ROLE_REGISTRY = {} + for eid in prev_exceeded: + e = states.get(eid) + if e is None or getattr(e, "cadence_phase", None) != "paused": + continue + # Only resume if the embryo is now UNDER the new budget (or the cap + # was disabled); otherwise it would just immediately re-pause. + if base_dose_budget_ms is not None: + rdef = ROLE_REGISTRY.get(getattr(e, "role", "test")) if hasattr(ROLE_REGISTRY, "get") else None + mult = getattr(rdef, "photodose_budget_multiplier", 1.0) if rdef else 1.0 + if (getattr(e, "total_exposure_ms", 0.0) or 0.0) > base_dose_budget_ms * mult: + continue + orchestrator.transition_cadence(e, new_phase="normal", reason="agent:budget change resume") + resumed.append(eid) + if resumed: + msg += f" Resumed: {', '.join(sorted(resumed))}." + return msg + + +@tool( + name="get_photodose_status", + description="""Report each embryo's cumulative light exposure vs its photodose budget, and which are paused over budget. +Use to reason about gentleness before/after changing the budget, power, or cadence.""", + category=ToolCategory.ANALYSIS, + examples=[ToolExample("How much light has each embryo gotten?", {})], +) +def get_photodose_status(context: Dict = None) -> str: + """Read-only photodose / budget status across embryos.""" + agent, err = require_agent(context) + if err: + return err + orchestrator, err = require_timelapse_orchestrator(agent) + if err: + return err + base = getattr(orchestrator, "_dose_budget_base_ms", None) + exceeded = getattr(orchestrator, "_dose_budget_exceeded", set()) or set() + states = getattr(orchestrator, "_embryo_states", {}) or {} + if base is None: + lines = ["Photodose budget: DISABLED (no cap).", ""] + else: + lines = [f"Photodose budget: {base:.0f} ms base (scaled per role).", ""] + try: + from gently.harness.roles import REGISTRY as ROLE_REGISTRY + except Exception: + ROLE_REGISTRY = {} + for eid in sorted(states): + e = states[eid] + used = getattr(e, "total_exposure_ms", 0.0) or 0.0 + role = getattr(e, "role", "test") + if base is not None: + rdef = ROLE_REGISTRY.get(role) if hasattr(ROLE_REGISTRY, "get") else None + mult = getattr(rdef, "photodose_budget_multiplier", 1.0) if rdef else 1.0 + cap = base * mult + pct = (used / cap * 100.0) if cap else 0.0 + flag = " [PAUSED: over budget]" if eid in exceeded else "" + lines.append(f" {eid} ({role}): {used:.0f}/{cap:.0f} ms ({pct:.0f}%){flag}") + else: + lines.append(f" {eid} ({role}): {used:.0f} ms used") + if len(lines) == 2: + lines.append(" (no embryos)") + return "\n".join(lines) # --------------------------------------------------------------------------- diff --git a/gently/app/wake_router.py b/gently/app/wake_router.py index 344e9d71..71faa7ce 100644 --- a/gently/app/wake_router.py +++ b/gently/app/wake_router.py @@ -32,6 +32,7 @@ # Tunables (seconds). COALESCE_WINDOW = 20.0 # collapse a burst of events into one wake MIN_WAKE_INTERVAL = 120.0 # throttle non-critical wakes +ASK_TIMEOUT_SEC = 300.0 # ASK mode: how long to wait for operator approval -> Skip # Events that always wake immediately (bypass MIN_WAKE_INTERVAL). CRITICAL_EVENTS = frozenset({ @@ -51,7 +52,7 @@ class WakeRouter: def __init__(self, agent, bus): self.agent = agent self.bus = bus - self.enabled = False + self.mode = "off" # 'off' | 'ask' | 'auto' self._loop = None self._pending = [] # list[(EventType, dict)] self._flush_handle = None # TimerHandle for the coalesce window @@ -62,12 +63,24 @@ def __init__(self, agent, bus): self._subscribe() # -- public control ------------------------------------------------- - def set_enabled(self, enabled: bool) -> bool: - self.enabled = bool(enabled) - if not self.enabled: + @property + def enabled(self) -> bool: + return self.mode != "off" + + def set_mode(self, mode: str) -> str: + mode = (mode or "off").strip().lower() + if mode not in ("off", "ask", "auto"): + mode = "off" + self.mode = mode + if mode == "off": self._cancel_flush() self._pending.clear() - logger.info("Wake-router %s", "ENABLED" if self.enabled else "disabled") + logger.info("Wake-router mode -> %s", mode.upper()) + return self.mode + + def set_enabled(self, enabled: bool) -> bool: + """Back-compat boolean toggle: maps to AUTO / OFF.""" + self.set_mode("auto" if enabled else "off") return self.enabled def is_enabled(self) -> bool: @@ -182,16 +195,28 @@ async def _flush(self): self._in_flight = True self._last_wake = now try: - note = self._build_wake_note(events) - logger.info("Wake-router firing autonomous turn (%d event(s))", len(events)) - await self.agent.run_wake_turn(note) + ask = (self.mode == "ask") + note, trigger = self._build_wake_note(events, ask=ask) + logger.info("Wake-router firing %s turn (%d event(s)): %s", + self.mode.upper(), len(events), trigger) + await self.agent.run_wake_turn(note, trigger=trigger, interactive=ask) except Exception: logger.exception("wake turn failed") finally: self._in_flight = False + # Events that arrived while we were busy (including deferred CRITICAL + # ones) are still in _pending — re-fire promptly rather than waiting + # out another coalesce window. _in_flight is now False so this flush + # will proceed instead of deferring (no busy-spin). + if self._pending and self.enabled: + self._schedule_flush( + critical=any(et in CRITICAL_EVENTS for et, _ in self._pending)) # -- wake package --------------------------------------------------- - def _build_wake_note(self, events) -> str: + def _build_wake_note(self, events, ask=False): + """Return (note, trigger_str). The note is the agent-facing wake prompt; + trigger_str is the short human-readable reason shown in the chat banner. + When ask=True the note instructs propose-then-confirm instead of acting.""" from gently.harness.prompts.templates import build_perception_snapshot triggers = [] for et, data in events: @@ -223,13 +248,24 @@ def _build_wake_note(self, events) -> str: except Exception: snap = "" snap = snap or "(no live perception data)" + trigger_str = "; ".join(triggers) - return ( - "[AUTONOMOUS WAKE] Something changed while no one was typing — decide if " - "any acquisition change is warranted.\n\n" - f"What triggered this: {'; '.join(triggers)}\n\n" + head = ( + "[AUTONOMOUS WAKE] Something changed while no one was typing.\n\n" + f"What triggered this: {trigger_str}\n\n" f"{snap}\n\n" - "If a change helps (adjust interval/power, add a stop condition, queue a " - "burst, or stop an embryo), do it now using your tools. If nothing needs " - "doing, say so briefly and take no action." ) + if ask: + tail = ( + "Decide whether any acquisition change is warranted. If so, briefly " + "state your proposed change and WHY, then call ask_user_choice with " + "options Approve / Modify / Skip and act ONLY if the operator approves. " + "If nothing needs doing, say so briefly and take no action (no need to ask)." + ) + else: + tail = ( + "If a change helps (adjust interval/power, add a stop condition, queue a " + "burst, or stop an embryo), do it now using your tools. If nothing needs " + "doing, say so briefly and take no action." + ) + return head + tail, trigger_str diff --git a/gently/harness/bridge.py b/gently/harness/bridge.py index 028ce2d7..812b9e15 100644 --- a/gently/harness/bridge.py +++ b/gently/harness/bridge.py @@ -43,6 +43,48 @@ def __init__(self, agent): self._wizard = None # StartupWizard, set by init_wizard() self._active_remote: Optional[Dict[str, Any]] = None # {"peer": PeerInfo, "campaign_id": str} self._pending_import: Optional[Dict] = None # For /import-embryos picker + # Set by the web layer (register_display_broadcaster) so AGENT-INITIATED + # turns (the wake-router) can stream to all chat clients + the transcript. + self._display_broadcaster: Optional[Callable] = None + + def register_display_broadcaster(self, broadcast_fn, choice_factory=None, + choice_discard=None) -> None: + """Register the web layer's broadcast fn for autonomous (wake) turns. + + The wake-router has no per-connection send_fn, so to make autonomous + turns visible we route their chunks through the same _broadcast the web + route uses for user turns (records to the display transcript AND fans out + to every connected chat client). Also wires the agent's dangling + on_message_callback to this path, and (for ASK mode) the choice-future + factory so an autonomous turn can round-trip an approval picker. + Idempotent — last registration wins; the registered fns are router-scoped + and fan out to whoever is connected. + """ + self._display_broadcaster = broadcast_fn + try: + self.agent.on_message_callback = self.broadcast_autonomous_chunk + if choice_factory is not None: + self.agent._wake_choice_factory = choice_factory + if choice_discard is not None: + self.agent._wake_choice_discard = choice_discard + except Exception: + pass + + async def broadcast_autonomous_chunk(self, chunk) -> None: + """Fan one autonomous-turn chunk to all chat clients + the transcript. + + No-op when no web client has registered a broadcaster (headless run) — + the wake turn still executes and is persisted to the conversation/log. + """ + fn = self._display_broadcaster + if fn is None: + return + try: + res = fn(chunk) + if asyncio.iscoroutine(res): + await res + except Exception: + logger.debug("broadcast_autonomous_chunk failed", exc_info=True) async def handle_choice_response(self, request_id: str, selected: str, send_fn) -> bool: """Handle a choice response that may belong to a bridge-initiated picker. diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index a2f7634f..61d06706 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -87,62 +87,32 @@ # CV Subagent capabilities CV_SUBAGENT = """ -# CV Subagent for Advanced Analysis - -For complex computer vision analysis, you have access to a specialized CV subagent via the `cv_analyze` tool. - -## IMPORTANT: Volume Required First! - -Before using cv_analyze or classify_embryo_stage, you MUST ensure the embryo has a volume acquired -in this session. If the user asks for cell counting, stage classification, or any analysis: - -1. Check if the embryo has been imaged (recent_images exists) -2. If NOT, acquire a volume first with `acquire_volume` -3. Then proceed with analysis - -Example workflow: -User: "Count the cells in embryo_3" -→ First: acquire_volume(embryo_id="embryo_3") # Get fresh data -→ Then: cv_analyze(intent="count cells", embryo_id="embryo_3") - -## When to use cv_analyze - -Use the CV subagent when you need: -- **Accurate stage classification** - It segments nuclei (Cellpose) and uses count + morphology for staging -- **Cell counting** - 3D segmentation gives precise nuclei counts, not visual estimates -- **Division tracking** - Tracks cells across timepoints, identifies division events -- **Morphology measurements** - Elongation ratio, circularity (important for comma/fold stages) -- **Anomaly detection** - Compares to expected developmental patterns - -## When NOT to use cv_analyze - -Don't use it for: -- Quick visual checks (use simple image viewing instead) -- Hatching detection (the hatching detector handles this) -- Basic "what stage is this?" if rough estimate is fine - -## How it works - -The CV subagent is itself an AI agent that: -1. Loads volume data from the data store -2. Segments with Cellpose/StarDist (nuclei count!) -3. Measures morphology (elongation for fold stages) -4. Adds scale bars and annotations -5. Uses Claude Vision with rich quantitative context - -This gives much more accurate results than just sending an image to vision. - -## Example usage - -User: "How many cells does embryo 1 have?" -→ First acquire_volume if needed, then cv_analyze with intent="count cells and nuclei" - -User: "What stage is embryo 2?" -→ If precision matters: acquire_volume then cv_analyze intent="classify developmental stage" -→ If quick check: view the image yourself - -User: "Track cell divisions over the last 5 timepoints" -→ cv_analyze with intent="track cell divisions" and timepoints=[t-4, t-3, t-2, t-1, t] +# Perception & Analysis + +You see and reason about embryo development through three channels: + +1. **Live perception (the perceiver).** During a timelapse a vision-language + perceiver classifies each acquired volume's developmental stage and tracks + each embryo's trajectory. Its current read is injected into your context + under "## Perception (live)" — stage, stability (how long it's held that + stage), time-in-stage, and a possible-arrest flag. Call + `get_recent_perceptions(embryo_id)` for the fuller picture: stage history, + trajectory, the arrest signal, and the perceiver's own reasoning. This is + your primary signal for "how is it developing?" and for deciding whether to + adapt acquisition. + +2. **On-demand vision (`analyze_volume`).** Ask Claude Vision a specific + question about an acquired volume (e.g. "is the reporter saturating?", + "describe the morphology"). Requires a volume in this session — acquire one + first with `acquire_volume` if none exists. + +3. **Stage tools.** `classify_embryo_stage` (a vision spot-check of the latest + image), `get_stage_history`, and `predict_hatching` — the latter two read the + live perceiver when available, so they work without a manual classify call. + +Prefer the live perception snapshot + `get_recent_perceptions` for routine +"what stage / is anything stuck" questions; reach for `analyze_volume` when you +need a specific visual judgement about a particular volume. """ @@ -266,30 +236,78 @@ 1. User: "Run timelapse until all embryos hatch" 2. Agent: - - Enables hatching detector (enable_preset_detector) - - Starts timelapse with stop_condition="hatching" + - Starts the timelapse with stop_condition="hatching" (the stop condition + wires the detection; the perception loop classifies each acquired volume) + - Optionally installs a monitoring mode (enable_monitoring_mode) for + reactive cadence/power - Reports progress on request - Each embryo stops automatically when it hatches -## Available Preset Detectors +## Stage detection -- **hatching**: Detects eggshell breach and embryo emergence -- **comma**: Detects comma stage morphology -- **pretzel**: Detects 3-fold/pretzel stage -- **gastrulation**: Detects cell internalization -- **first_division**: Detects 1-cell to 2-cell transition +Developmental stage comes from the live perception loop (see "Perception & +Analysis"), surfaced in your context and via get_recent_perceptions. Stop +conditions can key on it — e.g. stop_condition="hatching" or "comma". ## Commands During Timelapse - Query status: get_timelapse_status - Stop one embryo: stop_timelapse_embryo -- Change interval: modify_timelapse_embryo +- Change interval (all embryos): modify_timelapse_interval +- Change one embryo's cadence: set_embryo_cadence +- Other per-embryo params: modify_timelapse_embryo / modify_parameters - Pause all: pause_timelapse - Resume: resume_timelapse - Stop all: stop_timelapse """ +AUTONOMY_AND_ADAPTATION = """ +# Adapting Acquisition — Gently + +Gentleness is the prime directive: every imaging action spends photodose on a +precious, living sample. Always prefer the *least* light that answers the +question. When you do adapt, you have direct, live knobs — each takes effect on +the embryo's next acquisition, no restart: + +- **Cadence**: `modify_timelapse_interval` (whole run) / `set_embryo_cadence` + (one embryo). Speed up only around events worth catching (e.g. approaching + hatching); slow back down when nothing is changing. +- **Dose levers**: `modify_parameters` — num_slices, exposure_ms, acquisition + mode (volume ↔ snap, snap is far gentler), and per-embryo 488 power (hard + clamped 2–6%). `set_photodose_budget` caps cumulative exposure and pauses an + embryo that exceeds it; `get_photodose_status` shows where each stands. +- **Events**: `add_stop_condition` (auto-stop on hatching/stage/duration), + `queue_burst` (one-shot high-rate capture of a transient), and per-embryo + pause / resume / stop. +- **Reactive modes**: `enable_monitoring_mode` installs perception-driven rules + that fire on their own (pre-hatching speedup, 488 rampdown on saturation, + burst on stable structure). + +Bias toward the gentlest sufficient action — snap over volume, fewer slices, +lower power, longer interval — unless an event genuinely needs the resolution. + +# Autonomy (OFF / ASK / AUTO) + +You may act between user messages, but only as far as the operator allows. The +mode is set with `set_autonomy` and is **OFF by default**: + +- **off** — act only when the user messages you. +- **ask** — on a notable event (a developmental stage transition, possible + arrest, hatching, an embryo terminating, or an error) you wake, briefly state + your PROPOSED change and why, then call `ask_user_choice` with + Approve / Modify / Skip and act ONLY on Approve. +- **auto** — you adapt on your own on those events. Still: prefer the gentlest + action, and a few irreversible tools (turning the laser on via + `set_laser_power`, `remove_embryo`, `stop_timelapse`) are hard-blocked from + autonomous use — ask the operator for those. + +When you wake autonomously, your turn and the trigger that woke you are shown to +the operator in the chat. Keep autonomous turns tight: assess, make the smallest +helpful change (or none), and explain it in a sentence or two. +""" + + def build_perception_snapshot(perceiver, embryos) -> str: """One compact line per embryo of live perception state for the system prompt. @@ -451,6 +469,8 @@ def build_system_prompt( {REACTIVE_MONITORING_MODES} +{AUTONOMY_AND_ADAPTATION} + {USER_INTERACTION_GUIDELINES} {SESSION_MANAGEMENT} diff --git a/gently/harness/tools/registry.py b/gently/harness/tools/registry.py index 106bd7a2..48e1cf4f 100644 --- a/gently/harness/tools/registry.py +++ b/gently/harness/tools/registry.py @@ -425,6 +425,20 @@ async def execute(self, tool_name: str, tool_input: Dict, context: Dict = None) else: exec_context = self._context + # Hybrid-autonomy backstop: during an autonomous (wake) turn, a small set + # of irreversible tools (laser-on, embryo termination, stopping the run) + # must NEVER execute without a human — even if the model tries to call + # them directly. The agent sets these flags around its autonomous turns; + # user-driven turns are unaffected. The blocked set is supplied by the + # agent so this layer stays free of app-specific tool names. + _agent = exec_context.get('agent') if isinstance(exec_context, dict) else None + if _agent is not None and getattr(_agent, '_autonomous_active', False): + blocked = getattr(_agent, '_autonomous_blocked_tools', None) or () + if tool_name in blocked: + logger.info("Autonomy backstop blocked '%s' (irreversible)", tool_name) + return (f"'{tool_name}' is an irreversible action and cannot run " + f"autonomously. Ask the operator to confirm it.") + # Check microscope requirement if tool.requires_microscope: client = exec_context.get('client') diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index 0d27a5d0..36ca6ee5 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -48,7 +48,7 @@ def create_router(server) -> APIRouter: # Persisted to /chat_display.json so it survives reconnects and # restarts; broadcast live so all instances stay in sync. _history: list = [] - _history_state = {"sid": None, "path": None, "agent_buf": None} + _history_state = {"sid": None, "path": None, "agent_buf": None, "autonomous": False} async def _broadcast_control_status(): """Tell every connected agent client who currently holds control.""" @@ -81,6 +81,7 @@ def _load_history_for_session(bridge): _history_state["sid"] = sid _history_state["path"] = None _history_state["agent_buf"] = None + _history_state["autonomous"] = False try: if store and sid: sdir = store._session_dir(sid) @@ -140,7 +141,10 @@ def _record(item): def _flush_agent_buf(): buf = _history_state["agent_buf"] if buf: - _record({"role": "agent", "text": buf}) + # An autonomous (wake) turn's text is recorded distinctly so replay + # shows it as "Gently · autonomous", not an ordinary agent reply. + role = "autonomous" if _history_state.get("autonomous") else "agent" + _record({"role": role, "text": buf}) _history_state["agent_buf"] = None def _record_display(msg): @@ -148,8 +152,15 @@ def _record_display(msg): t = msg.get("type") if t == "user_message": _flush_agent_buf() + _history_state["autonomous"] = False _record({"role": "user", "text": msg.get("text", ""), "author": msg.get("author")}) + elif t == "autonomous_start": + # An autonomous wake turn is beginning — record the trigger banner + # and mark following text as autonomous until stream_end. + _flush_agent_buf() + _history_state["autonomous"] = True + _record({"role": "autonomous_start", "trigger": msg.get("trigger", "")}) elif t == "text": _history_state["agent_buf"] = (_history_state["agent_buf"] or "") + msg.get("text", "") elif t == "tool_call": @@ -159,6 +170,7 @@ def _record_display(msg): "summary": msg.get("result_summary")}) elif t == "stream_end": _flush_agent_buf() + _history_state["autonomous"] = False async def _broadcast(msg): """Record to history + send a display message to ALL clients.""" @@ -274,6 +286,11 @@ async def agent_websocket(websocket: WebSocket): await websocket.close() return + # Route autonomous (wake-router) turns through this router's _broadcast so + # they stream to all chat clients + persist to the display transcript. + # Idempotent; _broadcast is router-scoped and fans out to whoever is live. + bridge.register_display_broadcaster(_broadcast) + # ── Authenticate the connection (account mode) ──────────── # When user accounts are configured, identity comes from the signed # session cookie (set at login). Viewers may watch but not drive; @@ -572,6 +589,14 @@ def choice_future_factory(choice_data: dict) -> asyncio.Future: _choice_futures[request_id] = future return future + def _discard_choice(request_id: str) -> None: + _choice_futures.pop(request_id, None) + + # Give the bridge the choice-factory + discard too, so ASK-mode autonomous + # turns can round-trip an approval picker through this connection's channel + # and clean up the future on timeout/cancel. + bridge.register_display_broadcaster(_broadcast, choice_future_factory, _discard_choice) + # Register this client for control arbitration; grant control if free # (only to clients allowed to drive — viewers never auto-hold). _clients[client_id] = send_fn diff --git a/gently/ui/web/static/css/agent-chat.css b/gently/ui/web/static/css/agent-chat.css index 777582b2..862a92e8 100644 --- a/gently/ui/web/static/css/agent-chat.css +++ b/gently/ui/web/static/css/agent-chat.css @@ -140,6 +140,28 @@ white-space: pre-wrap; word-wrap: break-word; } +/* ── Autonomous (wake) turns ────────────────────────────── */ +.ac-autonomous-banner { + display: flex; align-items: center; gap: 8px; + align-self: stretch; + margin: 2px 0; + padding: 6px 10px; + font-size: 11.5px; font-weight: 500; + color: var(--accent-purple); + background: rgba(167, 139, 250, 0.10); + border: 1px solid rgba(167, 139, 250, 0.28); + border-radius: 8px; +} +.ac-autonomous-dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--accent-purple); + box-shadow: 0 0 0 3px rgba(167, 139, 250, 0.20); + flex: 0 0 auto; +} +/* Autonomous agent bubbles get an accent rail + a distinct role label. */ +.ac-turn-autonomous { border-left: 2px solid rgba(167, 139, 250, 0.45); padding-left: 8px; } +.ac-turn-autonomous .ac-role { color: var(--accent-purple); } + /* ── Activity indicator ─────────────────────────────────── */ .ac-activity { display: flex; align-items: center; gap: 9px; @@ -220,6 +242,11 @@ .ac-choice-label { font-weight: 600; font-size: 13px; } .ac-choice-desc { font-size: 12px; color: var(--text-muted); } .ac-choice-picked { border-color: var(--accent-green); background: rgba(74, 222, 128, 0.08); } +.ac-choice-wake { border-color: rgba(167, 139, 250, 0.45); } +.ac-choice-origin { + font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; + color: var(--accent-purple); margin-bottom: 2px; +} /* ── Applied-spec card ──────────────────────────────────── */ .ac-spec { @@ -293,4 +320,44 @@ } .agent-chat-send:hover:not(:disabled) { background: var(--accent-hover); } .agent-chat-send:disabled { opacity: 0.5; cursor: default; } -.agent-chat-send.ac-busy { background: var(--color-danger, #f87171); } +/* Send now queues while busy (it no longer doubles as Stop), so just dim it. */ +.agent-chat-send.ac-busy { opacity: 0.6; } + +/* Explicit Stop (separate from Send), shown only during a cancellable turn. */ +.ac-stop { + flex: 0 0 auto; align-self: flex-end; + padding: 9px 12px; border-radius: 9px; + border: 1px solid var(--color-danger, #f87171); + background: transparent; color: var(--color-danger, #f87171); + font-weight: 600; font-size: 13px; cursor: pointer; +} +.ac-stop:hover { background: rgba(248, 113, 113, 0.12); } +.ac-stop.hidden { display: none; } + +/* ── Queued-message panel (type-while-busy) ─────────────── */ +.ac-queue { + margin: 0 12px 6px; + border: 1px solid var(--border); border-radius: 9px; + background: rgba(127, 127, 127, 0.06); + padding: 6px; font-size: 12px; +} +.ac-queue.hidden { display: none; } +.ac-queue-head { + display: flex; align-items: center; justify-content: space-between; + padding: 2px 4px 6px; color: var(--text-muted); +} +.ac-queue-clear { + background: none; border: none; color: var(--accent); + cursor: pointer; font-size: 11.5px; font-family: inherit; +} +.ac-queue-clear:hover { text-decoration: underline; } +.ac-queue-item { display: flex; align-items: center; gap: 8px; padding: 4px; } +.ac-queue-text { + flex: 1 1 auto; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ac-queue-remove { + flex: 0 0 auto; background: none; border: none; + color: var(--text-muted); cursor: pointer; font-size: 12px; line-height: 1; +} +.ac-queue-remove:hover { color: var(--color-danger, #f87171); } diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 3b10d8be..545b05ac 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -28,6 +28,12 @@ const AgentChat = (() => { let tools = []; // [{name, description, params, ...}] let acItems = []; // current completion items shown in the dropdown let acIdx = -1; // highlighted item index + let autonomousTurn = false; // true while rendering an autonomous (wake) turn + let agentBusy = false; // a turn (user or autonomous) is currently running + let busySource = null; // 'user' | 'wake' while busy + let msgQueue = []; // messages typed while busy, sent on idle + let queuePanel = null; // the "⏳ Queued (N)" panel element + let stopBtn = null; // explicit Stop button (separate from Send) // DOM refs (resolved in init) let fab, panel, log, input, sendBtn, conn, banner, closeBtn, userEl, signoutBtn; @@ -75,10 +81,11 @@ const AgentChat = (() => { function addTurn(role) { const wrap = document.createElement('div'); wrap.className = `ac-turn ac-turn-${role}`; + if (role === 'agent' && autonomousTurn) wrap.classList.add('ac-turn-autonomous'); if (role === 'agent') { const label = document.createElement('div'); label.className = 'ac-role'; - label.textContent = 'Gently'; + label.textContent = autonomousTurn ? 'Gently · autonomous' : 'Gently'; wrap.appendChild(label); } const content = document.createElement('div'); @@ -118,6 +125,14 @@ const AgentChat = (() => { const c = addTurn('agent'); c._raw = it.text || ''; c.innerHTML = mdToHtml(c._raw); + } else if (it.role === 'autonomous_start') { + addAutonomousBanner(it.trigger || ''); + } else if (it.role === 'autonomous') { + autonomousTurn = true; + const c = addTurn('agent'); + c._raw = it.text || ''; + c.innerHTML = mdToHtml(c._raw); + autonomousTurn = false; } else if (it.role === 'tool') { const el = document.createElement('div'); el.className = 'ac-tool ac-tool-done'; @@ -132,6 +147,16 @@ const AgentChat = (() => { scrollToBottom(); } + /** A divider announcing the agent woke itself, with the trigger reason. */ + function addAutonomousBanner(trigger) { + const el = document.createElement('div'); + el.className = 'ac-autonomous-banner'; + const t = trigger ? `Gently woke up — ${trigger}` : 'Gently woke up'; + el.innerHTML = `${escapeHtml(t)}`; + log.appendChild(el); + scrollToBottom(); + } + function addSystemLine(text, level = 'info') { const el = document.createElement('div'); el.className = `ac-system ac-level-${level}`; @@ -171,10 +196,20 @@ const AgentChat = (() => { case 'stream_start': streaming = true; currentAgentEl = null; // created lazily on first text - setBusy(true); + setBusy(true, 'user'); setActivity('Working…'); break; + case 'autonomous_start': + // The agent woke itself — render a distinct banner + label the + // following text as autonomous (no stream_start precedes this). + hideActivity(); + autonomousTurn = true; + currentAgentEl = null; + setBusy(true, 'wake'); + addAutonomousBanner(msg.trigger || ''); + break; + case 'thinking': if (streaming) setActivity('Thinking…'); break; @@ -256,6 +291,7 @@ const AgentChat = (() => { case 'stream_end': streaming = false; currentAgentEl = null; + autonomousTurn = false; hideActivity(); setBusy(false); break; @@ -289,7 +325,13 @@ const AgentChat = (() => { const data = msg.choice_data || {}; const reqId = msg.request_id || data.request_id || ''; const wrap = document.createElement('div'); - wrap.className = 'ac-choice'; + wrap.className = 'ac-choice' + (msg.origin === 'wake' ? ' ac-choice-wake' : ''); + if (msg.origin === 'wake') { + const tag = document.createElement('div'); + tag.className = 'ac-choice-origin'; + tag.textContent = 'Autonomy proposal — your approval needed'; + wrap.appendChild(tag); + } const q = document.createElement('div'); q.className = 'ac-choice-q'; q.innerHTML = mdToHtml(data.question || 'Choose:'); @@ -482,9 +524,67 @@ const AgentChat = (() => { } } - function setBusy(busy) { - sendBtn.textContent = busy ? 'Stop' : 'Send'; - sendBtn.classList.toggle('ac-busy', busy); + function setBusy(busy, source) { + agentBusy = !!busy; + busySource = agentBusy ? (source || 'user') : null; + // Send no longer doubles as Stop — it queues while busy. A separate Stop + // (shown only for a cancellable user turn) aborts the current turn. + if (stopBtn) stopBtn.classList.toggle('hidden', !(agentBusy && busySource === 'user')); + sendBtn.classList.toggle('ac-busy', agentBusy); + if (agentBusy) { + input.placeholder = (busySource === 'wake') + ? 'Gently is acting autonomously — your message will queue' + : 'Gently is working — your message will queue'; + } else { + if (hasControl) input.placeholder = 'Message Gently… ( / commands · @ tools )'; + drainQueue(); // a turn just ended — send the next queued message + } + } + + // ── Message queue (type-while-busy) ─────────────────────── + function enqueue(text) { msgQueue.push(text); renderQueue(); } + function removeQueued(i) { + if (i >= 0 && i < msgQueue.length) { msgQueue.splice(i, 1); renderQueue(); } + } + function clearQueue() { msgQueue = []; renderQueue(); } + function drainQueue() { + if (agentBusy || !msgQueue.length) return; + if (!ws || ws.readyState !== WebSocket.OPEN) return; // keep queued until reconnect + const next = msgQueue.shift(); + renderQueue(); + actuallySend(next); + } + function renderQueue() { + if (!queuePanel) return; + if (!msgQueue.length) { queuePanel.classList.add('hidden'); queuePanel.innerHTML = ''; return; } + queuePanel.classList.remove('hidden'); + queuePanel.innerHTML = ''; + const head = document.createElement('div'); + head.className = 'ac-queue-head'; + const lbl = document.createElement('span'); + lbl.textContent = `⏳ Queued (${msgQueue.length})`; + const clear = document.createElement('button'); + clear.className = 'ac-queue-clear'; + clear.textContent = 'Clear all'; + clear.addEventListener('click', clearQueue); + head.appendChild(lbl); + head.appendChild(clear); + queuePanel.appendChild(head); + msgQueue.forEach((m, i) => { + const row = document.createElement('div'); + row.className = 'ac-queue-item'; + const span = document.createElement('span'); + span.className = 'ac-queue-text'; + span.textContent = m; + const x = document.createElement('button'); + x.className = 'ac-queue-remove'; + x.textContent = '✕'; + x.title = 'Remove from queue'; + x.addEventListener('click', () => removeQueued(i)); + row.appendChild(span); + row.appendChild(x); + queuePanel.appendChild(row); + }); } function setConn(ok, label) { @@ -520,23 +620,32 @@ const AgentChat = (() => { } // ── Input handling ──────────────────────────────────────── - function submit() { - if (streaming) { send({ type: 'cancel' }); return; } // Send doubles as Stop - hideCompletions(); - const text = input.value.trim(); - if (!text) return; - if (!hasControl) { renderControl(); return; } + function actuallySend(text) { if (text.startsWith('/')) { addUserMessage(text); // commands aren't broadcast; echo locally send({ type: 'command', command: text }); // slash commands (e.g. /status) - } else { - send({ type: 'chat', text }); // echoed to all via 'user_message' + // Most commands reply with a single 'command_result' and no stream — + // do NOT mark the composer busy, or the queue would stick forever. + // Commands that DO stream (e.g. /wizard) set busy via stream_start. + return; } + send({ type: 'chat', text }); // echoed to all via 'user_message' // Instant feedback before the first chunk arrives. - setBusy(true); + setBusy(true, 'user'); setActivity('Working…'); + } + + function submit() { + hideCompletions(); + const text = input.value.trim(); + if (!text) return; + if (!hasControl) { renderControl(); return; } input.value = ''; autosize(); + // While the agent is busy (a user OR autonomous turn), queue instead of + // cancelling — Send no longer doubles as Stop. + if (agentBusy) { enqueue(text); return; } + actuallySend(text); } function autosize() { @@ -613,6 +722,19 @@ const AgentChat = (() => { acComplete = document.createElement('div'); acComplete.className = 'ac-complete hidden'; inputWrap.insertBefore(acComplete, inputWrap.firstChild); + + // Queued-message panel (above the composer) for type-while-busy. + queuePanel = document.createElement('div'); + queuePanel.className = 'ac-queue hidden'; + if (inputWrap.parentNode) inputWrap.parentNode.insertBefore(queuePanel, inputWrap); + + // Explicit Stop button — shown only during a cancellable user turn. + stopBtn = document.createElement('button'); + stopBtn.className = 'ac-stop hidden'; + stopBtn.textContent = 'Stop'; + stopBtn.title = 'Stop the current turn'; + stopBtn.addEventListener('click', () => { send({ type: 'cancel' }); setBusy(false); }); + inputWrap.appendChild(stopBtn); } sendBtn.addEventListener('click', submit); @@ -629,7 +751,11 @@ const AgentChat = (() => { if (e.key === 'Escape') { e.preventDefault(); hideCompletions(); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } - if (e.key === 'Escape' && streaming) { e.preventDefault(); send({ type: 'cancel' }); } + // Escape mirrors Stop: cancel a cancellable (user) turn and clear busy + // (a cancelled turn emits no stream_end, so clear optimistically). + if (e.key === 'Escape' && agentBusy && busySource === 'user') { + e.preventDefault(); send({ type: 'cancel' }); setBusy(false); + } }); } From f8f977159a83fb42491573439dca06972a622bdc Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 20:17:47 -0400 Subject: [PATCH 056/314] Redesign agent chat as a docked, sliding side panel (overlay + pin-to-dock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the floating popup bubble with a professional VSCode-style side panel, per a UI/UX + biologist-cognition consult that converged on overlay-by-default with an opt-in pin-to-dock. - App shell: header/navbar stay full-width; a new .app-shell flex row holds .app-main (content) + the chat +
From 2a36834b241d880e39ed56c8b69c8613880541e7 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 20:31:22 -0400 Subject: [PATCH 057/314] Add a Home landing tab; stop the chat auto-running the startup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the auto-popping startup wizard with a proper Home landing page (the new default tab). - Home tab: a scrollable landing with a 'Start / continue an experiment' button + a thin status line, and three at-a-glance cards — recent sessions (with Resume), recent plans (with progress chips), and a recent-images strip — all fed by existing endpoints (/api/sessions, /api/campaigns, /api/snapshots, /api/images/{uid}/png). New HomeApp module (mirrors ReviewApp/CampaignsApp); self-inits on load since it's the default tab. - Wizard: no longer auto-pops in the chat on connect — gated behind server.wizard_autorun (default off). 'Start / continue an experiment' opens the agent panel and runs /wizard on demand; the briefing/resolution path is unchanged (wizard_ran still derives from wizard.needed). - Wiring: TABS.HOME; Home is the default-active tab (navbar + panel + state.tab); switchTab lazy-inits HomeApp; #home in the hash-route whitelist; AgentChat now exposes runCommand() so Home can trigger /wizard. - Reuses .panel/.empty-state + theme vars; namespaced .home-* styles, responsive grid, scroll on an inner wrapper (the panel is overflow:hidden). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/agent_ws.py | 9 +- gently/ui/web/static/css/main.css | 108 ++++++++++++++++++ gently/ui/web/static/js/agent-chat.js | 10 +- gently/ui/web/static/js/app.js | 7 +- gently/ui/web/static/js/home.js | 156 ++++++++++++++++++++++++++ gently/ui/web/static/js/utils.js | 2 +- gently/ui/web/templates/_navbar.html | 4 +- gently/ui/web/templates/index.html | 45 +++++++- 8 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 gently/ui/web/static/js/home.js diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index 36ca6ee5..8f3a3262 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -617,9 +617,14 @@ def _discard_choice(request_id: str) -> None: try: # ── Wizard phase ────────────────────────────────────── - # Run startup wizard (if needed) before entering the REPL. + # The startup wizard no longer auto-pops in the chat — setup is now + # launched on demand from the Home page (which sends /wizard) or via + # the /wizard command. Re-enable auto-run by setting + # server.wizard_autorun = True. NOTE: wizard_ran below is still + # derived from wizard.needed, so the briefing/resolution path is + # unaffected by this gate. wizard = getattr(bridge, "_wizard", None) - if wizard is not None and wizard.needed: + if wizard is not None and wizard.needed and getattr(server, "wizard_autorun", False): wizard_task = await _run_wizard( wizard, websocket, send_fn, _choice_futures, bridge, log_transcript=_log_transcript, diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index bf36b569..fdafcc1d 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -652,6 +652,114 @@ a.tab-link.active { flex-direction: column; } +/* ── Home (landing) tab ───────────────────────────────────── + #home-content is a flex column with overflow:hidden, so the scroll lives on + .home-scroll. */ +.home-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} +.home-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 22px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--bg-card); +} +.home-hero-title { font-size: 1.35rem; font-weight: 700; color: var(--text); margin: 0; } +.home-hero-status { font-size: 12.5px; color: var(--text-muted); margin-top: 4px; } +.home-start-btn { + flex: 0 0 auto; + padding: 10px 18px; + border: none; border-radius: 10px; + background: var(--gradient-primary, var(--accent)); + color: #fff; font-weight: 600; font-size: 13.5px; cursor: pointer; + box-shadow: var(--shadow-glow); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.home-start-btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-glow-strong); } + +.home-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} +.home-card-wide { grid-column: 1 / -1; } +@media (max-width: 820px) { + .home-grid { grid-template-columns: 1fr; } + .home-card-wide { grid-column: auto; } +} + +.home-card { + display: flex; flex-direction: column; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-card); + min-height: 120px; +} +.home-card-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; +} +.home-card-title { + font-size: 11px; font-weight: 600; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--text-muted); +} +.home-card-link { font-size: 11.5px; color: var(--accent); text-decoration: none; } +.home-card-link:hover { text-decoration: underline; } +.home-card-body { display: flex; flex-direction: column; gap: 6px; } + +.home-item { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 8px 10px; border-radius: 8px; + background: rgba(127, 127, 127, 0.05); + border: 1px solid transparent; +} +.home-item-clickable { cursor: pointer; } +.home-item-clickable:hover { border-color: var(--accent); background: var(--bg-hover); } +.home-item-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.home-item-row { display: flex; align-items: center; gap: 7px; } +.home-item-name { + font-size: 13px; color: var(--text); font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.home-item-meta { font-size: 11.5px; color: var(--text-muted); } +.home-tag { + font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; + padding: 1px 6px; border-radius: 999px; +} +.home-tag-live { color: var(--accent-green); border: 1px solid rgba(74, 222, 128, 0.4); } +.home-resume { + flex: 0 0 auto; + padding: 4px 11px; border-radius: 7px; + border: 1px solid var(--accent); background: transparent; color: var(--accent); + font-size: 12px; font-weight: 600; cursor: pointer; +} +.home-resume:hover { background: var(--accent); color: #fff; } +.home-resume:disabled { opacity: 0.6; cursor: default; } +.home-chip { + flex: 0 0 auto; + font-size: 11px; font-weight: 600; + padding: 2px 8px; border-radius: 999px; + background: var(--bg-hover); color: var(--text-muted); +} + +.home-image-strip { display: flex; gap: 8px; flex-wrap: wrap; } +.home-image { + width: 84px; height: 84px; border-radius: 8px; overflow: hidden; + border: 1px solid var(--border); background: var(--img-bg); flex: 0 0 auto; +} +.home-image img { width: 100%; height: 100%; object-fit: cover; display: block; } + /* Live View - Clean full-width layout */ .live-view { display: flex; diff --git a/gently/ui/web/static/js/agent-chat.js b/gently/ui/web/static/js/agent-chat.js index 6b456955..170353c3 100644 --- a/gently/ui/web/static/js/agent-chat.js +++ b/gently/ui/web/static/js/agent-chat.js @@ -952,5 +952,13 @@ const AgentChat = (() => { document.addEventListener('DOMContentLoaded', init); - return { togglePanel }; + // Public: programmatically send a message/command (e.g. the Home page's + // "Start / continue an experiment" button sends '/wizard'). + function runCommand(text) { + if (!text) return; + if (!hasControl) { renderControl(); return; } + actuallySend(text); + } + + return { togglePanel, runCommand }; })(); diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js index 203cea04..d1e75a72 100644 --- a/gently/ui/web/static/js/app.js +++ b/gently/ui/web/static/js/app.js @@ -6,7 +6,7 @@ const state = { ws: null, connected: false, - tab: TABS.EMBRYOS, // Default to Embryos tab + tab: TABS.HOME, // Default to the Home landing tab snapshots: [], calibration: [], embryos: [], @@ -71,6 +71,9 @@ function switchTab(tabName) { const content = document.getElementById(`${tabName}-content`); if (content) content.classList.add('active'); + // Lazy-init Home landing tab + if (tabName === TABS.HOME && typeof HomeApp !== 'undefined') HomeApp.init(); + // Render galleries if (tabName === TABS.CALIBRATION) renderCalibrationGallery(); if (tabName === TABS.EVENTS) renderEventsTable(); @@ -631,7 +634,7 @@ document.addEventListener('DOMContentLoaded', () => { const hash = window.location.hash.slice(1); // remove # if (hash) { const [tab, param] = hash.split(':'); - if (tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT) { + if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT) { switchTab(tab); if (tab === TABS.PLANS && param && typeof openCampaign === 'function') { setTimeout(() => openCampaign(param), 200); diff --git a/gently/ui/web/static/js/home.js b/gently/ui/web/static/js/home.js new file mode 100644 index 00000000..54adadd9 --- /dev/null +++ b/gently/ui/web/static/js/home.js @@ -0,0 +1,156 @@ +/** + * HomeApp — the landing tab. + * + * A light at-a-glance landing surface: recent sessions, recent plans, recent + * images, a thin status line, and a "Start / continue an experiment" button + * that launches the setup flow (the wizard, which no longer auto-pops in chat). + * + * Read-only fetches against existing endpoints (/api/sessions, /api/campaigns, + * /api/snapshots); mirrors the ReviewApp/CampaignsApp module pattern. + */ +const HomeApp = (() => { + let _inited = false; + const SESSIONS_N = 5; + const CAMPAIGNS_N = 5; + const IMAGES_N = 8; + + function relTime(iso) { + if (!iso) return ''; + const t = Date.parse(iso); + if (isNaN(t)) return ''; + const s = Math.max(0, (Date.now() - t) / 1000); + if (s < 60) return 'just now'; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + const d = Math.floor(s / 86400); + return d < 30 ? `${d}d ago` : new Date(t).toLocaleDateString(); + } + + function empty(el, msg) { + el.innerHTML = `
${escapeHtml(msg)}
`; + } + + function wireGoTab(scope) { + (scope || document).querySelectorAll('[data-go-tab]').forEach(el => { + if (el._goWired) return; + el._goWired = true; + el.addEventListener('click', (e) => { + e.preventDefault(); + if (typeof switchTab === 'function') switchTab(el.dataset.goTab); + }); + }); + } + + async function loadSessions() { + const el = document.getElementById('home-recent-sessions'); + if (!el) return; + try { + const data = await (await fetch('/api/sessions')).json(); + const sessions = (data.sessions || []).slice(0, SESSIONS_N); + if (!sessions.length) { empty(el, 'No sessions yet.'); return; } + el.innerHTML = sessions.map(s => { + const live = s.active ? 'live' : ''; + const resume = s.active ? '' : + ``; + return `
+
+
${escapeHtml(s.name || s.session_id)}${live}
+ ${escapeHtml(relTime(s.last_active))} · ${s.embryo_count || 0} embryos +
${resume} +
`; + }).join(''); + el.querySelectorAll('[data-resume]').forEach(b => b.addEventListener('click', async () => { + b.disabled = true; + b.textContent = 'Resuming…'; + try { + await fetch(`/api/sessions/${encodeURIComponent(b.dataset.resume)}/resume`, { method: 'POST' }); + } catch (_) { b.disabled = false; b.textContent = 'Resume'; } + })); + } catch (e) { empty(el, 'Could not load sessions.'); } + } + + async function loadCampaigns() { + const el = document.getElementById('home-recent-campaigns'); + if (!el) return; + try { + const data = await (await fetch('/api/campaigns')).json(); + const items = (data.campaigns || []).slice(0, CAMPAIGNS_N); + if (!items.length) { empty(el, 'No plans yet.'); return; } + el.innerHTML = items.map(t => { + const c = t.campaign || {}; + const st = t.status || {}; + const name = c.shorthand || c.description || 'Untitled plan'; + const total = st.total || 0; + const chip = total ? `${st.completed || 0}/${total} done` : ''; + return `
+ ${escapeHtml(name)}${chip} +
`; + }).join(''); + wireGoTab(el); + } catch (e) { empty(el, 'Could not load plans.'); } + } + + async function loadImages() { + const el = document.getElementById('home-recent-images'); + if (!el) return; + try { + const data = await (await fetch('/api/snapshots')).json(); + // /api/snapshots is timestamp-ASCENDING; take the tail for "recent". + const recent = (data.snapshots || []).slice(-IMAGES_N).reverse(); + if (!recent.length) { + empty(el, 'No images yet — they appear once a session is active.'); + return; + } + el.innerHTML = '
' + recent.map(s => { + const m = s.metadata || {}; + const label = m.embryo_id + ? `${m.embryo_id}${m.timepoint != null ? ' · t' + m.timepoint : ''}` : ''; + return `
+ ${escapeHtml(label)} +
`; + }).join('') + '
'; + } catch (e) { empty(el, 'Could not load images.'); } + } + + function updateStatus() { + const el = document.getElementById('home-status'); + if (!el) return; + const connected = (typeof state !== 'undefined' && state.connected); + const n = (typeof state !== 'undefined' && Array.isArray(state.embryos)) ? state.embryos.length : 0; + el.textContent = connected + ? `Connected · ${n} embryo${n === 1 ? '' : 's'} in view` + : 'Offline — start the agent to connect.'; + } + + function refresh() { + updateStatus(); + loadSessions(); + loadCampaigns(); + loadImages(); + } + + function init() { + if (!_inited) { + _inited = true; + wireGoTab(document.getElementById('home-content')); + const start = document.getElementById('home-start-btn'); + if (start) start.addEventListener('click', () => { + if (typeof AgentChat !== 'undefined' && AgentChat.togglePanel) { + AgentChat.togglePanel(true); + // Let the panel's WS connect before sending the command. + if (AgentChat.runCommand) setTimeout(() => AgentChat.runCommand('/wizard'), 250); + } + }); + } + refresh(); // re-fetch on every entry to the tab + } + + // Self-initialise on load when Home is the default-active tab (switchTab's + // lazy-init hook only fires on a tab click / hash route, not initial paint). + document.addEventListener('DOMContentLoaded', () => { + const home = document.getElementById('home-content'); + if (home && home.classList.contains('active')) init(); + }); + + return { init, refresh }; +})(); diff --git a/gently/ui/web/static/js/utils.js b/gently/ui/web/static/js/utils.js index b0e6d2ac..4b8ff62b 100644 --- a/gently/ui/web/static/js/utils.js +++ b/gently/ui/web/static/js/utils.js @@ -3,7 +3,7 @@ // ══════════════════════════════════════════════════════════ // Tab and view name constants -const TABS = { EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment' }; +const TABS = { HOME: 'home', EMBRYOS: 'embryos', CALIBRATION: 'calibration', EVENTS: 'events', PLANS: 'plans', SESSIONS: 'sessions', DEVICES: 'devices', EXPERIMENT: 'experiment' }; /** * HTML-escape a string (safe for insertion into innerHTML). diff --git a/gently/ui/web/templates/_navbar.html b/gently/ui/web/templates/_navbar.html index 821c8710..33d5f675 100644 --- a/gently/ui/web/templates/_navbar.html +++ b/gently/ui/web/templates/_navbar.html @@ -2,7 +2,8 @@
{% if is_live %} {# SPA tabs — JS-driven via switchTab() in app.js #} -
+
Home
+
Embryos 0
@@ -24,6 +25,7 @@
Sessions
{% else %} {# Standalone pages — all tabs link back to the SPA #} +
Home Embryos Calibration System diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 31013b1f..e8164eca 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -25,6 +25,48 @@
+ +
+
+
+
+

Welcome to Gently

+
Connecting…
+
+ +
+
+
+
+ Recent sessions + All +
+
+
Loading…
+
+
+
+
+ Recent plans + All +
+
+
Loading…
+
+
+
+
+ Recent images + All +
+
+
No images yet — they appear once a session is active.
+
+
+
+
+
+

Calibration

@@ -127,7 +169,7 @@

Calibration

-
+
Monitoring
@@ -576,6 +618,7 @@

Properties

+ From befe98e2045678b5205cc7ffabf988ef9131f0f1 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 20:38:10 -0400 Subject: [PATCH 058/314] =?UTF-8?q?Cache=20YAML=20parses=20in=20FileContex?= =?UTF-8?q?tStore=20=E2=80=94=20fixes=20slow=20Plans/campaign=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_campaign_tree calls get_subcampaigns (reads every campaign.yaml) and get_plan_status -> get_plan_items(include_children) -> _get_campaign_tree_ids (re-scans every campaign.yaml) per node, so listing/opening campaigns re-parsed the same YAML files O(N^2)+ times per request. The new Home tab made /api/campaigns fire on every page load, compounding it. Add a parse cache to _read_yaml keyed by (mtime, size): repeated reads of the same file return a deepcopy of the cached parse instead of re-opening + re-parsing. Auto-invalidated when a file's mtime/size changes (incl. external writes) and explicitly on _write_yaml. Every return is a deepcopy, so callers that mutate raw plan-item lists (update/delete) can't corrupt the cache. Collapses the tree build from O(N^2) YAML parses to O(N) parses + cheap stat/deepcopy. Verified: deepcopy isolation, write- and mtime-invalidation, missing-file. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/harness/memory/file_store.py | 32 +++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 84beebf2..f8f797bb 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -28,6 +28,7 @@ assessments/{id}.yaml """ +import copy import dataclasses import json import logging @@ -106,6 +107,11 @@ class FileContextStore: def __init__(self, agent_dir: Path): self.agent_dir = Path(agent_dir) self._ensure_dirs() + # YAML parse cache: str(path) -> ((mtime, size), parsed). Collapses the + # O(N^2) re-parsing in campaign-tree builds; auto-invalidated by file + # mtime/size changes and explicitly on _write_yaml. Set BEFORE the index + # rebuild below, which reads YAML through the cache. + self._yaml_cache: Dict[str, tuple] = {} # In-memory index: campaign_id -> folder Path self._campaign_index: Dict[str, Path] = {} self._rebuild_campaign_index() @@ -177,22 +183,32 @@ def _write_yaml(self, path: Path, data): yaml.dump(data, fh, Dumper=_ISODumper, default_flow_style=False, allow_unicode=True, sort_keys=False) # Atomic rename (on Windows this replaces the target). - if os.name == "nt": - # os.replace is atomic on Windows when on same volume. - os.replace(str(tmp), str(path)) - else: - os.replace(str(tmp), str(path)) + os.replace(str(tmp), str(path)) + # Invalidate the parse cache so the next read reloads (new mtime anyway). + self._yaml_cache.pop(str(path), None) def _read_yaml(self, path: Path): - """Read a YAML file; return None if missing or empty.""" - if not path.exists(): + """Read a YAML file, parse-cached by (mtime, size). Returns None if + missing or empty. The cached object is never handed out directly — every + return is a deepcopy — so callers may freely mutate the result without + corrupting the cache.""" + try: + st = path.stat() + except OSError: return None + key = str(path) + sig = (st.st_mtime, st.st_size) + cached = self._yaml_cache.get(key) + if cached is not None and cached[0] == sig: + return copy.deepcopy(cached[1]) try: with open(path, "r", encoding="utf-8") as fh: - return yaml.safe_load(fh) + data = yaml.safe_load(fh) except Exception: logger.warning(f"Failed to read {path}", exc_info=True) return None + self._yaml_cache[key] = (sig, data) + return copy.deepcopy(data) def _append_jsonl(self, path: Path, record: dict): """Append one JSON line to a file.""" From 9369407619d1291e143cf8b646544bc95e9d2613 Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 20:56:08 -0400 Subject: [PATCH 059/314] Home: recent images aggregated across previous sessions The home "recent images" card previously read /api/snapshots, which is the in-memory ImageStore for the *current* session only -- so a freshly opened UI showed nothing until the live session captured volumes. Pull from the FileStore on disk instead so it reflects imagery from previous sessions. New routes (gently/ui/web/routes/sessions.py): - GET /api/home/recent-images -- latest projection per embryo across the most-recent sessions. Cheap by construction: session IDs from folder names (no session.yaml parse), embryo IDs from directory names (no embryo.yaml parse), timepoints from a filename glob (no pixel decode), and the walk stops as soon as `limit` images are collected. limit/sessions are clamped on both ends so a crafted query cannot turn this unauthenticated read into a full-disk scan. - GET /api/sessions/{id}/projection -- serves any saved session's JPEG projection, with a component-wise path-traversal guard (the resolved file must be a child of the session dir; not str.startswith, which a sibling like `_evil` would slip through). Cheap FileStore helpers (gently/core/file_store.py): - recent_session_ids(limit) -- newest-first by folder date prefix, no YAML. - list_embryo_ids(session) -- IDs from directory names, no embryo.yaml read. home.js loadImages() now fetches the aggregator and builds encoded thumbnail URLs, with an in-flight + 15s TTL guard so re-entering the Home tab does not re-walk the disk on every visit. Verified with a synthetic on-disk store (helpers, aggregation ordering + short-circuit + clamps, and the traversal-guard predicate including the sibling-prefix case). Findings from an adversarial review (missing upper-bound clamp, no early short-circuit, per-embryo YAML parse, redundant fetches) are all addressed here. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/core/file_store.py | 31 +++++++++++ gently/ui/web/routes/sessions.py | 92 ++++++++++++++++++++++++++++++++ gently/ui/web/static/js/home.js | 45 +++++++++++----- 3 files changed, 156 insertions(+), 12 deletions(-) diff --git a/gently/core/file_store.py b/gently/core/file_store.py index 4b33dedd..e4f14009 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -388,6 +388,21 @@ def list_sessions(self) -> List[SessionInfo]: sessions.sort(key=lambda s: s.get("last_active", ""), reverse=True) return sessions + def recent_session_ids(self, limit: int = 8) -> List[str]: + """Most-recent session IDs by folder-name date prefix, *cheaply*. + + Folder names are ``{YYYYMMDD}_{HHMM}_{slug}_{id8}`` so a reverse lexical + sort of the index orders them newest-first by creation time — no + ``session.yaml`` parse required. This is a creation-recency proxy (a + long-dormant session that was just resumed sorts by its original date), + which is fine for at-a-glance landing views; use ``list_sessions`` when + exact ``last_active`` ordering matters. + """ + items = sorted(self._index.items(), key=lambda kv: kv[1], reverse=True) + if limit and limit > 0: + items = items[:limit] + return [sid for sid, _ in items] + def touch_session(self, session_id: str) -> None: """Update last_active timestamp.""" sd = self._session_dir(session_id) @@ -562,6 +577,22 @@ def list_embryos(self, session_id: str) -> List[EmbryoInfo]: result.append(_normalize_embryo_record(data)) return result + def list_embryo_ids(self, session_id: str) -> List[str]: + """Embryo IDs from directory names only — no ``embryo.yaml`` parse. + + The directory name *is* the embryo_id in this layout (see + ``_embryo_dir`` / ``put_embryo``), so callers that only need the ids + (e.g. enumerating projections) can skip the per-embryo YAML read that + ``list_embryos`` pays. + """ + sd = self._session_dir(session_id) + if sd is None: + return [] + embryos_dir = sd / "embryos" + if not embryos_dir.exists(): + return [] + return [e.name for e in sorted(embryos_dir.iterdir()) if e.is_dir()] + # ================================================================== # Volumes # ================================================================== diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index 58f3394d..09f3bd25 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -1,8 +1,10 @@ """Session routes - list, retrieve, and resume saved sessions.""" import logging +from pathlib import Path from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse from gently.ui.web.auth import require_control @@ -54,6 +56,96 @@ async def list_sessions(): logger.warning("Failed to list sessions from FileStore: %s", e) return {"sessions": sessions} + @router.get("/api/home/recent-images") + async def recent_images(limit: int = 8, sessions: int = 6): + """Latest projection per embryo, aggregated across recent sessions. + + Unlike /api/snapshots (in-memory, current session only), this walks the + FileStore on disk so the home page can show imagery from *previous* + sessions. Cheap by construction: recent session IDs come from folder + names (no session.yaml parse), embryo IDs from directory names (no + embryo.yaml parse), timepoints from a filename glob (no pixel decode), + and the walk stops as soon as `limit` images are collected. Bounds are + clamped so a crafted ?sessions=/?limit= can't turn this unauthenticated + read into a full-disk scan. Returns components; the client builds the + (encoded) image URL. + """ + store = _file_store() + if store is None: + return {"images": []} + limit = max(1, min(int(limit), 48)) + sessions = max(1, min(int(sessions), 24)) + out = [] + try: + for sid in (store.recent_session_ids(sessions) or []): + try: + eids = store.list_embryo_ids(sid) + except Exception: + eids = [] + sname = None # parsed lazily, only if this session contributes + for eid in eids: + try: + tps = store.list_projection_timepoints(sid, eid) or [] + except Exception: + tps = [] + if not tps: + continue + if sname is None: + try: + info = store.get_session(sid) + except Exception: + info = None + sname = (info.get("name") if info else None) or sid + out.append({ + "session_id": sid, + "session_name": sname, + "embryo_id": eid, + "timepoint": int(max(tps)), + }) + if len(out) >= limit: + break + if len(out) >= limit: + break + except Exception as e: + logger.warning("recent_images failed: %s", e) + return {"images": out[:limit]} + + @router.get("/api/sessions/{session_id}/projection") + async def get_session_projection(session_id: str, embryo: str, t: int): + """Serve a saved JPEG projection from any session on disk. + + Path-traversal safe: the resolved file must live inside the session's + own directory, so a crafted `embryo` (e.g. '../..') can't escape. + """ + store = _file_store() + if store is None: + raise HTTPException(status_code=503, detail="Store not available") + path = store.get_projection_path(session_id, embryo, t) + if path is None: + raise HTTPException(status_code=404, detail="Projection not found") + try: + sd = store._session_dir(session_id) + resolved = Path(path).resolve() + # Component-wise ancestor check (not str.startswith, which would + # let a sibling like `_evil` slip through the prefix match). + sd_resolved = Path(sd).resolve() if sd is not None else None + if sd_resolved is None or sd_resolved not in resolved.parents: + raise HTTPException(status_code=404, detail="Not found") + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=404, detail="Not found") + try: + st = resolved.stat() + etag = f'"{int(st.st_mtime)}-{st.st_size}"' + except OSError: + etag = None + headers = {"Cache-Control": "private, max-age=60"} + if etag: + headers["ETag"] = etag + return FileResponse(str(resolved), media_type="image/jpeg", + headers=headers) + @router.post("/api/sessions/{session_id}/resume", dependencies=[Depends(require_control)]) async def resume_session(session_id: str): diff --git a/gently/ui/web/static/js/home.js b/gently/ui/web/static/js/home.js index 54adadd9..089d7de3 100644 --- a/gently/ui/web/static/js/home.js +++ b/gently/ui/web/static/js/home.js @@ -6,13 +6,18 @@ * that launches the setup flow (the wizard, which no longer auto-pops in chat). * * Read-only fetches against existing endpoints (/api/sessions, /api/campaigns, - * /api/snapshots); mirrors the ReviewApp/CampaignsApp module pattern. + * /api/home/recent-images); mirrors the ReviewApp/CampaignsApp module pattern. */ const HomeApp = (() => { let _inited = false; const SESSIONS_N = 5; const CAMPAIGNS_N = 5; const IMAGES_N = 8; + // Recent images are stable (latest projection per embryo). refresh() runs on + // every Home-tab entry, so guard against redundant disk-walking fetches: + // skip if one is in flight or the strip was loaded within IMAGES_TTL_MS. + const IMAGES_TTL_MS = 15000; + let _imgState = { at: 0, inflight: false }; function relTime(iso) { if (!iso) return ''; @@ -90,26 +95,42 @@ const HomeApp = (() => { } catch (e) { empty(el, 'Could not load plans.'); } } - async function loadImages() { + async function loadImages(force) { const el = document.getElementById('home-recent-images'); if (!el) return; + if (_imgState.inflight) return; + // _imgState.at is set only after a completed fetch (images or empty), + // never after an error — so failures still retry on the next entry. + if (!force && _imgState.at && (Date.now() - _imgState.at) < IMAGES_TTL_MS) return; + _imgState.inflight = true; try { - const data = await (await fetch('/api/snapshots')).json(); - // /api/snapshots is timestamp-ASCENDING; take the tail for "recent". - const recent = (data.snapshots || []).slice(-IMAGES_N).reverse(); + const data = await (await fetch(`/api/home/recent-images?limit=${IMAGES_N}`)).json(); + // Latest projection per embryo across recent sessions (server orders + // most-recent session first). + const recent = (data.images || []).slice(0, IMAGES_N); if (!recent.length) { - empty(el, 'No images yet — they appear once a session is active.'); + empty(el, 'No images yet — they appear once a session has captured volumes.'); + _imgState.at = Date.now(); return; } el.innerHTML = '
' + recent.map(s => { - const m = s.metadata || {}; - const label = m.embryo_id - ? `${m.embryo_id}${m.timepoint != null ? ' · t' + m.timepoint : ''}` : ''; - return `
- ${escapeHtml(label)} + const tp = (s.timepoint != null) ? ` · t${s.timepoint}` : ''; + const label = `${s.embryo_id || ''}${tp}`; + const sub = s.session_name && s.session_name !== s.session_id + ? ` (${s.session_name})` : ''; + const src = `/api/sessions/${encodeURIComponent(s.session_id)}` + + `/projection?embryo=${encodeURIComponent(s.embryo_id)}` + + `&t=${encodeURIComponent(s.timepoint)}`; + return `
+ ${escapeHtml(label)}
`; }).join('') + '
'; - } catch (e) { empty(el, 'Could not load images.'); } + _imgState.at = Date.now(); + } catch (e) { + empty(el, 'Could not load images.'); + } finally { + _imgState.inflight = false; + } } function updateStatus() { From 7d877ba51daa470791a89f0f55fdeb873314f6cb Mon Sep 17 00:00:00 2001 From: Kesavan Date: Fri, 29 May 2026 20:57:06 -0400 Subject: [PATCH 060/314] Login: non-blocking page with a "Continue in view-only" escape hatch The /login page was a dead end: once a viewer landed there (via the chat window's "Sign in" button, a bookmark, or a redirect) the only way forward was valid credentials. Viewing is already open to everyone at / (index serves the SPA in view mode; signing in is an *elevation* to control, not a gate), so the login page should offer the same choice. Add a clearly-secondary "Continue without signing in ->" action beneath the Sign in button that drops straight into view-only mode, with a one-line note that you can sign in any time to take control. The subtitle now frames both paths. Only rendered when accounts are configured (otherwise /login already redirects to /). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/templates/login.html | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/gently/ui/web/templates/login.html b/gently/ui/web/templates/login.html index 9f893995..62e5ec98 100644 --- a/gently/ui/web/templates/login.html +++ b/gently/ui/web/templates/login.html @@ -46,6 +46,25 @@ button:hover:not(:disabled) { background: var(--accent-hover); } button:disabled { opacity: 0.6; cursor: default; } .error { color: var(--danger); font-size: 13px; margin-top: 14px; min-height: 18px; } + .divider { + display: flex; align-items: center; gap: 10px; + margin: 18px 0 0; color: var(--text-muted); + font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; + } + .divider::before, .divider::after { + content: ""; flex: 1; height: 1px; background: var(--border); + } + .ghost { + display: block; width: 100%; margin-top: 14px; padding: 10px; + text-align: center; border: 1px solid var(--border); + border-radius: 9px; background: transparent; color: var(--text); + font-size: 13.5px; font-weight: 500; text-decoration: none; + } + .ghost:hover { border-color: var(--accent); color: var(--accent-hover); } + .alt-note { + margin: 12px 0 0; color: var(--text-muted); + font-size: 12px; line-height: 1.5; text-align: center; + } @@ -54,13 +73,16 @@

Gently

-

Sign in to control the microscope.

+

Sign in to control the microscope — or keep watching in view-only mode.

+
or
+ Continue without signing in → +

View-only: watch live sessions and imagery. You can sign in any time to take control.

', '', html, flags=_re.DOTALL) - text = _re.sub(r']*>.*?', '', html, flags=_re.DOTALL) - text = _re.sub(r'<[^>]+>', ' ', text) - text = _re.sub(r'\s+', ' ', text).strip() + text = _re.sub(r"]*>.*?", "", html, flags=_re.DOTALL) + text = _re.sub(r"]*>.*?", "", html, flags=_re.DOTALL) + text = _re.sub(r"<[^>]+>", " ", text) + text = _re.sub(r"\s+", " ", text).strip() if len(text) > 15000: text = text[:15000] + "\n\n[... truncated ...]" @@ -899,7 +939,7 @@ async def _fetch_url_text(url: str) -> Optional[str]: return None -def _read_pdf_file(path: str) -> Optional[str]: +def _read_pdf_file(path: str) -> str | None: """Extract text from a local PDF file using pymupdf if available.""" import os @@ -908,6 +948,7 @@ def _read_pdf_file(path: str) -> Optional[str]: try: import fitz # pymupdf + doc = fitz.open(path) pages = [] for page in doc: @@ -920,15 +961,16 @@ def _read_pdf_file(path: str) -> Optional[str]: return text if text.strip() else None except ImportError: - logger.info("pymupdf not installed — cannot extract PDF text. " - "Install with: pip install pymupdf") + logger.info( + "pymupdf not installed — cannot extract PDF text. Install with: pip install pymupdf" + ) return None except Exception as e: logger.warning(f"PDF extraction failed for {path}: {e}") return None -async def _pubmed_abstract(pmid: str) -> Optional[Dict]: +async def _pubmed_abstract(pmid: str) -> dict | None: """Fetch article metadata + abstract from PubMed.""" import aiohttp @@ -948,6 +990,7 @@ async def _pubmed_abstract(pmid: str) -> Optional[Dict]: xml_text = await resp.text() import xml.etree.ElementTree as ET + root = ET.fromstring(xml_text) article = root.find(".//PubmedArticle") @@ -998,26 +1041,26 @@ async def _pubmed_abstract(pmid: str) -> Optional[Dict]: return None -async def _resolve_reference(reference: str) -> Dict: +async def _resolve_reference(reference: str) -> dict: """Parse a reference string and determine what kind of input it is. Returns a dict with keys: type, pmid, doi, url, path, query """ - import re import os + import re ref = reference.strip() result = {"type": "unknown", "raw": ref} # PMID - m = re.match(r'^(?:PMID[:\s]*)?(\d{6,9})$', ref, re.IGNORECASE) + m = re.match(r"^(?:PMID[:\s]*)?(\d{6,9})$", ref, re.IGNORECASE) if m: result["type"] = "pmid" result["pmid"] = m.group(1) return result # DOI - m = re.search(r'(10\.\d{4,}/[^\s]+)', ref) + m = re.search(r"(10\.\d{4,}/[^\s]+)", ref) if m: result["type"] = "doi" result["doi"] = m.group(1).rstrip(".,;)") @@ -1029,13 +1072,13 @@ async def _resolve_reference(reference: str) -> Dict: result["url"] = ref if ref.startswith("http") else "https://" + ref # Extract PMID from PubMed URLs - m = re.search(r'pubmed\.ncbi.*?/(\d{6,9})', ref) + m = re.search(r"pubmed\.ncbi.*?/(\d{6,9})", ref) if m: result["type"] = "pmid" result["pmid"] = m.group(1) # Extract PMCID from PMC URLs - m = re.search(r'/pmc/articles/(PMC\d+)', ref) + m = re.search(r"/pmc/articles/(PMC\d+)", ref) if m: result["type"] = "pmcid" result["pmcid"] = m.group(1) @@ -1054,7 +1097,7 @@ async def _resolve_reference(reference: str) -> Dict: return result -async def _search_pmid(query: str) -> Optional[str]: +async def _search_pmid(query: str) -> str | None: """Search PubMed for a citation string and return the best PMID. Tries multiple query strategies to handle imprecise citations like @@ -1066,7 +1109,7 @@ async def _search_pmid(query: str) -> Optional[str]: # Detect "Author et al YEAR topic" pattern m = _re.match( - r'^([A-Z][a-z]+)\s+(?:et\s+al\.?\s+)?(\d{4})?\s*(.*)?$', + r"^([A-Z][a-z]+)\s+(?:et\s+al\.?\s+)?(\d{4})?\s*(.*)?$", query.strip(), ) if m: @@ -1076,8 +1119,10 @@ async def _search_pmid(query: str) -> Optional[str]: # Fix common organism names in topic topic_fixed = _re.sub( - r'\bC\.?\s*elegans\b', '"Caenorhabditis elegans"', - topic, flags=_re.IGNORECASE, + r"\bC\.?\s*elegans\b", + '"Caenorhabditis elegans"', + topic, + flags=_re.IGNORECASE, ) # Strategy 1: author + organism MeSH + quoted topic (most specific) @@ -1087,15 +1132,11 @@ async def _search_pmid(query: str) -> Optional[str]: ) # Strategy 2: author + organism MeSH (no topic — topic may not be in title) - strategies.append( - f'{author}[author] AND "Caenorhabditis elegans"[Mesh]' - ) + strategies.append(f'{author}[author] AND "Caenorhabditis elegans"[Mesh]') # Strategy 3: author + year + topic (exact year, may be wrong) if year and topic_fixed: - strategies.append( - f'{author}[author] AND {year}[pdat] AND {topic_fixed}' - ) + strategies.append(f"{author}[author] AND {year}[pdat] AND {topic_fixed}") # Strategy 4: author + year only if year: @@ -1103,8 +1144,10 @@ async def _search_pmid(query: str) -> Optional[str]: # Strategy 5: original query with organism name fix fixed = _re.sub( - r'\bC\.?\s*elegans\b', '"Caenorhabditis elegans"', - query, flags=_re.IGNORECASE, + r"\bC\.?\s*elegans\b", + '"Caenorhabditis elegans"', + query, + flags=_re.IGNORECASE, ) if fixed != query: strategies.append(fixed) @@ -1122,6 +1165,7 @@ async def _search_pmid(query: str) -> Optional[str]: # Try each strategy import aiohttp + for attempt in unique: try: async with _http_session() as session: @@ -1145,7 +1189,7 @@ async def _search_pmid(query: str) -> Optional[str]: return None -async def _doi_to_pmid(doi: str) -> Optional[str]: +async def _doi_to_pmid(doi: str) -> str | None: """Resolve a DOI to a PMID via PubMed search.""" return await _search_pmid(f"{doi}[doi]") @@ -1177,7 +1221,7 @@ async def _doi_to_pmid(doi: str) -> Optional[str]: ) async def read_paper( reference: str, - context: Dict = None, + context: dict = None, ) -> str: """Read a scientific paper and return its content. @@ -1229,11 +1273,7 @@ async def read_paper( status_lines.append(f"Reading local PDF: {path}") text = _read_pdf_file(path) if text: - return ( - f"[Paper from local file: {path}]\n\n" - f"{text}\n\n---\n" - f"Source: local file" - ) + return f"[Paper from local file: {path}]\n\n{text}\n\n---\nSource: local file" else: return ( f"[Paper from local file: {path}]\n\n" @@ -1308,7 +1348,7 @@ async def read_paper( meta = await _pubmed_abstract(pmid) if meta: lines = [ - f"[Abstract only — full text not freely available]\n", + "[Abstract only — full text not freely available]\n", f"# {meta['title']}\n", f"**Authors:** {meta['authors']}", f"**Journal:** {meta['journal']} ({meta['year']})", @@ -1319,9 +1359,9 @@ async def read_paper( lines.append(f"\n## Abstract\n\n{meta['abstract']}") lines.append( - f"\n---\n" - f"*Full text not available through open access channels. " - f"If you have a PDF, provide the file path and I can read it.*" + "\n---\n" + "*Full text not available through open access channels. " + "If you have a PDF, provide the file path and I can read it.*" ) lines.append(f"\n*Resolution path: {' → '.join(status_lines)}*") diff --git a/gently/harness/plan_mode/tools/templates.py b/gently/harness/plan_mode/tools/templates.py index 006c4c95..b75b0e64 100644 --- a/gently/harness/plan_mode/tools/templates.py +++ b/gently/harness/plan_mode/tools/templates.py @@ -5,9 +5,7 @@ dependencies) for re-use with different strains, temperatures, etc. """ -from typing import Dict, Optional - -from ...tools.registry import tool, ToolCategory, ToolExample +from ...tools.registry import ToolCategory, ToolExample, tool @tool( @@ -34,7 +32,7 @@ async def save_plan_template( campaign_id: str, name: str, description: str = None, - context: Dict = None, + context: dict = None, ) -> str: """Save a campaign as a reusable template.""" agent = context.get("agent") if context else None @@ -67,7 +65,7 @@ async def save_plan_template( category=ToolCategory.UTILITY, ) async def list_templates( - context: Dict = None, + context: dict = None, ) -> str: """List available plan templates.""" agent = context.get("agent") if context else None @@ -112,8 +110,8 @@ async def list_templates( ) async def apply_template( template_id: str, - overrides: Dict = None, - context: Dict = None, + overrides: dict = None, + context: dict = None, ) -> str: """Instantiate a template into a new campaign.""" agent = context.get("agent") if context else None diff --git a/gently/harness/plan_mode/tools/validation.py b/gently/harness/plan_mode/tools/validation.py index 8e09802a..18e06ae6 100644 --- a/gently/harness/plan_mode/tools/validation.py +++ b/gently/harness/plan_mode/tools/validation.py @@ -5,11 +5,9 @@ detector validity, missing controls, dependency cycles, and completeness. """ -import json import logging -from typing import Dict, List, Optional, Set, Tuple -from ...tools.registry import tool, ToolCategory, ToolExample +from ...tools.registry import ToolCategory, ToolExample, tool logger = logging.getLogger(__name__) @@ -22,7 +20,7 @@ "num_slices": (10, 200), "exposure_ms": (5.0, 100.0), "laser_power_pct": (0.0, 100.0), - "interval_s": (10, None), # minimum 10s, no hard max + "interval_s": (10, None), # minimum 10s, no hard max "piezo_amplitude_um": (None, 200.0), # max ±200 μm } @@ -40,9 +38,9 @@ # Temperature scaling factors (relative to 20°C) TEMP_SCALE = { - 15.0: 24.0 / 14.0, # ~1.71× slower + 15.0: 24.0 / 14.0, # ~1.71× slower 20.0: 1.0, - 25.0: 10.0 / 14.0, # ~0.71× faster + 25.0: 10.0 / 14.0, # ~0.71× faster } CONTROL_KEYWORDS = {"control", "wildtype", "n2", "wt", "wild-type", "wild type"} @@ -52,7 +50,8 @@ # Helpers # --------------------------------------------------------------------------- -def _get_temp_factor(temperature_c: Optional[float]) -> float: + +def _get_temp_factor(temperature_c: float | None) -> float: """Return scaling factor for developmental timing at given temperature.""" if temperature_c is None: return 1.0 @@ -68,19 +67,19 @@ def _get_temp_factor(temperature_c: Optional[float]) -> float: return TEMP_SCALE[20.0] + frac * (TEMP_SCALE[25.0] - TEMP_SCALE[20.0]) -def _check_dependency_cycles(items) -> List[str]: +def _check_dependency_cycles(items) -> list[str]: """DFS-based cycle detection on the dependency graph.""" # Build adjacency list: item_id -> list of dependency IDs - adj: Dict[str, List[str]] = {} - id_to_title: Dict[str, str] = {} + adj: dict[str, list[str]] = {} + id_to_title: dict[str, str] = {} for item in items: adj[item.id] = list(item.depends_on) id_to_title[item.id] = item.title WHITE, GRAY, BLACK = 0, 1, 2 - color: Dict[str, int] = {nid: WHITE for nid in adj} - cycles: List[str] = [] - path: List[str] = [] + color: dict[str, int] = {nid: WHITE for nid in adj} + cycles: list[str] = [] + path: list[str] = [] def dfs(node: str): if node not in color: @@ -110,13 +109,16 @@ def dfs(node: str): return cycles -def _stage_order(stage_name: str) -> Optional[int]: +def _stage_order(stage_name: str) -> int | None: """Get ordinal position of a stage, or None if unrecognised.""" from gently_perception.organism import CELEGANS + stages = CELEGANS.stages aliases = { - "3fold": "pretzel", "threefold": "pretzel", - "1.5-fold": "1.5fold", "2-fold": "2fold", + "3fold": "pretzel", + "threefold": "pretzel", + "1.5-fold": "1.5fold", + "2-fold": "2fold", } normed = stage_name.lower().replace("-", "").replace(" ", "") name = aliases.get(normed, normed) @@ -127,9 +129,10 @@ def _stage_order(stage_name: str) -> Optional[int]: return None -def _normalise_stage(name: str) -> Optional[str]: +def _normalise_stage(name: str) -> str | None: """Normalise a stage name to canonical form, or None.""" from gently_perception.organism import CELEGANS + stages = CELEGANS.stages low = name.lower().strip() for s in stages: @@ -143,6 +146,7 @@ def _normalise_stage(name: str) -> Optional[str]: # Tool # --------------------------------------------------------------------------- + @tool( name="validate_plan", description=( @@ -161,7 +165,7 @@ def _normalise_stage(name: str) -> Optional[str]: ) async def validate_plan( campaign_id: str, - context: Dict = None, + context: dict = None, ) -> str: """Validate a plan and return errors/warnings.""" agent = context.get("agent") if context else None @@ -178,14 +182,18 @@ async def validate_plan( if not items: return f"Campaign '{campaign.description}' has no plan items to validate." - errors: List[str] = [] - warnings: List[str] = [] + errors: list[str] = [] + warnings: list[str] = [] # Load detector presets for validation try: from gently.organisms import get_organism + org = get_organism() - presets_mod = __import__(f"gently.organisms.{org.ORGANISM_NAME}.detector_presets", fromlist=["get_detector_presets"]) + presets_mod = __import__( + f"gently.organisms.{org.ORGANISM_NAME}.detector_presets", + fromlist=["get_detector_presets"], + ) valid_detectors = set(presets_mod.get_detector_presets().keys()) except ImportError: valid_detectors = set() @@ -199,16 +207,31 @@ async def validate_plan( label = f"[{item.type.value}] '{item.title}'" # Check for control mentions - text_blob = " ".join(filter(None, [ - item.title, item.description, item.outcome, - ])).lower() + text_blob = " ".join( + filter( + None, + [ + item.title, + item.description, + item.outcome, + ], + ) + ).lower() if item.imaging_spec: - text_blob += " " + " ".join(filter(None, [ - item.imaging_spec.strain, - item.imaging_spec.genotype, - item.imaging_spec.reporter, - item.imaging_spec.success_criteria, - ])).lower() + text_blob += ( + " " + + " ".join( + filter( + None, + [ + item.imaging_spec.strain, + item.imaging_spec.genotype, + item.imaging_spec.reporter, + item.imaging_spec.success_criteria, + ], + ) + ).lower() + ) if any(kw in text_blob for kw in CONTROL_KEYWORDS): has_control = True @@ -221,13 +244,9 @@ async def validate_plan( if val is None: continue if lo is not None and val < lo: - errors.append( - f"{label}: {field_name}={val} below minimum {lo}" - ) + errors.append(f"{label}: {field_name}={val} below minimum {lo}") if hi is not None and val > hi: - errors.append( - f"{label}: {field_name}={val} exceeds maximum {hi}" - ) + errors.append(f"{label}: {field_name}={val} exceeds maximum {hi}") # Stage consistency if spec.start_stage and spec.stop_condition: diff --git a/gently/harness/prompts/manager.py b/gently/harness/prompts/manager.py index cde5db44..9552be76 100644 --- a/gently/harness/prompts/manager.py +++ b/gently/harness/prompts/manager.py @@ -9,13 +9,13 @@ import json import logging from datetime import datetime -from typing import Dict, List, Optional from gently.settings import settings -from .templates import build_system_prompt, build_context_message + from ..plan_mode.prompt import build_plan_prompt from ..resolution_mode.prompt import build_resolution_prompt from ..tools.registry import get_tool_registry +from .templates import build_system_prompt logger = logging.getLogger(__name__) @@ -33,21 +33,22 @@ def __init__(self, claude_client, model): self.model = model # Context summary caching - self._context_summary_cache: Optional[str] = None - self._context_summary_time: Optional[datetime] = None + self._context_summary_cache: str | None = None + self._context_summary_time: datetime | None = None self._context_summary_ttl: int = 300 # 5 minutes # Memory awareness caching - self._memory_awareness_cache: Optional[str] = None - self._memory_awareness_time: Optional[datetime] = None + self._memory_awareness_cache: str | None = None + self._memory_awareness_time: datetime | None = None self._memory_awareness_ttl: int = 600 # 10 minutes # Set by agent after construction self.context_store = None self.memory = None # AgentMemory instance - def update_system_prompt(self, experiment, client, mode: str, - context_summary: str = None, perceiver=None) -> str: + def update_system_prompt( + self, experiment, client, mode: str, context_summary: str = None, perceiver=None + ) -> str: """ Rebuild system prompt with current experiment state and connection status. @@ -86,14 +87,16 @@ def update_system_prompt(self, experiment, client, mode: str, # Execution mode if client: connection_status = { - 'device_layer': client.is_connected, - 'sam_detection': client.has_sam, + "device_layer": client.is_connected, + "sam_detection": client.has_sam, } else: connection_status = None return build_system_prompt( - experiment, connection_status, context_summary, + experiment, + connection_status, + context_summary, memory_awareness=memory_awareness, microscope=client, perceiver=perceiver, @@ -143,18 +146,32 @@ def get_tools_for_mode(self, mode: str, has_microscope: bool) -> list: return [t for t in all_tools if t["name"] in resolution_tool_names] if mode == "plan": plan_tool_names = { - "create_campaign", "create_plan_item", "update_plan_item", - "link_plan_items", "propose_plan", "get_plan_status", + "create_campaign", + "create_plan_item", + "update_plan_item", + "link_plan_items", + "propose_plan", + "get_plan_status", "get_plan_item", - "move_plan_item", "delete_plan_item", "reorder_plan_items", - "update_phase", "delete_phase", + "move_plan_item", + "delete_plan_item", + "reorder_plan_items", + "update_phase", + "delete_phase", "export_plan", - "query_lab_history", "check_hardware_capability", - "search_literature", "search_strains", + "query_lab_history", + "check_hardware_capability", + "search_literature", + "search_strains", "validate_plan", - "batch_update_status", "batch_update_spec", - "snapshot_plan", "list_plan_versions", "restore_plan_version", - "save_plan_template", "list_templates", "apply_template", + "batch_update_status", + "batch_update_spec", + "snapshot_plan", + "list_plan_versions", + "restore_plan_version", + "save_plan_template", + "list_templates", + "apply_template", "ask_user_choice", } all_tools = registry.get_claude_schemas(has_microscope=False) @@ -167,14 +184,16 @@ def get_cached_memory_awareness(self) -> str: if not self.memory: return "" now = datetime.now() - if (self._memory_awareness_cache is None or - self._memory_awareness_time is None or - (now - self._memory_awareness_time).total_seconds() > self._memory_awareness_ttl): + if ( + self._memory_awareness_cache is None + or self._memory_awareness_time is None + or (now - self._memory_awareness_time).total_seconds() > self._memory_awareness_ttl + ): self._memory_awareness_cache = self.memory.get_awareness_summary() self._memory_awareness_time = now return self._memory_awareness_cache - def get_active_plan_summary(self) -> Optional[str]: + def get_active_plan_summary(self) -> str | None: """Get a summary of the active experimental plan, if any.""" if not self.context_store: return None @@ -192,13 +211,14 @@ def get_active_plan_summary(self) -> Optional[str]: f" ({status['completed']}/{status['total']} items done)" ) if status["next_actions"]: - lines.append(" Next: " + ", ".join( - a.title for a in status["next_actions"][:3] - )) + lines.append( + " Next: " + ", ".join(a.title for a in status["next_actions"][:3]) + ) if status["pending_decisions"]: - lines.append(" Decisions pending: " + ", ".join( - d.title for d in status["pending_decisions"] - )) + lines.append( + " Decisions pending: " + + ", ".join(d.title for d in status["pending_decisions"]) + ) return "\n".join(lines) if lines else None except Exception: return None @@ -222,36 +242,36 @@ def gather_context_data(self, experiment, timelapse_orch, timeline_mgr) -> dict: Context data including timelapse status, events, and detections """ data = { - 'current_time': datetime.now().isoformat(), - 'timelapse_status': None, - 'recent_events': [], - 'recent_detections': [], - 'detection_reasoning': [], + "current_time": datetime.now().isoformat(), + "timelapse_status": None, + "recent_events": [], + "recent_detections": [], + "detection_reasoning": [], } if timelapse_orch: try: status = timelapse_orch.get_status() - data['timelapse_status'] = { - 'state': status.status.value if status.status else 'unknown', - 'total_timepoints': status.total_timepoints or 0, - 'started_at': status.started_at.isoformat() if status.started_at else None, - 'embryo_count': len(status.embryos) if status.embryos else 0, + data["timelapse_status"] = { + "state": status.status.value if status.status else "unknown", + "total_timepoints": status.total_timepoints or 0, + "started_at": status.started_at.isoformat() if status.started_at else None, + "embryo_count": len(status.embryos) if status.embryos else 0, } except Exception as e: logger.debug(f"Could not get timelapse status: {e}") if timeline_mgr: try: - events = timeline_mgr.get_events(limit=20, session_id='current') - data['recent_events'] = [ + events = timeline_mgr.get_events(limit=20, session_id="current") + data["recent_events"] = [ { - 'type': e.event_subtype, - 'time': e.timestamp.isoformat(), - 'embryo': e.embryo_id, - 'detector': e.detector_name, - 'timepoint': e.timepoint, - 'confidence': e.confidence, + "type": e.event_subtype, + "time": e.timestamp.isoformat(), + "embryo": e.embryo_id, + "detector": e.detector_name, + "timepoint": e.timepoint, + "confidence": e.confidence, } for e in events ] @@ -260,32 +280,35 @@ def gather_context_data(self, experiment, timelapse_orch, timeline_mgr) -> dict: try: for embryo_id, embryo_state in experiment.embryos.items(): - if not hasattr(embryo_state, 'detection_results'): + if not hasattr(embryo_state, "detection_results"): continue for detector_name, results in embryo_state.detection_results.items(): recent_results = results[-3:] if len(results) > 3 else results for r in recent_results: - if r.get('detected'): - data['recent_detections'].append({ - 'detector': detector_name, - 'embryo': embryo_id, - 'timepoint': r.get('timepoint'), - 'confidence': r.get('confidence'), - }) - if r.get('reasoning'): - data['detection_reasoning'].append({ - 'detector': detector_name, - 'embryo': embryo_id, - 'timepoint': r.get('timepoint'), - 'reasoning': r.get('reasoning')[:500], - }) + if r.get("detected"): + data["recent_detections"].append( + { + "detector": detector_name, + "embryo": embryo_id, + "timepoint": r.get("timepoint"), + "confidence": r.get("confidence"), + } + ) + if r.get("reasoning"): + data["detection_reasoning"].append( + { + "detector": detector_name, + "embryo": embryo_id, + "timepoint": r.get("timepoint"), + "reasoning": r.get("reasoning")[:500], + } + ) except Exception as e: logger.debug(f"Could not get detection results: {e}") return data - async def generate_context_summary(self, experiment, timelapse_orch, - timeline_mgr) -> str: + async def generate_context_summary(self, experiment, timelapse_orch, timeline_mgr) -> str: """ Generate concise context summary using Haiku. @@ -302,22 +325,23 @@ async def generate_context_summary(self, experiment, timelapse_orch, """ raw_data = self.gather_context_data(experiment, timelapse_orch, timeline_mgr) - has_timelapse = raw_data['timelapse_status'] is not None - has_events = len(raw_data['recent_events']) > 0 - has_detections = len(raw_data['recent_detections']) > 0 + has_timelapse = raw_data["timelapse_status"] is not None + has_events = len(raw_data["recent_events"]) > 0 + has_detections = len(raw_data["recent_detections"]) > 0 if not (has_timelapse or has_events or has_detections): return "" - prompt = f"""Summarize the current microscopy session state in 2-3 sentences for another AI assistant. -Focus on: timelapse status (is it running, completed, or idle?), time since last activity, and notable detections. -Be factual and concise. + prompt = f"""Summarize the current microscopy session state in 2-3 sentences for +another AI assistant. Focus on: timelapse status (is it running, completed, or idle?), +time since last activity, and notable detections. Be factual and concise. Raw session data: {json.dumps(raw_data, indent=2, default=str)} Write a brief status summary. Examples: -- "Timelapse completed 10h ago with 233 timepoints. Hatching was detected at timepoints 175-193 with HIGH confidence." +- "Timelapse completed 10h ago with 233 timepoints. Hatching was detected at timepoints + 175-193 with HIGH confidence." - "Timelapse is currently running for embryo_1 at timepoint 45. No detections yet." - "No active timelapse. Last session had 50 timepoints, with comma stage detected at t=30." """ @@ -327,15 +351,14 @@ async def generate_context_summary(self, experiment, timelapse_orch, self.claude.messages.create, model=settings.models.fast, max_tokens=150, - messages=[{"role": "user", "content": prompt}] + messages=[{"role": "user", "content": prompt}], ) return response.content[0].text.strip() except Exception as e: logger.warning(f"Failed to generate context summary: {e}") return "" - async def get_cached_context_summary(self, experiment, timelapse_orch, - timeline_mgr) -> str: + async def get_cached_context_summary(self, experiment, timelapse_orch, timeline_mgr) -> str: """ Get context summary with caching (5-minute TTL). @@ -351,9 +374,11 @@ async def get_cached_context_summary(self, experiment, timelapse_orch, Cached or newly generated context summary """ now = datetime.now() - if (self._context_summary_cache is None or - self._context_summary_time is None or - (now - self._context_summary_time).total_seconds() > self._context_summary_ttl): + if ( + self._context_summary_cache is None + or self._context_summary_time is None + or (now - self._context_summary_time).total_seconds() > self._context_summary_ttl + ): self._context_summary_cache = await self.generate_context_summary( experiment, timelapse_orch, timeline_mgr ) @@ -384,6 +409,6 @@ def get_cached_system_prompt(self, system_prompt: str) -> list: { "type": "text", "text": system_prompt, - "cache_control": {"type": "ephemeral", "ttl": "1h"} + "cache_control": {"type": "ephemeral", "ttl": "1h"}, } ] diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index 61d06706..ef2fc3f4 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -2,17 +2,20 @@ System prompts and context builders for the Microscopy Agent """ -from typing import Dict, List -from ..state import ExperimentState -from gently.organisms import get_organism from gently.hardware import get_hardware +from gently.organisms import get_organism +from ..state import ExperimentState # Interactive choice guidance USER_INTERACTION_GUIDELINES = """ # Interactive User Choices — MANDATORY -CRITICAL RULE: Whenever you need to ask the user a question — whether it's a yes/no confirmation, a choice between options, or any question where the answer could be one of several discrete responses — you MUST use the `ask_user_choice` tool. NEVER present options as numbered text lists or bullet points. NEVER ask the user to type their choice as text when you could present selectable options instead. +CRITICAL RULE: Whenever you need to ask the user a question — whether it's a yes/no +confirmation, a choice between options, or any question where the answer could be one of +several discrete responses — you MUST use the `ask_user_choice` tool. NEVER present options +as numbered text lists or bullet points. NEVER ask the user to type their choice as text when +you could present selectable options instead. ## When to use ask_user_choice @@ -45,14 +48,21 @@ GOOD (always do this): Call the `ask_user_choice` tool. Example parameters: question: "What would you like to work on today?" - options: [{"id": "new", "label": "Start a new experiment"}, {"id": "resume", "label": "Resume a session"}] + options: [{"id": "new", "label": "Start a new experiment"}, + {"id": "resume", "label": "Resume a session"}] -The user interface renders these as an interactive picker with arrow-key navigation — much better UX than typing. -Do NOT write tool calls as XML tags or code blocks in your text — always invoke tools through the tool mechanism. +The user interface renders these as an interactive picker with arrow-key navigation — much +better UX than typing. +Do NOT write tool calls as XML tags or code blocks in your text — always invoke tools through +the tool mechanism. -IMPORTANT: This is not optional. ALWAYS use ask_user_choice when presenting choices or asking questions. The ONLY exception is when you need a completely free-form text response (like asking for a name or description). +IMPORTANT: This is not optional. ALWAYS use ask_user_choice when presenting choices or asking +questions. The ONLY exception is when you need a completely free-form text response (like +asking for a name or description). -Each option should be a specific, distinct choice. The picker automatically adds a free-text "Something else..." input at the bottom for custom responses, so your options can focus on the most likely concrete answers. +Each option should be a specific, distinct choice. The picker automatically adds a free-text +"Something else..." input at the bottom for custom responses, so your options can focus on +the most likely concrete answers. """ @@ -192,7 +202,7 @@ | User describes... | Mode to install | |---|---| -| reporter expression, GFP/mCherry onset, "neurons lighting up", dopaminergic signal, anything where fluorescence turns on | `expression_monitoring` | +| reporter onset: GFP/mCherry, dopaminergic signal, neurons lighting up | `expression_monitoring` | | hatching timing, pre-hatch dynamics, "track until they hatch" | `pre_terminal_monitoring` | | plain imaging, exploratory, no specific signal target | none (idle) | @@ -222,7 +232,8 @@ 1. **Non-blocking operation**: The timelapse runs independently - you can still chat with the user 2. **Per-embryo stop conditions**: Each embryo can stop at different times (e.g., when hatching) 3. **Dynamic intervals**: Adjust imaging frequency per-embryo during the experiment -4. **Detector integration**: Stop conditions triggered by visual detection (hatching, comma stage, etc.) +4. **Detector integration**: Stop conditions triggered by visual detection + (hatching, comma stage, etc.) ## Stop Conditions @@ -377,14 +388,16 @@ def build_system_prompt( str Complete system prompt """ - embryo_summary = experiment_state.get_summary() if experiment_state.embryos else "No embryos loaded yet" + embryo_summary = ( + experiment_state.get_summary() if experiment_state.embryos else "No embryos loaded yet" + ) # Build connection status section if connection_status: - device_layer = "connected" if connection_status.get('device_layer') else "NOT CONNECTED" - sam = "available" if connection_status.get('sam_detection') else "not available" + device_layer = "connected" if connection_status.get("device_layer") else "NOT CONNECTED" + sam = "available" if connection_status.get("sam_detection") else "not available" - if not connection_status.get('device_layer'): + if not connection_status.get("device_layer"): connection_section = f"""# Hardware Connection Status ⚠️ **OFFLINE MODE** - Device layer is not connected. @@ -392,7 +405,8 @@ def build_system_prompt( - Device Layer: {device_layer} - SAM Detection: {sam} -**Important**: You cannot perform hardware operations (detect embryos, capture images, move stage, etc.) +**Important**: You cannot perform hardware operations (detect embryos, capture images, +move stage, etc.) without a connected device layer. If the user asks for hardware operations, inform them that the microscope is not connected and suggest they start the server or check the connection.""" else: @@ -436,13 +450,13 @@ def build_system_prompt( biology_knowledge = organism.BIOLOGY_KNOWLEDGE # Build stop conditions list from organism module - stop_condition_names = list(organism.STOP_CONDITIONS.keys()) - detector_names = list(organism.get_detector_presets().keys()) + list(organism.STOP_CONDITIONS.keys()) + list(organism.get_detector_presets().keys()) # Pull hardware description — prefer microscope (from device layer handshake), # fall back to the static hardware module hardware = get_hardware() - hardware_description = getattr(microscope, 'DESCRIPTION', '') or hardware.HARDWARE_DESCRIPTION + hardware_description = getattr(microscope, "DESCRIPTION", "") or hardware.HARDWARE_DESCRIPTION hardware_display = hardware.HARDWARE_DISPLAY_NAME return f"""You are Gently — an AI scientific collaborator running {hardware_display} @@ -484,28 +498,40 @@ def build_system_prompt( Answer the user's request using relevant tools. Before calling a tool, do some analysis: 1. Think about which of the provided tools is relevant to answer the user's request -2. Go through each required parameter and determine if the user has provided or given enough information to infer a value +2. Go through each required parameter and determine if the user has provided or given enough + information to infer a value 3. If all required parameters are present or can be reasonably inferred, PROCEED WITH THE TOOL CALL 4. If a required parameter is missing, ask the user to provide it 5. DO NOT ask for more information on optional parameters if not provided - use defaults IMPORTANT: When you need information (status, positions, etc.), CALL THE TOOL IMMEDIATELY. -Do NOT explain what you "would need to do" - just do it. Never say "I would need to query..." - just query it. +Do NOT explain what you "would need to do" - just do it. Never say "I would need to +query..." - just query it. # Behavior Guidelines -1. **Act, then explain**: Call tools first, then explain results. Don't describe what you would do - do it. -2. **Be scientifically accurate**: Base interpretations on actual developmental biology, not speculation +1. **Act, then explain**: Call tools first, then explain results. Don't describe what you + would do - do it. +2. **Be scientifically accurate**: Base interpretations on actual developmental biology, + not speculation 3. **Prioritize sample health**: Always minimize photobleaching and photodamage -4. **Respect embryo roles**: Every embryo line shows `[role=TEST]`, `[role=CALIBRATION]`, or `[role=UNASSIGNED]`. Calibrate / sweep / classify on CALIBRATION embryos; conserve photodose on TEST. Never suggest calibrating against a TEST embryo (see Embryo Roles section). +4. **Respect embryo roles**: Every embryo line shows `[role=TEST]`, `[role=CALIBRATION]`, + or `[role=UNASSIGNED]`. Calibrate / sweep / classify on CALIBRATION embryos; conserve + photodose on TEST. Never suggest calibrating against a TEST embryo (see Embryo Roles + section). 5. **Use proper terminology**: Refer to embryos by ID, nickname, or user label naturally 6. **Track temporal context**: Remember what you've seen in recent images when analyzing new data 6. **Generate safe plans**: Always validate parameters are within hardware limits 7. **Be conversational**: You're a scientific colleague, not a robot -8. **Stop after success**: When a tool returns a success message (starts with ✓), do NOT retry. Report success and wait for next request. -9. **Single tool = complete action**: Tools like capture_lightsheet, view_image, and acquire_volume are COMPLETE actions. Do NOT chain them unless explicitly asked. -10. **Use defaults**: If a tool has default parameters and the user doesn't specify values, use the defaults. -11. **ALWAYS use ask_user_choice**: When asking the user ANY question with selectable answers, MUST use the `ask_user_choice` tool. NEVER list options as text. This is the #1 UX rule. +8. **Stop after success**: When a tool returns a success message (starts with ✓), do NOT + retry. Report success and wait for next request. +9. **Single tool = complete action**: Tools like capture_lightsheet, view_image, and + acquire_volume are COMPLETE actions. Do NOT chain them unless explicitly asked. +10. **Use defaults**: If a tool has default parameters and the user doesn't specify values, + use the defaults. +11. **ALWAYS use ask_user_choice**: When asking the user ANY question with selectable + answers, MUST use the `ask_user_choice` tool. NEVER list options as text. This is the + #1 UX rule. # Embryo Naming @@ -521,7 +547,7 @@ def build_system_prompt( """ -def build_context_message(experiment_state: ExperimentState) -> Dict: +def build_context_message(experiment_state: ExperimentState) -> dict: """ Build context message with current experiment state @@ -539,5 +565,7 @@ def build_context_message(experiment_state: ExperimentState) -> Dict: """ return { "role": "user", - "content": f"[System update - current experiment state]\n\n{experiment_state.get_summary()}" + "content": ( + f"[System update - current experiment state]\n\n{experiment_state.get_summary()}" + ), } diff --git a/gently/harness/protocols.py b/gently/harness/protocols.py index 9b8b29e9..eb2e811a 100644 --- a/gently/harness/protocols.py +++ b/gently/harness/protocols.py @@ -10,7 +10,7 @@ from gently.harness.protocols import MicroscopeClientProtocol """ -from typing import Protocol, runtime_checkable, Dict, List, Set, Tuple, Optional +from typing import Protocol, runtime_checkable @runtime_checkable @@ -24,11 +24,11 @@ class OrganismProtocol(Protocol): ORGANISM_NAME: str ORGANISM_DISPLAY_NAME: str - SAMPLE_TERM: str # "embryo", "cell", "organoid" + SAMPLE_TERM: str # "embryo", "cell", "organoid" SAMPLE_TERM_PLURAL: str STAGES: list TERMINAL_STAGES: set - BIOLOGY_KNOWLEDGE: str # Markdown text for LLM context + BIOLOGY_KNOWLEDGE: str # Markdown text for LLM context PERCEPTION_SYSTEM_PROMPT: str @@ -55,10 +55,10 @@ class HardwareProtocol(Protocol): HARDWARE_NAME: str HARDWARE_DISPLAY_NAME: str - HARDWARE_DESCRIPTION: str # Markdown text for LLM context - CAPABILITIES: set # Set of capability strings + HARDWARE_DESCRIPTION: str # Markdown text for LLM context + CAPABILITIES: set # Set of capability strings # Backward-compat alias — the Microscope base class in harness/microscope.py # replaces this Protocol. Import from there for new code. -from .microscope import Microscope as MicroscopeClientProtocol # noqa: F401 +from .microscope import Microscope as MicroscopeClientProtocol # noqa: E402, F401 diff --git a/gently/harness/resolution_mode/prompt.py b/gently/harness/resolution_mode/prompt.py index 6b3d0e5e..ad4ae321 100644 --- a/gently/harness/resolution_mode/prompt.py +++ b/gently/harness/resolution_mode/prompt.py @@ -13,11 +13,8 @@ and call one of the resolution lifecycle tools to record it. """ -from typing import Optional - -from gently.organisms import get_organism from gently.hardware import get_hardware - +from gently.organisms import get_organism RESOLUTION_MODE_IDENTITY = """\ You're in **session resolution** — figure out what the researcher @@ -110,8 +107,8 @@ def build_resolution_prompt( - context_summary: Optional[str] = None, - memory_awareness: Optional[str] = None, + context_summary: str | None = None, + memory_awareness: str | None = None, ) -> str: """ Build the system prompt for resolution mode. diff --git a/gently/harness/roles.py b/gently/harness/roles.py index 8708f817..beaa59a4 100644 --- a/gently/harness/roles.py +++ b/gently/harness/roles.py @@ -20,7 +20,6 @@ """ from dataclasses import dataclass -from typing import Dict, List, Optional @dataclass(frozen=True) @@ -29,10 +28,11 @@ class EmbryoRole: Frozen so role definitions are immutable references after registry build. """ + name: str description: str default_cadence_seconds: float = 300.0 - detector_name: Optional[str] = None + detector_name: str | None = None photodose_budget_multiplier: float = 1.0 ui_color: str = "#888888" ui_icon: str = "circle" @@ -43,10 +43,10 @@ class EmbryoRole: # drift back; once they're out of view they stay out, so they get # a short threshold. Test embryos can occasionally pop out and # back, so they get a longer one. - no_object_consecutive_terminal: Optional[int] = None + no_object_consecutive_terminal: int | None = None -REGISTRY: Dict[str, EmbryoRole] = { +REGISTRY: dict[str, EmbryoRole] = { "unassigned": EmbryoRole( name="unassigned", description="No role assigned yet — treated like 'test' for safety.", @@ -94,10 +94,7 @@ class EmbryoRole: def get_role(name: str) -> EmbryoRole: """Look up a role by name. Raises KeyError with helpful message.""" if name not in REGISTRY: - raise KeyError( - f"Unknown embryo role: {name!r}. " - f"Available: {sorted(REGISTRY.keys())}" - ) + raise KeyError(f"Unknown embryo role: {name!r}. Available: {sorted(REGISTRY.keys())}") return REGISTRY[name] @@ -105,6 +102,6 @@ def is_valid_role(name: str) -> bool: return name in REGISTRY -def list_roles() -> List[str]: +def list_roles() -> list[str]: """All registered role names, sorted.""" return sorted(REGISTRY.keys()) diff --git a/gently/harness/session/interaction_logger.py b/gently/harness/session/interaction_logger.py index 15c32ce1..1caa1e90 100644 --- a/gently/harness/session/interaction_logger.py +++ b/gently/harness/session/interaction_logger.py @@ -14,12 +14,12 @@ """ import json +import logging import subprocess -from dataclasses import dataclass, field, asdict +from dataclasses import asdict, dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional -import logging +from typing import Any logger = logging.getLogger(__name__) @@ -27,12 +27,13 @@ @dataclass class ToolCallRecord: """Record of a single tool call""" + tool_name: str - tool_input: Dict[str, Any] + tool_input: dict[str, Any] result: str duration_seconds: float is_error: bool = False - error_message: Optional[str] = None + error_message: str | None = None @dataclass @@ -43,6 +44,7 @@ class InteractionRecord: An interaction is one user message and the agent's response, including any tool calls made during that response. """ + # Unique ID for this interaction interaction_id: str @@ -51,44 +53,42 @@ class InteractionRecord: timestamp: datetime # System state snapshot at time of request - system_state: Dict[str, Any] = field(default_factory=dict) + system_state: dict[str, Any] = field(default_factory=dict) # What happened - tool_calls: List[ToolCallRecord] = field(default_factory=list) + tool_calls: list[ToolCallRecord] = field(default_factory=list) assistant_response: str = "" total_duration_seconds: float = 0.0 # Errors - error: Optional[str] = None - error_traceback: Optional[str] = None + error: str | None = None + error_traceback: str | None = None # Correction detection (filled in after next turn) was_corrected: bool = False - correction_prompt: Optional[str] = None - correction_indicators: List[str] = field(default_factory=list) + correction_prompt: str | None = None + correction_indicators: list[str] = field(default_factory=list) # Metadata session_id: str = "" codebase_version: str = "" model: str = "" - def to_dict(self) -> Dict: + def to_dict(self) -> dict: """Serialize to dictionary for JSON storage""" d = asdict(self) # Convert datetime to ISO format - d['timestamp'] = self.timestamp.isoformat() + d["timestamp"] = self.timestamp.isoformat() # Convert tool calls - d['tool_calls'] = [asdict(tc) for tc in self.tool_calls] + d["tool_calls"] = [asdict(tc) for tc in self.tool_calls] return d @classmethod - def from_dict(cls, d: Dict) -> 'InteractionRecord': + def from_dict(cls, d: dict) -> "InteractionRecord": """Deserialize from dictionary""" d = d.copy() - d['timestamp'] = datetime.fromisoformat(d['timestamp']) - d['tool_calls'] = [ - ToolCallRecord(**tc) for tc in d.get('tool_calls', []) - ] + d["timestamp"] = datetime.fromisoformat(d["timestamp"]) + d["tool_calls"] = [ToolCallRecord(**tc) for tc in d.get("tool_calls", [])] return cls(**d) @@ -152,7 +152,7 @@ def __init__( self.log_file = self.logs_dir / f"{session_id}.jsonl" # In-memory buffer of recent interactions (for correction detection) - self._recent_interactions: List[InteractionRecord] = [] + self._recent_interactions: list[InteractionRecord] = [] self._max_recent = 10 # Get codebase version (git commit) @@ -171,7 +171,7 @@ def _get_git_version(self) -> str: capture_output=True, text=True, cwd=str(self.storage_path.parent), - timeout=5 + timeout=5, ) if result.returncode == 0: return result.stdout.strip() @@ -182,7 +182,7 @@ def _get_git_version(self) -> str: def start_interaction( self, user_prompt: str, - system_state: Dict[str, Any], + system_state: dict[str, Any], ) -> InteractionRecord: """ Start recording a new interaction @@ -219,11 +219,11 @@ def record_tool_call( self, interaction: InteractionRecord, tool_name: str, - tool_input: Dict[str, Any], + tool_input: dict[str, Any], result: str, duration_seconds: float, is_error: bool = False, - error_message: Optional[str] = None, + error_message: str | None = None, ): """ Record a tool call within an interaction @@ -268,8 +268,8 @@ def complete_interaction( interaction: InteractionRecord, assistant_response: str, total_duration_seconds: float, - error: Optional[str] = None, - error_traceback: Optional[str] = None, + error: str | None = None, + error_traceback: str | None = None, ): """ Complete and save an interaction record @@ -342,17 +342,13 @@ def _detect_correction(self, current: InteractionRecord): f"(indicators: {indicators_found})" ) - def _save_interaction( - self, - interaction: InteractionRecord, - append: bool = True - ): + def _save_interaction(self, interaction: InteractionRecord, append: bool = True): """Save interaction to JSONL file""" try: if append: # Append to log file - with open(self.log_file, 'a', encoding='utf-8') as f: - f.write(json.dumps(interaction.to_dict()) + '\n') + with open(self.log_file, "a", encoding="utf-8") as f: + f.write(json.dumps(interaction.to_dict()) + "\n") else: # Need to update existing record - rewrite file # This is less efficient but corrections are rare @@ -368,7 +364,7 @@ def _rewrite_with_update(self, updated: InteractionRecord): # Read all interactions interactions = [] try: - with open(self.log_file, 'r', encoding='utf-8') as f: + with open(self.log_file, encoding="utf-8") as f: for line in f: if line.strip(): record = InteractionRecord.from_dict(json.loads(line)) @@ -382,30 +378,30 @@ def _rewrite_with_update(self, updated: InteractionRecord): # Rewrite file try: - with open(self.log_file, 'w', encoding='utf-8') as f: + with open(self.log_file, "w", encoding="utf-8") as f: for record in interactions: - f.write(json.dumps(record.to_dict()) + '\n') + f.write(json.dumps(record.to_dict()) + "\n") except Exception as e: logger.error(f"Failed to rewrite log file: {e}") - def _sanitize_state(self, state: Dict[str, Any]) -> Dict[str, Any]: + def _sanitize_state(self, state: dict[str, Any]) -> dict[str, Any]: """Remove large/sensitive data from state snapshot""" sanitized = {} # Keep summary info - if 'embryos' in state: - sanitized['embryo_count'] = len(state['embryos']) - sanitized['embryo_ids'] = list(state['embryos'].keys()) + if "embryos" in state: + sanitized["embryo_count"] = len(state["embryos"]) + sanitized["embryo_ids"] = list(state["embryos"].keys()) - if 'detectors' in state: - sanitized['detector_count'] = len(state['detectors']) + if "detectors" in state: + sanitized["detector_count"] = len(state["detectors"]) - if 'acquisition_status' in state: - sanitized['acquisition_status'] = state['acquisition_status'] + if "acquisition_status" in state: + sanitized["acquisition_status"] = state["acquisition_status"] return sanitized - def _sanitize_tool_input(self, tool_input: Dict[str, Any]) -> Dict[str, Any]: + def _sanitize_tool_input(self, tool_input: dict[str, Any]) -> dict[str, Any]: """Remove large/binary data from tool input""" sanitized = {} for key, value in tool_input.items(): @@ -425,14 +421,14 @@ def _sanitize_tool_input(self, tool_input: Dict[str, Any]) -> Dict[str, Any]: sanitized[key] = f"[{type(value).__name__}]" return sanitized - def get_session_stats(self) -> Dict[str, Any]: + def get_session_stats(self) -> dict[str, Any]: """Get statistics for current session""" if not self.log_file.exists(): return { - 'total_interactions': 0, - 'corrections': 0, - 'errors': 0, - 'tool_calls': 0, + "total_interactions": 0, + "corrections": 0, + "errors": 0, + "tool_calls": 0, } total = 0 @@ -441,35 +437,35 @@ def get_session_stats(self) -> Dict[str, Any]: tool_calls = 0 try: - with open(self.log_file, 'r', encoding='utf-8') as f: + with open(self.log_file, encoding="utf-8") as f: for line in f: if line.strip(): record = json.loads(line) total += 1 - if record.get('was_corrected'): + if record.get("was_corrected"): corrections += 1 - if record.get('error'): + if record.get("error"): errors += 1 - tool_calls += len(record.get('tool_calls', [])) + tool_calls += len(record.get("tool_calls", [])) except Exception: pass return { - 'total_interactions': total, - 'corrections': corrections, - 'errors': errors, - 'tool_calls': tool_calls, - 'correction_rate': corrections / total if total > 0 else 0, + "total_interactions": total, + "corrections": corrections, + "errors": errors, + "tool_calls": tool_calls, + "correction_rate": corrections / total if total > 0 else 0, } - def load_session_interactions(self) -> List[InteractionRecord]: + def load_session_interactions(self) -> list[InteractionRecord]: """Load all interactions from current session""" if not self.log_file.exists(): return [] interactions = [] try: - with open(self.log_file, 'r', encoding='utf-8') as f: + with open(self.log_file, encoding="utf-8") as f: for line in f: if line.strip(): record = InteractionRecord.from_dict(json.loads(line)) diff --git a/gently/harness/session/manager.py b/gently/harness/session/manager.py index 47ea38d3..b8d12824 100644 --- a/gently/harness/session/manager.py +++ b/gently/harness/session/manager.py @@ -8,7 +8,6 @@ import json import logging import uuid -from typing import Dict, List, Optional logger = logging.getLogger(__name__) @@ -24,7 +23,7 @@ class SessionManager: def __init__(self, store, storage_path): self.store = store self.storage_path = storage_path - self._session_id: Optional[str] = None + self._session_id: str | None = None @property def session_id(self) -> str: @@ -74,31 +73,31 @@ def _resume_session(self, session_id: str, experiment): conversation_history = [] if snapshot: - raw_history = snapshot.get('conversation_history', []) + raw_history = snapshot.get("conversation_history", []) conversation_history = self.sanitize_loaded_messages(raw_history) - experiment_data = snapshot.get('experiment_data', {}) - experiment.active_plan_item_id = experiment_data.get('active_plan_item_id') - embryo_states = experiment_data.get('embryos', {}) + experiment_data = snapshot.get("experiment_data", {}) + experiment.active_plan_item_id = experiment_data.get("active_plan_item_id") + embryo_states = experiment_data.get("embryos", {}) for embryo_id, embryo_data in embryo_states.items(): - pos = embryo_data.get('stage_position', {}) + pos = embryo_data.get("stage_position", {}) experiment.add_embryo( embryo_id=embryo_id, position=pos, - calibration=embryo_data.get('calibration', {}), - user_label=embryo_data.get('user_label'), - uid=embryo_data.get('uid'), + calibration=embryo_data.get("calibration", {}), + user_label=embryo_data.get("user_label"), + uid=embryo_data.get("uid"), ) embryo = experiment.embryos[embryo_id] - embryo.nickname = embryo_data.get('nickname') - embryo.interval_seconds = embryo_data.get('interval_seconds') - embryo.num_slices = embryo_data.get('num_slices', 50) - embryo.exposure_ms = embryo_data.get('exposure_ms', 10.0) - embryo.priority = embryo_data.get('priority', 'normal') - embryo.timepoints_acquired = embryo_data.get('timepoints_acquired', 0) - embryo.should_skip = embryo_data.get('should_skip', False) - embryo.skip_reason = embryo_data.get('skip_reason') + embryo.nickname = embryo_data.get("nickname") + embryo.interval_seconds = embryo_data.get("interval_seconds") + embryo.num_slices = embryo_data.get("num_slices", 50) + embryo.exposure_ms = embryo_data.get("exposure_ms", 10.0) + embryo.priority = embryo_data.get("priority", "normal") + embryo.timepoints_acquired = embryo_data.get("timepoints_acquired", 0) + embryo.should_skip = embryo_data.get("should_skip", False) + embryo.skip_reason = embryo_data.get("skip_reason") # Also load embryos from store's embryo table. FileStore returns # position_coarse / position_fine (with legacy position_x / position_y @@ -106,13 +105,13 @@ def _resume_session(self, session_id: str, experiment): # the resume. store_embryos = self.store.list_embryos(session_id) for e in store_embryos: - eid = e['embryo_id'] + eid = e["embryo_id"] if eid not in experiment.embryos: experiment.add_embryo( embryo_id=eid, - position=e.get('position_coarse') or {}, - position_fine=e.get('position_fine') or {}, - calibration=json.loads(e['calibration']) if e.get('calibration') else {}, + position=e.get("position_coarse") or {}, + position_fine=e.get("position_fine") or {}, + calibration=json.loads(e["calibration"]) if e.get("calibration") else {}, ) self.store.touch_session(session_id) @@ -141,11 +140,14 @@ def save_session(self, experiment, conversation_history, system_prompt) -> bool: if not self._session_id: return False try: - self.store.save_session_snapshot(self._session_id, { - 'conversation_history': self.serialize_messages(conversation_history), - 'experiment_data': experiment.to_dict(), - 'system_prompt': system_prompt, - }) + self.store.save_session_snapshot( + self._session_id, + { + "conversation_history": self.serialize_messages(conversation_history), + "experiment_data": experiment.to_dict(), + "system_prompt": system_prompt, + }, + ) self._sync_embryos_to_db(experiment) self.store.touch_session(self._session_id) return True @@ -158,11 +160,14 @@ def auto_save(self, experiment, conversation_history, system_prompt): if not self._session_id: return try: - self.store.save_session_snapshot(self._session_id, { - 'conversation_history': self.serialize_messages(conversation_history), - 'experiment_data': experiment.to_dict(), - 'system_prompt': system_prompt, - }) + self.store.save_session_snapshot( + self._session_id, + { + "conversation_history": self.serialize_messages(conversation_history), + "experiment_data": experiment.to_dict(), + "system_prompt": system_prompt, + }, + ) self._sync_embryos_to_db(experiment) self.store.touch_session(self._session_id) except Exception: @@ -173,15 +178,16 @@ def _sync_embryos_to_db(self, experiment): for embryo_id, embryo in experiment.embryos.items(): pos = embryo.stage_position or {} self.store.register_embryo( - self._session_id, embryo_id, - embryo_uid=getattr(embryo, 'uid', None), - nickname=getattr(embryo, 'user_label', None), - position_x=pos.get('x'), - position_y=pos.get('y'), + self._session_id, + embryo_id, + embryo_uid=getattr(embryo, "uid", None), + nickname=getattr(embryo, "user_label", None), + position_x=pos.get("x"), + position_y=pos.get("y"), calibration=embryo.calibration, ) - def list_sessions(self) -> List[Dict]: + def list_sessions(self) -> list[dict]: """ List available sessions from FileStore. @@ -192,8 +198,9 @@ def list_sessions(self) -> List[Dict]: """ return self.store.list_sessions() - def resume_session(self, session_id: str, experiment, conversation_mgr, - prompt_mgr_update_fn) -> bool: + def resume_session( + self, session_id: str, experiment, conversation_mgr, prompt_mgr_update_fn + ) -> bool: """ Resume a session (public interface for CLI). @@ -234,7 +241,7 @@ def resume_session(self, session_id: str, experiment, conversation_mgr, # ===== Message Serialization ===== @staticmethod - def sanitize_loaded_messages(messages: List[Dict]) -> List[Dict]: + def sanitize_loaded_messages(messages: list[dict]) -> list[dict]: """Fix conversation history loaded from JSON snapshots. Old snapshots may contain content blocks that were serialized @@ -244,7 +251,7 @@ def sanitize_loaded_messages(messages: List[Dict]) -> List[Dict]: """ clean = [] for msg in messages: - content = msg.get('content') + content = msg.get("content") if content is None: continue if isinstance(content, str): @@ -256,16 +263,16 @@ def sanitize_loaded_messages(messages: List[Dict]) -> List[Dict]: if isinstance(block, dict): valid_blocks.append(block) elif isinstance(block, str): - if block.startswith(('TextBlock(', 'ToolUseBlock(')): + if block.startswith(("TextBlock(", "ToolUseBlock(")): continue valid_blocks.append(block) if valid_blocks: - clean.append({**msg, 'content': valid_blocks}) + clean.append({**msg, "content": valid_blocks}) continue return clean @staticmethod - def serialize_messages(messages: List[Dict]) -> List[Dict]: + def serialize_messages(messages: list[dict]) -> list[dict]: """Convert conversation history to JSON-safe plain dicts. Anthropic SDK returns content blocks as objects (TextBlock, @@ -273,30 +280,31 @@ def serialize_messages(messages: List[Dict]) -> List[Dict]: repr strings. This converts everything to plain dicts so the history round-trips cleanly through JSON. """ + def _block_to_dict(block): if isinstance(block, dict): return block if isinstance(block, str): return block - if hasattr(block, 'model_dump'): + if hasattr(block, "model_dump"): return block.model_dump() - if hasattr(block, 'to_dict'): + if hasattr(block, "to_dict"): return block.to_dict() - if hasattr(block, 'type'): - d = {'type': block.type} - if block.type == 'text' and hasattr(block, 'text'): - d['text'] = block.text - elif block.type == 'tool_use': - d['id'] = getattr(block, 'id', '') - d['name'] = getattr(block, 'name', '') - d['input'] = getattr(block, 'input', {}) + if hasattr(block, "type"): + d = {"type": block.type} + if block.type == "text" and hasattr(block, "text"): + d["text"] = block.text + elif block.type == "tool_use": + d["id"] = getattr(block, "id", "") + d["name"] = getattr(block, "name", "") + d["input"] = getattr(block, "input", {}) return d return str(block) serialized = [] for msg in messages: - content = msg.get('content') + content = msg.get("content") if isinstance(content, list): content = [_block_to_dict(b) for b in content] - serialized.append({**msg, 'content': content}) + serialized.append({**msg, "content": content}) return serialized diff --git a/gently/harness/session/timeline.py b/gently/harness/session/timeline.py index 5d7ea6f0..b0ae7b92 100644 --- a/gently/harness/session/timeline.py +++ b/gently/harness/session/timeline.py @@ -11,12 +11,12 @@ import json import logging import threading -import uuid from collections import deque -from dataclasses import dataclass, field, asdict +from collections.abc import Callable +from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import Any from gently.core.event_bus import Event, EventType, get_event_bus @@ -55,83 +55,84 @@ class TimelineEvent: severity : str Severity level: info | success | warning | error """ + event_id: str event_type: str event_subtype: str timestamp: datetime source: str - session_id: Optional[str] = None # Session this event belongs to - embryo_id: Optional[str] = None - detector_name: Optional[str] = None - timepoint: Optional[int] = None - confidence: Optional[str] = None - data: Dict[str, Any] = field(default_factory=dict) + session_id: str | None = None # Session this event belongs to + embryo_id: str | None = None + detector_name: str | None = None + timepoint: int | None = None + confidence: str | None = None + data: dict[str, Any] = field(default_factory=dict) icon: str = ">" severity: str = "info" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Serialize to dictionary""" return { - 'event_id': self.event_id, - 'event_type': self.event_type, - 'event_subtype': self.event_subtype, - 'timestamp': self.timestamp.isoformat(), - 'source': self.source, - 'session_id': self.session_id, - 'embryo_id': self.embryo_id, - 'detector_name': self.detector_name, - 'timepoint': self.timepoint, - 'confidence': self.confidence, - 'data': self.data, - 'icon': self.icon, - 'severity': self.severity, + "event_id": self.event_id, + "event_type": self.event_type, + "event_subtype": self.event_subtype, + "timestamp": self.timestamp.isoformat(), + "source": self.source, + "session_id": self.session_id, + "embryo_id": self.embryo_id, + "detector_name": self.detector_name, + "timepoint": self.timepoint, + "confidence": self.confidence, + "data": self.data, + "icon": self.icon, + "severity": self.severity, } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> 'TimelineEvent': + def from_dict(cls, d: dict[str, Any]) -> "TimelineEvent": """Deserialize from dictionary""" return cls( - event_id=d['event_id'], - event_type=d['event_type'], - event_subtype=d['event_subtype'], - timestamp=datetime.fromisoformat(d['timestamp']), - source=d.get('source', 'unknown'), - session_id=d.get('session_id'), - embryo_id=d.get('embryo_id'), - detector_name=d.get('detector_name'), - timepoint=d.get('timepoint'), - confidence=d.get('confidence'), - data=d.get('data', {}), - icon=d.get('icon', '>'), - severity=d.get('severity', 'info'), + event_id=d["event_id"], + event_type=d["event_type"], + event_subtype=d["event_subtype"], + timestamp=datetime.fromisoformat(d["timestamp"]), + source=d.get("source", "unknown"), + session_id=d.get("session_id"), + embryo_id=d.get("embryo_id"), + detector_name=d.get("detector_name"), + timepoint=d.get("timepoint"), + confidence=d.get("confidence"), + data=d.get("data", {}), + icon=d.get("icon", ">"), + severity=d.get("severity", "info"), ) @property def short_label(self) -> str: """Short label for timeline display (e.g., 'TL', 'DET')""" - if self.event_type == 'timelapse': - return 'TL' - elif self.event_type == 'detection': - return 'DET' + if self.event_type == "timelapse": + return "TL" + elif self.event_type == "detection": + return "DET" else: - return 'SYS' + return "SYS" @property def description(self) -> str: """Human-readable description of the event""" - if self.event_type == 'timelapse': - if self.event_subtype == 'started': - embryos = self.data.get('embryo_ids', []) + if self.event_type == "timelapse": + if self.event_subtype == "started": + embryos = self.data.get("embryo_ids", []) return f"Started timelapse with {len(embryos)} embryo(s)" - elif self.event_subtype == 'volume_acquired': + elif self.event_subtype == "volume_acquired": return f"{self.embryo_id} @ t={self.timepoint}" - elif self.event_subtype == 'completed': - total = self.data.get('total_timepoints', '?') + elif self.event_subtype == "completed": + total = self.data.get("total_timepoints", "?") return f"Completed ({total} timepoints)" - elif self.event_subtype == 'failed': + elif self.event_subtype == "failed": return f"Failed: {self.data.get('error', 'unknown error')}" - elif self.event_type == 'detection': - detected = self.data.get('detected', False) + elif self.event_type == "detection": + detected = self.data.get("detected", False) status = "Detected" if detected else "Not detected" conf = f" ({self.confidence})" if self.confidence else "" return f"{self.detector_name} on {self.embryo_id} - {status}{conf}" @@ -141,91 +142,91 @@ def description(self) -> str: # Mapping from EventBus EventType to TimelineEvent properties EVENT_MAPPING = { EventType.ACQUISITION_STARTED: { - 'event_type': 'timelapse', - 'event_subtype': 'started', - 'icon': '>', - 'severity': 'info', + "event_type": "timelapse", + "event_subtype": "started", + "icon": ">", + "severity": "info", }, EventType.VOLUME_ACQUIRED: { - 'event_type': 'timelapse', - 'event_subtype': 'volume_acquired', - 'icon': '+', - 'severity': 'success', + "event_type": "timelapse", + "event_subtype": "volume_acquired", + "icon": "+", + "severity": "success", }, EventType.ACQUISITION_COMPLETED: { - 'event_type': 'timelapse', - 'event_subtype': 'completed', - 'icon': '+', - 'severity': 'success', + "event_type": "timelapse", + "event_subtype": "completed", + "icon": "+", + "severity": "success", }, EventType.ACQUISITION_STOPPED: { - 'event_type': 'timelapse', - 'event_subtype': 'stopped', - 'icon': '-', - 'severity': 'info', + "event_type": "timelapse", + "event_subtype": "stopped", + "icon": "-", + "severity": "info", }, EventType.ACQUISITION_FAILED: { - 'event_type': 'timelapse', - 'event_subtype': 'failed', - 'icon': 'x', - 'severity': 'error', + "event_type": "timelapse", + "event_subtype": "failed", + "icon": "x", + "severity": "error", }, EventType.DETECTOR_EVALUATED: { - 'event_type': 'detection', - 'event_subtype': 'evaluated', - 'icon': '?', - 'severity': 'info', + "event_type": "detection", + "event_subtype": "evaluated", + "icon": "?", + "severity": "info", }, EventType.DETECTION_TRIGGERED: { - 'event_type': 'detection', - 'event_subtype': 'triggered', - 'icon': '!', - 'severity': 'success', + "event_type": "detection", + "event_subtype": "triggered", + "icon": "!", + "severity": "success", }, EventType.HATCHING_DETECTED: { - 'event_type': 'detection', - 'event_subtype': 'hatching', - 'icon': '+', - 'severity': 'success', + "event_type": "detection", + "event_subtype": "hatching", + "icon": "+", + "severity": "success", }, # Strategy / experiment view persistence — these were already emitted on # the EventBus but weren't being captured to timeline.jsonl, so the # swimlane view had no event history to replay. EventType.EMBRYO_CADENCE_CHANGED: { - 'event_type': 'timelapse', - 'event_subtype': 'cadence_changed', - 'icon': '~', - 'severity': 'info', + "event_type": "timelapse", + "event_subtype": "cadence_changed", + "icon": "~", + "severity": "info", }, EventType.POWER_RAMP_STEP: { - 'event_type': 'timelapse', - 'event_subtype': 'power_changed', - 'icon': '*', - 'severity': 'info', + "event_type": "timelapse", + "event_subtype": "power_changed", + "icon": "*", + "severity": "info", }, EventType.TRIGGER_FIRED: { - 'event_type': 'timelapse', - 'event_subtype': 'trigger_fired', - 'icon': '<>', - 'severity': 'info', + "event_type": "timelapse", + "event_subtype": "trigger_fired", + "icon": "<>", + "severity": "info", }, EventType.BURST_QUEUED: { - 'event_type': 'timelapse', - 'event_subtype': 'burst_queued', - 'icon': 'q', - 'severity': 'info', + "event_type": "timelapse", + "event_subtype": "burst_queued", + "icon": "q", + "severity": "info", }, EventType.BURST_START: { - 'event_type': 'timelapse', - 'event_subtype': 'burst_started', - 'icon': '^', - 'severity': 'info', + "event_type": "timelapse", + "event_subtype": "burst_started", + "icon": "^", + "severity": "info", }, EventType.BURST_COMPLETE: { - 'event_type': 'timelapse', - 'event_subtype': 'burst_completed', - 'icon': 'v', - 'severity': 'success', + "event_type": "timelapse", + "event_subtype": "burst_completed", + "icon": "v", + "severity": "success", }, } @@ -243,9 +244,9 @@ class TimelineManager: def __init__( self, - storage_path: Optional[Path] = None, + storage_path: Path | None = None, max_events: int = 1000, - session_id: Optional[str] = None, + session_id: str | None = None, ): """ Parameters @@ -262,7 +263,7 @@ def __init__( self._session_id = session_id self._events: deque[TimelineEvent] = deque(maxlen=max_events) self._lock = threading.RLock() - self._unsubscribers: List[Callable] = [] + self._unsubscribers: list[Callable] = [] self._started = False # Load existing events from storage @@ -274,7 +275,7 @@ def set_session_id(self, session_id: str) -> None: self._session_id = session_id @property - def storage_file(self) -> Optional[Path]: + def storage_file(self) -> Path | None: """Path to the timeline JSONL file""" if self._storage_path: return self._storage_path / "timeline.jsonl" @@ -317,18 +318,18 @@ def _on_event(self, event: Event) -> None: timeline_event = TimelineEvent( event_id=event.event_id, - event_type=mapping['event_type'], - event_subtype=mapping['event_subtype'], + event_type=mapping["event_type"], + event_subtype=mapping["event_subtype"], timestamp=event.timestamp, source=event.source, session_id=self._session_id, # Tag with current session - embryo_id=data.get('embryo_id'), - detector_name=data.get('detector_name'), - timepoint=data.get('timepoint'), - confidence=data.get('confidence'), + embryo_id=data.get("embryo_id"), + detector_name=data.get("detector_name"), + timepoint=data.get("timepoint"), + confidence=data.get("confidence"), data=data, - icon=mapping['icon'], - severity=mapping['severity'], + icon=mapping["icon"], + severity=mapping["severity"], ) self.add_event(timeline_event) @@ -352,13 +353,13 @@ def add_event(self, event: TimelineEvent) -> None: def get_events( self, - event_type: Optional[str] = None, - embryo_id: Optional[str] = None, - since: Optional[datetime] = None, - until: Optional[datetime] = None, - session_id: Optional[str] = "current", + event_type: str | None = None, + embryo_id: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + session_id: str | None = "current", limit: int = 50, - ) -> List[TimelineEvent]: + ) -> list[TimelineEvent]: """ Get filtered events from timeline @@ -405,7 +406,7 @@ def get_events( # Return limited, oldest first (chronological) return events[-limit:] if len(events) > limit else events - def get_time_range(self) -> tuple[Optional[datetime], Optional[datetime]]: + def get_time_range(self) -> tuple[datetime | None, datetime | None]: """ Get the time range of events in the timeline @@ -421,7 +422,7 @@ def get_time_range(self) -> tuple[Optional[datetime], Optional[datetime]]: return events[0].timestamp, events[-1].timestamp - def clear_events(self, before: Optional[datetime] = None) -> int: + def clear_events(self, before: datetime | None = None) -> int: """ Clear events from timeline @@ -443,7 +444,7 @@ def clear_events(self, before: Optional[datetime] = None) -> int: old_count = len(self._events) self._events = deque( (e for e in self._events if e.timestamp >= before), - maxlen=self._max_events + maxlen=self._max_events, ) count = old_count - len(self._events) @@ -460,11 +461,11 @@ def _load_from_file(self) -> None: return try: - with open(self.storage_file, 'r', encoding='utf-8') as f: + with open(self.storage_file, encoding="utf-8") as f: for line in f: line = line.strip() # Only parse lines that look like JSON objects - if line and line.startswith('{'): + if line and line.startswith("{"): try: data = json.loads(line) event = TimelineEvent.from_dict(data) @@ -484,8 +485,8 @@ def _persist_event(self, event: TimelineEvent) -> None: # Ensure directory exists self._storage_path.mkdir(parents=True, exist_ok=True) - with open(self.storage_file, 'a', encoding='utf-8') as f: - f.write(json.dumps(event.to_dict()) + '\n') + with open(self.storage_file, "a", encoding="utf-8") as f: + f.write(json.dumps(event.to_dict()) + "\n") except Exception as e: logger.error(f"Error persisting timeline event: {e}") @@ -500,9 +501,9 @@ def _rewrite_storage(self) -> None: with self._lock: events = list(self._events) - with open(self.storage_file, 'w', encoding='utf-8') as f: + with open(self.storage_file, "w", encoding="utf-8") as f: for event in events: - f.write(json.dumps(event.to_dict()) + '\n') + f.write(json.dumps(event.to_dict()) + "\n") except Exception as e: logger.error(f"Error rewriting timeline storage: {e}") @@ -512,7 +513,7 @@ def __len__(self) -> int: return len(self._events) -def parse_time_delta(s: str) -> Optional[timedelta]: +def parse_time_delta(s: str) -> timedelta | None: """ Parse a time delta string like "1h", "30m", "2d" @@ -531,13 +532,13 @@ def parse_time_delta(s: str) -> Optional[timedelta]: return None try: - if s.endswith('m'): + if s.endswith("m"): return timedelta(minutes=int(s[:-1])) - elif s.endswith('h'): + elif s.endswith("h"): return timedelta(hours=int(s[:-1])) - elif s.endswith('d'): + elif s.endswith("d"): return timedelta(days=int(s[:-1])) - elif s.endswith('w'): + elif s.endswith("w"): return timedelta(weeks=int(s[:-1])) else: # Try parsing as minutes diff --git a/gently/harness/state.py b/gently/harness/state.py index a52f7b79..e5b275ab 100644 --- a/gently/harness/state.py +++ b/gently/harness/state.py @@ -27,19 +27,20 @@ import logging import re +from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime -from typing import Any, Callable, Dict, List, Optional, Tuple -from pathlib import Path -import numpy as np +from typing import Any -logger = logging.getLogger(__name__) +import numpy as np # Re-export CalibrationPrior from its hardware-specific home for backward compat. # CalibrationPrior is diSPIM-specific (piezo-galvo linear fit). Other hardware # modules will define their own calibration models. from gently.hardware.dispim.calibration import CalibrationPrior +logger = logging.getLogger(__name__) + @dataclass class FocusDataPoint: @@ -59,13 +60,14 @@ class FocusDataPoint: - z: primary focus axis (µm) — piezo for diSPIM, Z-motor for 2P/confocal - secondary_axis: optional second axis — galvo for diSPIM, unused (0.0) for single-axis systems """ - z: float # Primary focus position (µm) + + z: float # Primary focus position (µm) secondary_axis: float # Secondary axis position (galvo deg for diSPIM, 0.0 otherwise) - score: float # Focus quality score (algorithm-dependent) - r_squared: float # Gaussian fit quality (0-1), higher = more reliable - timestamp: datetime # When this measurement was made - method: str # 'calibration', 'fine_focus', 'manual' - algorithm: str = 'fft_bandpass' # Focus algorithm used + score: float # Focus quality score (algorithm-dependent) + r_squared: float # Gaussian fit quality (0-1), higher = more reliable + timestamp: datetime # When this measurement was made + method: str # 'calibration', 'fine_focus', 'manual' + algorithm: str = "fft_bandpass" # Focus algorithm used # Backward-compatible properties for code that uses the old field names @property @@ -76,38 +78,39 @@ def piezo(self) -> float: def galvo(self) -> float: return self.secondary_axis - def to_dict(self) -> Dict: + def to_dict(self) -> dict: """Serialize for JSON storage""" return { - 'z': self.z, - 'secondary_axis': self.secondary_axis, - 'score': self.score, - 'r_squared': self.r_squared, - 'timestamp': self.timestamp.isoformat(), - 'method': self.method, - 'algorithm': self.algorithm, + "z": self.z, + "secondary_axis": self.secondary_axis, + "score": self.score, + "r_squared": self.r_squared, + "timestamp": self.timestamp.isoformat(), + "method": self.method, + "algorithm": self.algorithm, # Backward-compatible keys for existing serialized data - 'galvo': self.secondary_axis, - 'piezo': self.z, + "galvo": self.secondary_axis, + "piezo": self.z, } @classmethod - def from_dict(cls, data: Dict) -> 'FocusDataPoint': + def from_dict(cls, data: dict) -> "FocusDataPoint": """Deserialize from JSON. Handles both old (galvo/piezo) and new (z/secondary_axis) keys.""" return cls( - z=data.get('z', data.get('piezo', 0.0)), - secondary_axis=data.get('secondary_axis', data.get('galvo', 0.0)), - score=data['score'], - r_squared=data['r_squared'], - timestamp=datetime.fromisoformat(data['timestamp']), - method=data['method'], - algorithm=data.get('algorithm', 'fft_bandpass'), + z=data.get("z", data.get("piezo", 0.0)), + secondary_axis=data.get("secondary_axis", data.get("galvo", 0.0)), + score=data["score"], + r_squared=data["r_squared"], + timestamp=datetime.fromisoformat(data["timestamp"]), + method=data["method"], + algorithm=data.get("algorithm", "fft_bandpass"), ) @dataclass class ImageRecord: """Record of a single acquired image/volume""" + embryo_id: str timepoint: int timestamp: datetime @@ -115,8 +118,8 @@ class ImageRecord: max_projection_b64: str # Base64-encoded JPEG for Claude Vision size_kb: float # UID-based data references (new data layer) - volume_uid: Optional[str] = None # UID for volume in DataStore - projection_uid: Optional[str] = None # UID for max projection in DataStore + volume_uid: str | None = None # UID for volume in DataStore + projection_uid: str | None = None # UID for max projection in DataStore @dataclass @@ -125,9 +128,9 @@ class EmbryoState: # Identity id: str # "embryo_1" - uid: Optional[str] = None # Global unique identifier for cross-session tracking - nickname: Optional[str] = None # Agent-assigned: "the fast one" - user_label: Optional[str] = None # User-provided: "control_1" + uid: str | None = None # Global unique identifier for cross-session tracking + nickname: str | None = None # Agent-assigned: "the fast one" + user_label: str | None = None # User-provided: "control_1" # Role key into gently.harness.roles.REGISTRY. Drives cadence policy, # detector selection, photodose budget, UI presentation. Default "test" # is the safe choice — accidental Calibration→Test only over-protects; @@ -139,13 +142,13 @@ class EmbryoState: # later by SPIM-objective alignment). Resolved value is exposed by the # `stage_position` property so downstream motion/perception can stay # agnostic about which stage we're in. - position_coarse: Dict[str, float] = field(default_factory=dict) # {'x': ..., 'y': ...} - position_fine: Dict[str, float] = field(default_factory=dict) # empty until SPIM head alignment - calibration: Dict = field(default_factory=dict) # Galvo/piezo parameters + position_coarse: dict[str, float] = field(default_factory=dict) # {'x': ..., 'y': ...} + position_fine: dict[str, float] = field(default_factory=dict) # empty until SPIM head alignment + calibration: dict = field(default_factory=dict) # Galvo/piezo parameters detection_confidence: float = 0.0 # SAM/detection confidence score (0-1) # Acquisition Parameters (current) - interval_seconds: Optional[float] = None # Per-embryo interval; None = use timelapse default + interval_seconds: float | None = None # Per-embryo interval; None = use timelapse default num_slices: int = 50 exposure_ms: float = 10.0 priority: str = "normal" # high/normal/low @@ -153,13 +156,13 @@ class EmbryoState: # Per-embryo 488 laser power %. None = use device-layer default (no # change at acquire time). Float values are hard-limited at the device # layer by DiSPIMLightSource.POWER_LIMITS_PCT[488] (default 2-6%). - laser_power_488_pct: Optional[float] = None + laser_power_488_pct: float | None = None # Status - last_imaged: Optional[datetime] = None + last_imaged: datetime | None = None timepoints_acquired: int = 0 should_skip: bool = False - skip_reason: Optional[str] = None + skip_reason: str | None = None # Timelapse runtime state (consolidated from former EmbryoAcquisitionState). # Populated/used by TimelapseOrchestrator while this embryo is part of an @@ -169,12 +172,12 @@ class EmbryoState: # is a Phase 9 concern. stop_condition: Any = None is_complete: bool = False - completion_reason: Optional[str] = None + completion_reason: str | None = None error_count: int = 0 - last_error: Optional[str] = None - detection_triggered_at: Optional[int] = None - detection_type: Optional[str] = None - no_object_since_timepoint: Optional[int] = None + last_error: str | None = None + detection_triggered_at: int | None = None + detection_type: str | None = None + no_object_since_timepoint: int | None = None # Count of consecutive "no_object" detections. Reset to 0 whenever # the embryo is detected again. When this crosses the role's # ``no_object_consecutive_terminal`` threshold, the orchestrator @@ -192,58 +195,65 @@ class EmbryoState: # - paused: skip in the due loop (over-budget, manually paused, or # idle during another embryo's burst) cadence_phase: str = "normal" - next_due_at: Optional[datetime] = None + next_due_at: datetime | None = None # Light exposure tracking (for phototoxicity monitoring) exposure_count: int = 0 # Number of imaging events (snaps + volumes) total_exposure_ms: float = 0.0 # Cumulative laser-on time in milliseconds # Analysis Results (cached) - hatching_status: Dict = field(default_factory=dict) + hatching_status: dict = field(default_factory=dict) # {hatched: bool, confidence: str, timepoint: int} - morphology_history: List[Dict] = field(default_factory=list) + morphology_history: list[dict] = field(default_factory=list) # [{timepoint, size, shape, activity_score}] - fluorescence_history: List[Dict] = field(default_factory=list) + fluorescence_history: list[dict] = field(default_factory=list) # [{timepoint, mean_intensity, photobleaching_estimate}] - custom_classifications: Dict = field(default_factory=dict) + custom_classifications: dict = field(default_factory=dict) # User-defined: {"first_cleavage": {detected: bool, timepoint: 42}} # Verification round tracking (for consecutive confirmation) pending_verification: bool = False # True when detection fired, awaiting verification consecutive_detection_count: int = 0 # Must reach 5 consecutive verified detections to stop - last_detection_round: Optional[int] = None # Round when detection was last verified + last_detection_round: int | None = None # Round when detection was last verified # Detection results from detector system - detection_results: Dict[str, List[Dict]] = field(default_factory=dict) + detection_results: dict[str, list[dict]] = field(default_factory=dict) # detector_name -> list of detection results # e.g., {"comma_stage": [{"timepoint": 120, "detected": False, "confidence": "HIGH"}, ...]} # CV Subagent analysis results (populated from CV_RESULT_READY events) - cv_analyses: Dict[str, List[Dict]] = field(default_factory=dict) + cv_analyses: dict[str, list[dict]] = field(default_factory=dict) # result_type -> list of results by timepoint # e.g., {"nuclei_count": [{"timepoint": 5, "num_nuclei": 66, ...}]} # Quick-access fields for latest CV results (for /embryos display) - latest_nuclei_count: Optional[int] = None - latest_developmental_stage: Optional[str] = None - latest_elongation_ratio: Optional[float] = None + latest_nuclei_count: int | None = None + latest_developmental_stage: str | None = None + latest_elongation_ratio: float | None = None # Images (recent for context) - recent_images: List[ImageRecord] = field(default_factory=list) + recent_images: list[ImageRecord] = field(default_factory=list) # Keep last 10 for temporal context in Claude Vision calls # Focus history - accumulated piezo-galvo measurements over time - focus_history: List[FocusDataPoint] = field(default_factory=list) + focus_history: list[FocusDataPoint] = field(default_factory=list) # Each focus operation adds a datapoint, building a focus map for this embryo - def add_focus_datapoint(self, z: float = None, secondary_axis: float = 0.0, - score: float = 0.0, r_squared: float = 0.0, - method: str = 'manual', algorithm: str = 'fft_bandpass', - # Backward-compatible kwargs - galvo: float = None, piezo: float = None): + def add_focus_datapoint( + self, + z: float = None, + secondary_axis: float = 0.0, + score: float = 0.0, + r_squared: float = 0.0, + method: str = "manual", + algorithm: str = "fft_bandpass", + # Backward-compatible kwargs + galvo: float = None, + piezo: float = None, + ): """ Record a focus measurement for this embryo. @@ -276,19 +286,24 @@ def add_focus_datapoint(self, z: float = None, secondary_axis: float = 0.0, if galvo is not None: secondary_axis = galvo - self.focus_history.append(FocusDataPoint( - z=z, - secondary_axis=secondary_axis, - score=score, - r_squared=r_squared, - timestamp=datetime.now(), - method=method, - algorithm=algorithm, - )) - - def get_focus_at_secondary(self, secondary_position: float, - max_age_hours: Optional[float] = None, - min_r_squared: float = 0.5) -> Optional[float]: + self.focus_history.append( + FocusDataPoint( + z=z, + secondary_axis=secondary_axis, + score=score, + r_squared=r_squared, + timestamp=datetime.now(), + method=method, + algorithm=algorithm, + ) + ) + + def get_focus_at_secondary( + self, + secondary_position: float, + max_age_hours: float | None = None, + min_r_squared: float = 0.5, + ) -> float | None: """ Get the best Z position for a given secondary axis position. @@ -334,35 +349,38 @@ def get_focus_at_secondary(self, secondary_position: float, axis_distance = abs(fp.secondary_axis - secondary_position) age_hours = (now - fp.timestamp).total_seconds() / 3600 - candidates.append({ - 'z': fp.z, - 'axis_distance': axis_distance, - 'age_hours': age_hours, - 'r_squared': fp.r_squared, - }) + candidates.append( + { + "z": fp.z, + "axis_distance": axis_distance, + "age_hours": age_hours, + "r_squared": fp.r_squared, + } + ) if not candidates: return None # If we have exact matches, use the most recent - exact_matches = [c for c in candidates if c['axis_distance'] < 0.01] + exact_matches = [c for c in candidates if c["axis_distance"] < 0.01] if exact_matches: # Sort by recency, return most recent - exact_matches.sort(key=lambda x: x['age_hours']) - return exact_matches[0]['z'] + exact_matches.sort(key=lambda x: x["age_hours"]) + return exact_matches[0]["z"] # Otherwise, interpolate from nearby measurements # Sort by axis distance - candidates.sort(key=lambda x: x['axis_distance']) - return candidates[0]['z'] # Return closest match + candidates.sort(key=lambda x: x["axis_distance"]) + return candidates[0]["z"] # Return closest match # Backward-compatible alias - def get_focus_at_galvo(self, galvo_position: float, **kwargs) -> Optional[float]: + def get_focus_at_galvo(self, galvo_position: float, **kwargs) -> float | None: """Backward-compatible alias for get_focus_at_secondary.""" return self.get_focus_at_secondary(galvo_position, **kwargs) - def get_z_axis_fit(self, max_age_hours: Optional[float] = None, - min_r_squared: float = 0.5) -> Optional[Tuple[float, float]]: + def get_z_axis_fit( + self, max_age_hours: float | None = None, min_r_squared: float = 0.5 + ) -> tuple[float, float] | None: """ Fit a linear relationship between Z and secondary axis from accumulated data. @@ -415,13 +433,16 @@ def get_z_axis_fit(self, max_age_hours: Optional[float] = None, return None # Backward-compatible alias - def get_piezo_galvo_fit(self, **kwargs) -> Optional[Tuple[float, float]]: + def get_piezo_galvo_fit(self, **kwargs) -> tuple[float, float] | None: """Backward-compatible alias for get_z_axis_fit.""" return self.get_z_axis_fit(**kwargs) - def get_focus_drift_rate(self, secondary_position: float = 0.0, - galvo_position: float = None, - min_measurements: int = 3) -> Optional[float]: + def get_focus_drift_rate( + self, + secondary_position: float = 0.0, + galvo_position: float = None, + min_measurements: int = 3, + ) -> float | None: """ Calculate how fast focus is drifting (µm/hour) at a given secondary axis position. @@ -442,8 +463,11 @@ def get_focus_drift_rate(self, secondary_position: float = 0.0, if galvo_position is not None: secondary_position = galvo_position # Get measurements at similar secondary axis position - relevant = [fp for fp in self.focus_history - if abs(fp.secondary_axis - secondary_position) < 0.1 and fp.r_squared >= 0.5] + relevant = [ + fp + for fp in self.focus_history + if abs(fp.secondary_axis - secondary_position) < 0.1 and fp.r_squared >= 0.5 + ] if len(relevant) < min_measurements: return None @@ -470,9 +494,12 @@ def get_focus_drift_rate(self, secondary_position: float = 0.0, except Exception: return None - def needs_refocus(self, max_age_minutes: float = 60, - secondary_position: float = 0.0, - galvo_position: float = None) -> bool: + def needs_refocus( + self, + max_age_minutes: float = 60, + secondary_position: float = 0.0, + galvo_position: float = None, + ) -> bool: """ Determine if this embryo needs focus re-measurement. @@ -523,7 +550,8 @@ def get_focus_summary(self) -> str: lines = [ f"Focus history: {n_points} measurements over {span_hours:.1f} hours", - f"Latest: z={last.z:.2f}µm @ secondary={last.secondary_axis:.2f} (R²={last.r_squared:.3f})", + f"Latest: z={last.z:.2f}µm @ secondary={last.secondary_axis:.2f}" + f" (R²={last.r_squared:.3f})", ] if drift is not None: @@ -536,7 +564,7 @@ def get_focus_summary(self) -> str: return "\n".join(lines) - def add_detection_result(self, detector_name: str, result: Dict): + def add_detection_result(self, detector_name: str, result: dict): """ Add detection result from detector system @@ -552,7 +580,7 @@ def add_detection_result(self, detector_name: str, result: Dict): self.detection_results[detector_name].append(result) - def get_latest_detection(self, detector_name: str) -> Optional[Dict]: + def get_latest_detection(self, detector_name: str) -> dict | None: """Get most recent detection result for a detector""" if detector_name not in self.detection_results: return None @@ -581,15 +609,15 @@ def was_detected(self, detector_name: str, require_verified: bool = False) -> bo return False for result in self.detection_results[detector_name]: - if result.get('detected', False): + if result.get("detected", False): if require_verified: - if result.get('verified', False): + if result.get("verified", False): return True else: return True return False - def mark_detection_verified(self, detector_name: str, timepoint: Optional[int] = None) -> bool: + def mark_detection_verified(self, detector_name: str, timepoint: int | None = None) -> bool: """ Mark a detection result as verified by the challenger system. @@ -616,19 +644,19 @@ def mark_detection_verified(self, detector_name: str, timepoint: Optional[int] = if timepoint is not None: # Find by timepoint for result in results: - if result.get('timepoint') == timepoint and result.get('detected', False): - result['verified'] = True + if result.get("timepoint") == timepoint and result.get("detected", False): + result["verified"] = True return True else: # Mark the most recent detected result for result in reversed(results): - if result.get('detected', False): - result['verified'] = True + if result.get("detected", False): + result["verified"] = True return True return False - def add_cv_result(self, result_type: str, result: Dict): + def add_cv_result(self, result_type: str, result: dict): """ Add CV analysis result from CV subagent. @@ -643,8 +671,8 @@ def add_cv_result(self, result_type: str, result: Dict): self.cv_analyses[result_type] = [] # Add timestamp if not present - if 'timestamp' not in result: - result['timestamp'] = datetime.now().isoformat() + if "timestamp" not in result: + result["timestamp"] = datetime.now().isoformat() self.cv_analyses[result_type].append(result) @@ -656,11 +684,7 @@ def add_cv_result(self, result_type: str, result: Dict): elif result_type == "elongation" and "elongation_ratio" in result: self.latest_elongation_ratio = result["elongation_ratio"] - def get_cv_result( - self, - result_type: str, - timepoint: Optional[int] = None - ) -> Optional[Dict]: + def get_cv_result(self, result_type: str, timepoint: int | None = None) -> dict | None: """ Get CV analysis result, optionally filtered by timepoint. @@ -691,7 +715,7 @@ def get_cv_result( # Return most recent return results[-1] - def get_cv_summary(self) -> Dict: + def get_cv_summary(self) -> dict: """ Get summary of CV analysis results for display. @@ -705,27 +729,27 @@ def get_cv_summary(self) -> Dict: "developmental_stage": self.latest_developmental_stage, "elongation_ratio": self.latest_elongation_ratio, "analyses_count": { - result_type: len(results) - for result_type, results in self.cv_analyses.items() + result_type: len(results) for result_type, results in self.cv_analyses.items() }, } - def update_from_analysis(self, analysis_result: Dict): + def update_from_analysis(self, analysis_result: dict): """Update state with new analysis""" - if 'hatching' in analysis_result: - self.hatching_status = analysis_result['hatching'] + if "hatching" in analysis_result: + self.hatching_status = analysis_result["hatching"] - if 'morphology' in analysis_result: - self.morphology_history.append({ - 'timepoint': self.timepoints_acquired, - **analysis_result['morphology'] - }) + if "morphology" in analysis_result: + self.morphology_history.append( + {"timepoint": self.timepoints_acquired, **analysis_result["morphology"]} + ) - if 'fluorescence' in analysis_result: - self.fluorescence_history.append({ - 'timepoint': self.timepoints_acquired, - **analysis_result['fluorescence'] - }) + if "fluorescence" in analysis_result: + self.fluorescence_history.append( + { + "timepoint": self.timepoints_acquired, + **analysis_result["fluorescence"], + } + ) def to_summary(self) -> str: """Format for Claude system prompt""" @@ -749,7 +773,7 @@ def to_summary(self) -> str: status_parts.append("not yet imaged") # Status - if self.hatching_status.get('hatched'): + if self.hatching_status.get("hatched"): status_parts.append(f"hatched at t{self.hatching_status['timepoint']:04d}") elif self.should_skip: status_parts.append(f"skipped ({self.skip_reason})") @@ -765,7 +789,12 @@ def to_summary(self) -> str: return " | ".join(status_parts) - def record_exposure(self, exposure_ms: float, num_frames: int = 1, timestamp: Optional[datetime] = None): + def record_exposure( + self, + exposure_ms: float, + num_frames: int = 1, + timestamp: datetime | None = None, + ): """ Record light exposure for phototoxicity tracking. @@ -798,7 +827,7 @@ def get_exposure_summary(self) -> str: return f"{self.exposure_count} exposures, {time_str} total" @property - def stage_position(self) -> Dict[str, float]: + def stage_position(self) -> dict[str, float]: """Resolved XY position — fine if SPIM-aligned, else coarse. Coarse comes from the bottom-camera detection / manual map placement. @@ -810,7 +839,7 @@ def stage_position(self) -> Dict[str, float]: return self.position_fine if self.position_fine else self.position_coarse @stage_position.setter - def stage_position(self, value: Dict[str, float]) -> None: + def stage_position(self, value: dict[str, float]) -> None: """Back-compat setter — writes to coarse. Legacy callers that assigned `embryo.stage_position = {...}` were @@ -824,42 +853,42 @@ def has_fine_position(self) -> bool: """True once SPIM-objective alignment has refined the coarse position.""" return bool(self.position_fine) - def to_dict(self) -> Dict: + def to_dict(self) -> dict: """Serialize for API responses""" return { - 'id': self.id, - 'uid': self.uid, - 'nickname': self.nickname, - 'user_label': self.user_label, - 'role': self.role, - 'stage_position': self.stage_position, - 'position_coarse': self.position_coarse, - 'position_fine': self.position_fine, - 'has_fine_position': self.has_fine_position, - 'calibration': self.calibration, - 'detection_confidence': self.detection_confidence, - 'interval_seconds': self.interval_seconds, - 'num_slices': self.num_slices, - 'exposure_ms': self.exposure_ms, - 'priority': self.priority, - 'acquisition_mode': self.acquisition_mode, - 'laser_power_488_pct': self.laser_power_488_pct, - 'last_imaged': self.last_imaged.isoformat() if self.last_imaged else None, - 'timepoints_acquired': self.timepoints_acquired, - 'should_skip': self.should_skip, - 'skip_reason': self.skip_reason, - 'exposure_count': self.exposure_count, - 'total_exposure_ms': self.total_exposure_ms, - 'hatching_status': self.hatching_status, - 'pending_verification': self.pending_verification, - 'consecutive_detection_count': self.consecutive_detection_count, - 'last_detection_round': self.last_detection_round, - 'recent_analyses': { - 'morphology': self.morphology_history[-5:] if self.morphology_history else [], - 'fluorescence': self.fluorescence_history[-5:] if self.fluorescence_history else [], - 'custom': self.custom_classifications, + "id": self.id, + "uid": self.uid, + "nickname": self.nickname, + "user_label": self.user_label, + "role": self.role, + "stage_position": self.stage_position, + "position_coarse": self.position_coarse, + "position_fine": self.position_fine, + "has_fine_position": self.has_fine_position, + "calibration": self.calibration, + "detection_confidence": self.detection_confidence, + "interval_seconds": self.interval_seconds, + "num_slices": self.num_slices, + "exposure_ms": self.exposure_ms, + "priority": self.priority, + "acquisition_mode": self.acquisition_mode, + "laser_power_488_pct": self.laser_power_488_pct, + "last_imaged": self.last_imaged.isoformat() if self.last_imaged else None, + "timepoints_acquired": self.timepoints_acquired, + "should_skip": self.should_skip, + "skip_reason": self.skip_reason, + "exposure_count": self.exposure_count, + "total_exposure_ms": self.total_exposure_ms, + "hatching_status": self.hatching_status, + "pending_verification": self.pending_verification, + "consecutive_detection_count": self.consecutive_detection_count, + "last_detection_round": self.last_detection_round, + "recent_analyses": { + "morphology": self.morphology_history[-5:] if self.morphology_history else [], + "fluorescence": self.fluorescence_history[-5:] if self.fluorescence_history else [], + "custom": self.custom_classifications, }, - 'focus_history': [fp.to_dict() for fp in self.focus_history], + "focus_history": [fp.to_dict() for fp in self.focus_history], } @@ -867,17 +896,17 @@ class ExperimentState: """Global experiment state""" def __init__(self): - self.embryos: Dict[str, EmbryoState] = {} - self.start_time: Optional[datetime] = None + self.embryos: dict[str, EmbryoState] = {} + self.start_time: datetime | None = None self.acquisition_status: str = "idle" # idle/running/paused/completed - self.current_plan_name: Optional[str] = None - self.plan_history: List[Dict] = [] - self.metadata: Dict = {} + self.current_plan_name: str | None = None + self.plan_history: list[dict] = [] + self.metadata: dict = {} # Active plan item — set during plan context resolution at startup. # When set, the agent's system prompt includes the full ImagingSpec # so it knows what it's here to do without being told. - self.active_plan_item_id: Optional[str] = None + self.active_plan_item_id: str | None = None # Session-level calibration prior for cross-embryo learning # Updated after each successful calibration, used to initialize subsequent embryos @@ -886,7 +915,7 @@ def __init__(self): # Observer hook — agent wires this at startup to publish EMBRYOS_UPDATE # over the event bus. Kept as a plain callback so this module stays # bus-agnostic. - self.on_embryos_changed: Optional[Callable[[], None]] = None + self.on_embryos_changed: Callable[[], None] | None = None def notify_embryos_changed(self) -> None: """Fire the on_embryos_changed observer if one is wired. @@ -904,10 +933,17 @@ def notify_embryos_changed(self) -> None: except Exception: logger.exception("ExperimentState.on_embryos_changed callback failed") - def add_embryo(self, embryo_id: str, position: Dict = None, - calibration: Dict = None, user_label: Optional[str] = None, - confidence: float = 0.0, uid: Optional[str] = None, - role: str = "test", position_fine: Dict = None): + def add_embryo( + self, + embryo_id: str, + position: dict = None, + calibration: dict = None, + user_label: str | None = None, + confidence: float = 0.0, + uid: str | None = None, + role: str = "test", + position_fine: dict = None, + ): """Register new embryo. ``role`` must be a key in :data:`gently.harness.roles.REGISTRY` @@ -924,6 +960,7 @@ def add_embryo(self, embryo_id: str, position: Dict = None, first acquisition. """ from gently.harness.roles import get_role + get_role(role) # raises KeyError if unknown # Auto-start experiment when first embryo is added @@ -947,6 +984,7 @@ def add_embryo(self, embryo_id: str, position: Dict = None, # decoupled from the event bus until first use. try: from gently.core import EventType, get_event_bus + get_event_bus().publish( event_type=EventType.EMBRYO_DETECTED, data={ @@ -978,7 +1016,7 @@ def assign_nickname(self, embryo_id: str, nickname: str): self.embryos[embryo_id].nickname = nickname self.notify_embryos_changed() - def get_embryo_by_any_name(self, name: str) -> Optional[EmbryoState]: + def get_embryo_by_any_name(self, name: str) -> EmbryoState | None: """Get embryo by ID, nickname, or user label""" # Direct ID match if name in self.embryos: @@ -990,7 +1028,7 @@ def get_embryo_by_any_name(self, name: str) -> Optional[EmbryoState]: return embryo # Try extracting number from name like "embryo 3" -> "embryo_3" - match = re.search(r'(\d+)', name) + match = re.search(r"(\d+)", name) if match: num = int(match.group(1)) # Try simple format first (embryo_3) @@ -1018,7 +1056,7 @@ def get_summary(self) -> str: f"Duration: {hours}h {minutes}m", f"Embryos: {len(self.embryos)}", "", - "Per-embryo status:" + "Per-embryo status:", ] for embryo in sorted(self.embryos.values(), key=lambda e: e.id): @@ -1032,15 +1070,15 @@ def get_summary(self) -> str: return "\n".join(lines) - def to_dict(self) -> Dict: + def to_dict(self) -> dict: """Serialize for API responses""" return { - 'start_time': self.start_time.isoformat() if self.start_time else None, - 'acquisition_status': self.acquisition_status, - 'current_plan_name': self.current_plan_name, - 'active_plan_item_id': self.active_plan_item_id, - 'embryo_count': len(self.embryos), - 'embryos': {eid: e.to_dict() for eid, e in self.embryos.items()}, - 'metadata': self.metadata, - 'calibration_prior': self.calibration_prior.to_dict(), + "start_time": self.start_time.isoformat() if self.start_time else None, + "acquisition_status": self.acquisition_status, + "current_plan_name": self.current_plan_name, + "active_plan_item_id": self.active_plan_item_id, + "embryo_count": len(self.embryos), + "embryos": {eid: e.to_dict() for eid, e in self.embryos.items()}, + "metadata": self.metadata, + "calibration_prior": self.calibration_prior.to_dict(), } diff --git a/gently/harness/tools/helpers.py b/gently/harness/tools/helpers.py index 3d6b03b8..fa98cf62 100644 --- a/gently/harness/tools/helpers.py +++ b/gently/harness/tools/helpers.py @@ -5,11 +5,11 @@ used across multiple tools to reduce code duplication. """ -from typing import Any, Dict, List, Optional, Tuple from datetime import datetime +from typing import Any -def require_agent(context: Dict) -> Tuple[Optional[Any], Optional[str]]: +def require_agent(context: dict) -> tuple[Any | None, str | None]: """ Extract agent from context or return error message @@ -23,13 +23,13 @@ def require_agent(context: Dict) -> Tuple[Optional[Any], Optional[str]]: tuple (agent, None) if found, (None, error_message) if not """ - agent = context.get('agent') + agent = context.get("agent") if not agent: return None, "Error: No agent context" return agent, None -def get_embryo_or_error(agent, embryo_id: str) -> Tuple[Optional[Any], Optional[str]]: +def get_embryo_or_error(agent, embryo_id: str) -> tuple[Any | None, str | None]: """ Get embryo by any name or return error message @@ -51,7 +51,7 @@ def get_embryo_or_error(agent, embryo_id: str) -> Tuple[Optional[Any], Optional[ return embryo, None -def require_microscope(context: Dict) -> Tuple[Optional[Any], Optional[str]]: +def require_microscope(context: dict) -> tuple[Any | None, str | None]: """ Get microscope client from context or return error message @@ -65,13 +65,13 @@ def require_microscope(context: Dict) -> Tuple[Optional[Any], Optional[str]]: tuple (client, None) if connected, (None, error_message) if not """ - client = context.get('client') + client = context.get("client") if not client: return None, "Not connected to microscope. Use connect_microscope first." return client, None -def require_interaction_logger(agent) -> Tuple[Optional[Any], Optional[str]]: +def require_interaction_logger(agent) -> tuple[Any | None, str | None]: """ Get interaction logger or return error message @@ -85,12 +85,12 @@ def require_interaction_logger(agent) -> Tuple[Optional[Any], Optional[str]]: tuple (logger, None) if available, (None, error_message) if not """ - if not hasattr(agent, 'interaction_logger') or not agent.interaction_logger: + if not hasattr(agent, "interaction_logger") or not agent.interaction_logger: return None, "Interaction logging not enabled." return agent.interaction_logger, None -def require_developmental_tracker(agent) -> Tuple[Optional[Any], Optional[str]]: +def require_developmental_tracker(agent) -> tuple[Any | None, str | None]: """ Get developmental tracker or return error message @@ -104,12 +104,15 @@ def require_developmental_tracker(agent) -> Tuple[Optional[Any], Optional[str]]: tuple (tracker, None) if available, (None, error_message) if not """ - if not hasattr(agent, 'developmental_tracker') or not agent.developmental_tracker: - return None, "No stage classifications recorded yet. Use classify_embryo_stage first." + if not hasattr(agent, "developmental_tracker") or not agent.developmental_tracker: + return ( + None, + "No stage classifications recorded yet. Use classify_embryo_stage first.", + ) return agent.developmental_tracker, None -def require_timelapse_orchestrator(agent) -> Tuple[Optional[Any], Optional[str]]: +def require_timelapse_orchestrator(agent) -> tuple[Any | None, str | None]: """ Get timelapse orchestrator or return error message @@ -123,12 +126,12 @@ def require_timelapse_orchestrator(agent) -> Tuple[Optional[Any], Optional[str]] tuple (orchestrator, None) if available, (None, error_message) if not """ - if not hasattr(agent, 'timelapse_orchestrator') or agent.timelapse_orchestrator is None: + if not hasattr(agent, "timelapse_orchestrator") or agent.timelapse_orchestrator is None: return None, "Timelapse orchestrator not initialized." return agent.timelapse_orchestrator, None -def require_databroker(agent) -> Tuple[Optional[Any], Optional[str]]: +def require_databroker(agent) -> tuple[Any | None, str | None]: """ Get databroker connection or return error message @@ -142,7 +145,7 @@ def require_databroker(agent) -> Tuple[Optional[Any], Optional[str]]: tuple (databroker, None) if available, (None, error_message) if not """ - if not hasattr(agent, 'databroker') or agent.databroker is None: + if not hasattr(agent, "databroker") or agent.databroker is None: return None, "No databroker connection. Data persistence not available." return agent.databroker, None @@ -184,13 +187,13 @@ def format_duration(seconds: float) -> str: def build_snapshot_metadata( - stage_position: Tuple[float, float], - image_shape: Tuple[int, ...], + stage_position: tuple[float, float], + image_shape: tuple[int, ...], experiment=None, pixel_size_um: float = 6.5, objective_mag: float = 10.0, - safety_limits: Optional[Dict] = None, -) -> Dict: + safety_limits: dict | None = None, +) -> dict: """Build metadata dict for a bottom camera snapshot. Captures everything needed to reconstruct embryo positions @@ -226,7 +229,7 @@ def build_snapshot_metadata( # gently/hardware/dispim/devices/stage.py::DiSPIMXYStage.__init__. safety_limits = {"x": (2000.0, 4000.0), "y": (-1000.0, 1000.0)} - meta: Dict[str, Any] = { + meta: dict[str, Any] = { "stage_x": stage_position[0], "stage_y": stage_position[1], "image_width": w, @@ -242,15 +245,17 @@ def build_snapshot_metadata( } if experiment and experiment.embryos: - embryos: List[Dict] = [] + embryos: list[dict] = [] for eid, emb in experiment.embryos.items(): pos = emb.stage_position or {} - embryos.append({ - "embryo_id": eid, - "stage_x": pos.get("x"), - "stage_y": pos.get("y"), - "nickname": getattr(emb, "nickname", None), - }) + embryos.append( + { + "embryo_id": eid, + "stage_x": pos.get("x"), + "stage_y": pos.get("y"), + "nickname": getattr(emb, "nickname", None), + } + ) meta["embryos"] = embryos return meta diff --git a/gently/harness/tools/registry.py b/gently/harness/tools/registry.py index 48e1cf4f..30f72cf8 100644 --- a/gently/harness/tools/registry.py +++ b/gently/harness/tools/registry.py @@ -13,49 +13,56 @@ import functools import inspect import logging +import time +from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum, auto from typing import ( - Any, Callable, Dict, List, Optional, Type, Union, - get_type_hints, get_origin, get_args + Any, + Union, + get_args, + get_origin, + get_type_hints, ) -import time logger = logging.getLogger(__name__) class ToolCategory(Enum): """Categories for organizing tools""" - ACQUISITION = auto() # Volume/image acquisition - MOVEMENT = auto() # Stage movement, positioning - CALIBRATION = auto() # Calibration procedures - ANALYSIS = auto() # Image/volume analysis - DETECTION = auto() # Detector management - EXPERIMENT = auto() # Experiment state management - EMBRYO = auto() # Embryo-specific operations - HARDWARE = auto() # Direct hardware control - DATA = auto() # Data/Databroker operations - UTILITY = auto() # Utility functions - ML = auto() # Machine learning training - TRANSFER = auto() # Bulk data transfer + + ACQUISITION = auto() # Volume/image acquisition + MOVEMENT = auto() # Stage movement, positioning + CALIBRATION = auto() # Calibration procedures + ANALYSIS = auto() # Image/volume analysis + DETECTION = auto() # Detector management + EXPERIMENT = auto() # Experiment state management + EMBRYO = auto() # Embryo-specific operations + HARDWARE = auto() # Direct hardware control + DATA = auto() # Data/Databroker operations + UTILITY = auto() # Utility functions + ML = auto() # Machine learning training + TRANSFER = auto() # Bulk data transfer @dataclass class ToolParameter: """Definition of a tool parameter""" + name: str type: str # JSON schema type description: str required: bool = True default: Any = None - enum: Optional[List[str]] = None + enum: list[str] | None = None @dataclass class ToolExample: """Example of when to use a tool""" + user_query: str - tool_input: Dict = field(default_factory=dict) + tool_input: dict = field(default_factory=dict) @dataclass @@ -69,17 +76,18 @@ class ToolDefinition: - Documentation - Filtering/discovery """ + name: str description: str handler: Callable - parameters: List[ToolParameter] = field(default_factory=list) + parameters: list[ToolParameter] = field(default_factory=list) category: ToolCategory = ToolCategory.UTILITY requires_microscope: bool = False is_async: bool = False - tags: List[str] = field(default_factory=list) - examples: List[ToolExample] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + examples: list[ToolExample] = field(default_factory=list) - def to_claude_schema(self) -> Dict: + def to_claude_schema(self) -> dict: """Generate Claude API tool schema with examples embedded in description""" properties = {} required = [] @@ -114,7 +122,7 @@ def to_claude_schema(self) -> Dict: "type": "object", "properties": properties, "required": required, - } + }, } @@ -156,29 +164,29 @@ def _python_type_to_json_schema(python_type) -> str: return "string" -def _extract_parameters_from_function(func: Callable) -> List[ToolParameter]: +def _extract_parameters_from_function(func: Callable) -> list[ToolParameter]: """Extract parameter definitions from function signature and type hints""" sig = inspect.signature(func) - hints = get_type_hints(func) if hasattr(func, '__annotations__') else {} + hints = get_type_hints(func) if hasattr(func, "__annotations__") else {} doc = inspect.getdoc(func) or "" # Parse docstring for parameter descriptions param_docs = {} in_params = False current_param = None - for line in doc.split('\n'): + for line in doc.split("\n"): line = line.strip() - if line.lower().startswith('parameters'): + if line.lower().startswith("parameters"): in_params = True continue if in_params: - if line.startswith('---'): + if line.startswith("---"): continue - if line.lower().startswith('returns'): + if line.lower().startswith("returns"): in_params = False continue - if ' : ' in line: - parts = line.split(' : ') + if " : " in line: + parts = line.split(" : ") current_param = parts[0].strip() param_docs[current_param] = "" elif current_param and line: @@ -187,7 +195,7 @@ def _extract_parameters_from_function(func: Callable) -> List[ToolParameter]: parameters = [] for param_name, param in sig.parameters.items(): # Skip 'self', 'tool_input' (legacy pattern), and 'context' (injected at runtime) - if param_name in ('self', 'tool_input', 'context'): + if param_name in ("self", "tool_input", "context"): continue python_type = hints.get(param_name, str) @@ -200,13 +208,15 @@ def _extract_parameters_from_function(func: Callable) -> List[ToolParameter]: # Get description from docstring description = param_docs.get(param_name, f"The {param_name} parameter").strip() - parameters.append(ToolParameter( - name=param_name, - type=json_type, - description=description, - required=required, - default=default, - )) + parameters.append( + ToolParameter( + name=param_name, + type=json_type, + description=description, + required=required, + default=default, + ) + ) return parameters @@ -223,8 +233,8 @@ class ToolRegistry: """ def __init__(self): - self._tools: Dict[str, ToolDefinition] = {} - self._context: Dict[str, Any] = {} # Shared context (agent, client, etc.) + self._tools: dict[str, ToolDefinition] = {} + self._context: dict[str, Any] = {} # Shared context (agent, client, etc.) def set_context(self, key: str, value: Any): """Set shared context available to all tools""" @@ -236,13 +246,13 @@ def get_context(self, key: str) -> Any: def register( self, - name: Optional[str] = None, - description: Optional[str] = None, + name: str | None = None, + description: str | None = None, category: ToolCategory = ToolCategory.UTILITY, requires_microscope: bool = False, - tags: Optional[List[str]] = None, - parameters: Optional[List[ToolParameter]] = None, - examples: Optional[List[ToolExample]] = None, + tags: list[str] | None = None, + parameters: list[ToolParameter] | None = None, + examples: list[ToolExample] | None = None, ) -> Callable: """ Decorator to register a function as a tool @@ -277,9 +287,10 @@ async def acquire_volume(embryo_id: str, num_slices: int = 50) -> str: examples : list of ToolExample, optional Usage examples showing when to call this tool """ + def decorator(func: Callable) -> Callable: tool_name = name or func.__name__ - tool_desc = description or (inspect.getdoc(func) or "").split('\n')[0] + tool_desc = description or (inspect.getdoc(func) or "").split("\n")[0] # Extract or use provided parameters tool_params = parameters or _extract_parameters_from_function(func) @@ -311,11 +322,11 @@ async def wrapper(*args, **kwargs): def register_function( self, func: Callable, - name: Optional[str] = None, - description: Optional[str] = None, + name: str | None = None, + description: str | None = None, category: ToolCategory = ToolCategory.UTILITY, requires_microscope: bool = False, - tags: Optional[List[str]] = None, + tags: list[str] | None = None, ): """ Register an existing function as a tool (non-decorator form) @@ -336,7 +347,7 @@ def register_function( Additional tags """ tool_name = name or func.__name__ - tool_desc = description or (inspect.getdoc(func) or "").split('\n')[0] + tool_desc = description or (inspect.getdoc(func) or "").split("\n")[0] tool_params = _extract_parameters_from_function(func) tool_def = ToolDefinition( @@ -360,23 +371,23 @@ def unregister(self, name: str) -> bool: return True return False - def get(self, name: str) -> Optional[ToolDefinition]: + def get(self, name: str) -> ToolDefinition | None: """Get tool definition by name""" return self._tools.get(name) - def list_all(self) -> List[ToolDefinition]: + def list_all(self) -> list[ToolDefinition]: """List all registered tools""" return list(self._tools.values()) - def list_by_category(self, category: ToolCategory) -> List[ToolDefinition]: + def list_by_category(self, category: ToolCategory) -> list[ToolDefinition]: """List tools in a category""" return [t for t in self._tools.values() if t.category == category] - def list_by_tag(self, tag: str) -> List[ToolDefinition]: + def list_by_tag(self, tag: str) -> list[ToolDefinition]: """List tools with a specific tag""" return [t for t in self._tools.values() if tag in t.tags] - def list_available(self, has_microscope: bool = False) -> List[ToolDefinition]: + def list_available(self, has_microscope: bool = False) -> list[ToolDefinition]: """List tools available given current context""" tools = [] for tool in self._tools.values(): @@ -385,14 +396,11 @@ def list_available(self, has_microscope: bool = False) -> List[ToolDefinition]: tools.append(tool) return tools - def get_claude_schemas(self, has_microscope: bool = False) -> List[Dict]: + def get_claude_schemas(self, has_microscope: bool = False) -> list[dict]: """Get Claude API tool schemas for available tools""" - return [ - tool.to_claude_schema() - for tool in self.list_available(has_microscope) - ] + return [tool.to_claude_schema() for tool in self.list_available(has_microscope)] - async def execute(self, tool_name: str, tool_input: Dict, context: Dict = None) -> str: + async def execute(self, tool_name: str, tool_input: dict, context: dict = None) -> str: """ Execute a tool by name @@ -420,8 +428,8 @@ async def execute(self, tool_name: str, tool_input: Dict, context: Dict = None) # 3. Fall back to stored registry context if context is not None: exec_context = context - elif 'context' in tool_input and tool_input['context'] is not None: - exec_context = tool_input['context'] + elif "context" in tool_input and tool_input["context"] is not None: + exec_context = tool_input["context"] else: exec_context = self._context @@ -431,17 +439,19 @@ async def execute(self, tool_name: str, tool_input: Dict, context: Dict = None) # them directly. The agent sets these flags around its autonomous turns; # user-driven turns are unaffected. The blocked set is supplied by the # agent so this layer stays free of app-specific tool names. - _agent = exec_context.get('agent') if isinstance(exec_context, dict) else None - if _agent is not None and getattr(_agent, '_autonomous_active', False): - blocked = getattr(_agent, '_autonomous_blocked_tools', None) or () + _agent = exec_context.get("agent") if isinstance(exec_context, dict) else None + if _agent is not None and getattr(_agent, "_autonomous_active", False): + blocked = getattr(_agent, "_autonomous_blocked_tools", None) or () if tool_name in blocked: logger.info("Autonomy backstop blocked '%s' (irreversible)", tool_name) - return (f"'{tool_name}' is an irreversible action and cannot run " - f"autonomously. Ask the operator to confirm it.") + return ( + f"'{tool_name}' is an irreversible action and cannot run " + f"autonomously. Ask the operator to confirm it." + ) # Check microscope requirement if tool.requires_microscope: - client = exec_context.get('client') + client = exec_context.get("client") if client is None: return "Error: Not connected to microscope server. Start the server and reconnect." @@ -453,8 +463,8 @@ async def execute(self, tool_name: str, tool_input: Dict, context: Dict = None) # Inject context if handler expects it (but don't overwrite if already provided) sig = inspect.signature(tool.handler) - if 'context' in sig.parameters and 'context' not in kwargs: - kwargs['context'] = exec_context + if "context" in sig.parameters and "context" not in kwargs: + kwargs["context"] = exec_context # Execute handler if tool.is_async: @@ -469,6 +479,7 @@ async def execute(self, tool_name: str, tool_input: Dict, context: Dict = None) except Exception as e: import traceback + logger.error(f"Tool {tool_name} failed: {e}") return f"Error executing {tool_name}: {str(e)}\n{traceback.format_exc()}" @@ -480,7 +491,7 @@ def __len__(self) -> int: # Global registry instance -_global_registry: Optional[ToolRegistry] = None +_global_registry: ToolRegistry | None = None def get_tool_registry() -> ToolRegistry: @@ -499,12 +510,12 @@ def set_tool_registry(registry: ToolRegistry): # Convenience decorator using global registry def tool( - name: Optional[str] = None, - description: Optional[str] = None, + name: str | None = None, + description: str | None = None, category: ToolCategory = ToolCategory.UTILITY, requires_microscope: bool = False, - tags: Optional[List[str]] = None, - examples: Optional[List[ToolExample]] = None, + tags: list[str] | None = None, + examples: list[ToolExample] | None = None, ) -> Callable: """ Decorator to register a tool with the global registry diff --git a/gently/log_config.py b/gently/log_config.py index 4db29ad1..5d5f75b2 100644 --- a/gently/log_config.py +++ b/gently/log_config.py @@ -9,6 +9,7 @@ GENTLY_LOG_FORMAT — console format string GENTLY_LOG_DATEFMT — timestamp format (default: %H:%M:%S) """ + import logging import os import sys @@ -54,9 +55,18 @@ def configure_logging( lgr.addHandler(console) # Suppress noisy third-party loggers on console - for name in ("uvicorn", "uvicorn.error", "uvicorn.access", - "httpx", "httpcore", "anthropic", "aiohttp", - "aiohttp.access", "bluesky", "bluesky.RE.state"): + for name in ( + "uvicorn", + "uvicorn.error", + "uvicorn.access", + "httpx", + "httpcore", + "anthropic", + "aiohttp", + "aiohttp.access", + "bluesky", + "bluesky.RE.state", + ): logging.getLogger(name).setLevel(logging.WARNING) # File handler — always INFO+ regardless of console level diff --git a/gently/mesh/audit.py b/gently/mesh/audit.py index 06e76729..e4f06746 100644 --- a/gently/mesh/audit.py +++ b/gently/mesh/audit.py @@ -53,7 +53,7 @@ def _count_lines(self): """Count existing lines for rotation tracking.""" if self._log_file.exists(): try: - with open(self._log_file, "r") as f: + with open(self._log_file) as f: self._line_count = sum(1 for _ in f) except OSError: self._line_count = 0 @@ -88,7 +88,7 @@ def log( def _rotate(self): """Keep last KEEP_LINES, discard the rest.""" try: - with open(self._log_file, "r") as f: + with open(self._log_file) as f: lines = f.readlines() keep = lines[-KEEP_LINES:] with open(self._log_file, "w") as f: diff --git a/gently/mesh/capability_provider.py b/gently/mesh/capability_provider.py index 2453dce9..7dfc6fd1 100644 --- a/gently/mesh/capability_provider.py +++ b/gently/mesh/capability_provider.py @@ -8,18 +8,19 @@ import logging import os import platform -from typing import Any, Dict, List, Optional +from typing import Any from .models import DatasetAdvertisement, GpuInfo, PeerRole logger = logging.getLogger(__name__) -def _detect_gpus() -> List[GpuInfo]: +def _detect_gpus() -> list[GpuInfo]: """Detect available NVIDIA GPUs via torch.cuda (pynvml fallback).""" gpus = [] try: import torch + if torch.cuda.is_available(): for i in range(torch.cuda.device_count()): props = torch.cuda.get_device_properties(i) @@ -28,23 +29,26 @@ def _detect_gpus() -> List[GpuInfo]: mem_used_gb = 0.0 try: import pynvml + pynvml.nvmlInit() handle = pynvml.nvmlDeviceGetHandleByIndex(i) util = pynvml.nvmlDeviceGetUtilizationRates(handle) util_pct = float(util.gpu) mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle) - mem_used_gb = mem_info.used / (1024 ** 3) + mem_used_gb = mem_info.used / (1024**3) except Exception: pass - gpus.append(GpuInfo( - device_index=i, - name=props.name, - vram_gb=round(props.total_mem / (1024 ** 3), 1), - compute_capability=f"{props.major}.{props.minor}", - utilization_pct=util_pct, - memory_used_gb=round(mem_used_gb, 2), - )) + gpus.append( + GpuInfo( + device_index=i, + name=props.name, + vram_gb=round(props.total_mem / (1024**3), 1), + compute_capability=f"{props.major}.{props.minor}", + utilization_pct=util_pct, + memory_used_gb=round(mem_used_gb, 2), + ) + ) except ImportError: pass except Exception as e: @@ -52,13 +56,14 @@ def _detect_gpus() -> List[GpuInfo]: return gpus -def _get_system_info() -> Dict[str, Any]: +def _get_system_info() -> dict[str, Any]: """Get CPU and RAM info.""" cpu_cores = os.cpu_count() or 0 ram_gb = 0.0 try: if platform.system() == "Windows": import ctypes + kernel32 = ctypes.windll.kernel32 mem_status = ctypes.c_ulonglong() kernel32.GetPhysicallyInstalledSystemMemory(ctypes.byref(mem_status)) @@ -92,12 +97,12 @@ def __init__( self, gently_store=None, device_layer=None, - static_caps: Optional[Dict[str, Any]] = None, + static_caps: dict[str, Any] | None = None, ): self._store = gently_store self._device_layer = device_layer self._static = static_caps or {} - self._gpus: List[GpuInfo] = [] + self._gpus: list[GpuInfo] = [] self._system_info = _get_system_info() # Initial GPU detection (cached, refreshed on demand) self._gpus = _detect_gpus() @@ -106,7 +111,7 @@ def refresh_gpus(self): """Re-detect GPUs (call periodically for live utilization).""" self._gpus = _detect_gpus() - def _compute_roles(self) -> List[str]: + def _compute_roles(self) -> list[str]: """Determine dynamic roles based on current state.""" roles = [] # Microscope controller if device is connected and responding @@ -131,7 +136,7 @@ def _compute_roles(self) -> List[str]: roles.append(PeerRole.PLANNER.value) return roles - def _get_datasets(self) -> List[DatasetAdvertisement]: + def _get_datasets(self) -> list[DatasetAdvertisement]: """Query FileStore for dataset advertisements.""" if self._store is None: return [] @@ -164,15 +169,17 @@ def _get_datasets(self) -> List[DatasetAdvertisement]: except Exception: pass - datasets.append(DatasetAdvertisement( - session_id=sid, - session_name=sname, - embryo_count=embryo_count, - volume_count=vol_count, - has_ground_truth=gt_count > 0, - ground_truth_count=gt_count, - stages_covered=sorted(stages), - )) + datasets.append( + DatasetAdvertisement( + session_id=sid, + session_name=sname, + embryo_count=embryo_count, + volume_count=vol_count, + has_ground_truth=gt_count > 0, + ground_truth_count=gt_count, + stages_covered=sorted(stages), + ) + ) except Exception as e: logger.debug(f"Dataset advertisement failed: {e}") return datasets @@ -186,7 +193,7 @@ def _is_microscope_connected(self) -> bool: pass return False - def __call__(self) -> Dict[str, Any]: + def __call__(self) -> dict[str, Any]: """Build the full capability dict. Called on each heartbeat.""" datasets = self._get_datasets() roles = self._compute_roles() @@ -199,10 +206,12 @@ def __call__(self) -> Dict[str, Any]: storage_total_gb = 0.0 try: import shutil + from ..settings import settings + usage = shutil.disk_usage(str(settings.storage.base_path)) - storage_free_gb = round(usage.free / (1024 ** 3), 1) - storage_total_gb = round(usage.total / (1024 ** 3), 1) + storage_free_gb = round(usage.free / (1024**3), 1) + storage_total_gb = round(usage.total / (1024**3), 1) except Exception: pass diff --git a/gently/mesh/discovery.py b/gently/mesh/discovery.py index 29fecd4a..878a9b81 100644 --- a/gently/mesh/discovery.py +++ b/gently/mesh/discovery.py @@ -15,7 +15,7 @@ import logging import socket import time -from typing import Callable, Optional +from collections.abc import Callable from ..settings import settings @@ -74,7 +74,7 @@ def __init__( self._pairing_manager = pairing_manager self._audit_log = audit_log self._known_ids: set = set() - self.transport: Optional[asyncio.DatagramTransport] = None + self.transport: asyncio.DatagramTransport | None = None def connection_made(self, transport: asyncio.DatagramTransport): self.transport = transport @@ -101,9 +101,12 @@ def datagram_received(self, data: bytes, addr: tuple): logger.debug(f"Mesh: rejected stale packet from {peer_id[:8]} (ts={ts})") if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.REPLAY_REJECTED, outcome="deny", - peer_id=peer_id, ip=sender_ip, + AuditEvent.REPLAY_REJECTED, + outcome="deny", + peer_id=peer_id, + ip=sender_ip, detail=f"ts_delta={abs(time.time() - ts):.1f}s", ) return @@ -120,9 +123,12 @@ def datagram_received(self, data: bytes, addr: tuple): logger.debug(f"Mesh: bad signature from {peer_id[:8]}") if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.SIG_INVALID, outcome="deny", - peer_id=peer_id, ip=sender_ip, + AuditEvent.SIG_INVALID, + outcome="deny", + peer_id=peer_id, + ip=sender_ip, ) if msg_type == "nudge": @@ -138,7 +144,7 @@ def datagram_received(self, data: bytes, addr: tuple): def error_received(self, exc: Exception): logger.debug(f"Mesh UDP error: {exc}") - def connection_lost(self, exc: Optional[Exception]): + def connection_lost(self, exc: Exception | None): pass def forget_peer(self, instance_id: str): @@ -169,9 +175,9 @@ def __init__( self._pairing_manager = pairing_manager self._audit_log = audit_log - self._protocol: Optional[_MeshProtocol] = None - self._transport: Optional[asyncio.DatagramTransport] = None - self._broadcast_task: Optional[asyncio.Task] = None + self._protocol: _MeshProtocol | None = None + self._transport: asyncio.DatagramTransport | None = None + self._broadcast_task: asyncio.Task | None = None self._running = False # Callbacks — set by MeshService before start() @@ -213,8 +219,7 @@ async def start(self): self._running = True self._broadcast_task = asyncio.create_task(self._broadcast_loop()) logger.info( - f"Mesh discovery started on port {self.mesh_port} " - f"(instance={self.instance_id[:8]})" + f"Mesh discovery started on port {self.mesh_port} (instance={self.instance_id[:8]})" ) async def stop(self): @@ -259,9 +264,7 @@ def send_nudge(self): packet = json.dumps(payload).encode("utf-8") try: - self._transport.sendto( - packet, ("255.255.255.255", self.mesh_port) - ) + self._transport.sendto(packet, ("255.255.255.255", self.mesh_port)) except OSError as e: logger.debug(f"Mesh nudge broadcast failed: {e}") @@ -286,9 +289,7 @@ async def _broadcast_loop(self): try: if self._transport: - self._transport.sendto( - heartbeat, ("255.255.255.255", self.mesh_port) - ) + self._transport.sendto(heartbeat, ("255.255.255.255", self.mesh_port)) except OSError as e: logger.debug(f"Mesh broadcast failed: {e}") diff --git a/gently/mesh/mesh_service.py b/gently/mesh/mesh_service.py index edac242c..3a4a7e52 100644 --- a/gently/mesh/mesh_service.py +++ b/gently/mesh/mesh_service.py @@ -11,12 +11,13 @@ import asyncio import logging import time +from collections.abc import Callable from pathlib import Path -from typing import Callable, Dict, List, Optional from gently.core.event_bus import EventType from gently.core.service import Service +from ..settings import settings from .discovery import MeshDiscovery from .models import PeerCapability, PeerInfo, PeerStatus from .peer_client import PeerClient @@ -24,8 +25,6 @@ logger = logging.getLogger(__name__) -from ..settings import settings - REAPER_INTERVAL = settings.mesh.reaper_interval_s STATUS_REFRESH_INTERVAL = settings.mesh.status_refresh_s @@ -59,7 +58,7 @@ def __init__( mesh_port: int = settings.network.mesh_port, pairing_manager=None, audit_log=None, - config_dir: Optional[Path] = None, + config_dir: Path | None = None, ): import socket as _socket @@ -78,12 +77,12 @@ def __init__( self._audit_log = audit_log self._hostname = _socket.gethostname() - self._peers: Dict[str, PeerInfo] = {} - self._discovery: Optional[MeshDiscovery] = None - self._peer_client: Optional[PeerClient] = None - self._reaper_task: Optional[asyncio.Task] = None - self._refresh_task: Optional[asyncio.Task] = None - self._cleanup_task: Optional[asyncio.Task] = None + self._peers: dict[str, PeerInfo] = {} + self._discovery: MeshDiscovery | None = None + self._peer_client: PeerClient | None = None + self._reaper_task: asyncio.Task | None = None + self._refresh_task: asyncio.Task | None = None + self._cleanup_task: asyncio.Task | None = None # Persistent verse map if config_dir is None: @@ -121,7 +120,8 @@ async def on_start(self): # When our own status changes, broadcast a nudge to all peers self._status_unsub = self._event_bus.subscribe( - EventType.STATUS_CHANGED, self._on_local_status_changed, + EventType.STATUS_CHANGED, + self._on_local_status_changed, ) async def on_stop(self): @@ -156,7 +156,8 @@ def _on_peer_discovered(self, data: dict, sender_ip: str, verified: bool = False # Check if this peer is already trusted trusted = ( self._pairing_manager.is_trusted(peer_id) - if self._pairing_manager else True # no manager = trust all (backward compat) + if self._pairing_manager + else True # no manager = trust all (backward compat) ) # Determine TLS status — trusted peers with a cert fingerprint use TLS @@ -187,24 +188,28 @@ def _on_peer_discovered(self, data: dict, sender_ip: str, verified: bool = False if was_offline: # Previously offline peer returned self._verse_map.on_peer_returned(peer_id) - self._emit_event(EventType.MESH_PEER_RETURNED, { - "instance_id": peer_id, - "hostname": peer.hostname, - "ip_address": sender_ip, - "is_trusted": trusted, - }) - logger.info( - f"Mesh: peer returned {peer.hostname} ({peer_id[:8]}) at {sender_ip}" + self._emit_event( + EventType.MESH_PEER_RETURNED, + { + "instance_id": peer_id, + "hostname": peer.hostname, + "ip_address": sender_ip, + "is_trusted": trusted, + }, ) + logger.info(f"Mesh: peer returned {peer.hostname} ({peer_id[:8]}) at {sender_ip}") else: - self._emit_event(EventType.MESH_PEER_DISCOVERED, { - "instance_id": peer_id, - "hostname": peer.hostname, - "ip_address": sender_ip, - "is_trusted": trusted, - "udp_verified": verified, - "tls_enabled": tls_enabled, - }) + self._emit_event( + EventType.MESH_PEER_DISCOVERED, + { + "instance_id": peer_id, + "hostname": peer.hostname, + "ip_address": sender_ip, + "is_trusted": trusted, + "udp_verified": verified, + "tls_enabled": tls_enabled, + }, + ) logger.info( f"Mesh: discovered peer {peer.hostname} ({peer_id[:8]}) at {sender_ip} " f"[trusted={trusted}, udp_verified={verified}, tls={tls_enabled}]" @@ -257,20 +262,28 @@ async def _reaper_loop(self): self._verse_map.on_peer_offline(pid) if self._discovery: self._discovery.forget_peer(pid) - self._emit_event(EventType.MESH_PEER_OFFLINE, { - "instance_id": pid, - "hostname": peer.hostname, - }) - logger.info(f"Mesh: peer offline {peer.hostname} ({pid[:8]}) — kept in verse map") + self._emit_event( + EventType.MESH_PEER_OFFLINE, + { + "instance_id": pid, + "hostname": peer.hostname, + }, + ) + logger.info( + f"Mesh: peer offline {peer.hostname} ({pid[:8]}) — kept in verse map" + ) else: # Untrusted peer: fully remove self._peers.pop(pid, None) if self._discovery: self._discovery.forget_peer(pid) - self._emit_event(EventType.MESH_PEER_LOST, { - "instance_id": pid, - "hostname": peer.hostname, - }) + self._emit_event( + EventType.MESH_PEER_LOST, + { + "instance_id": pid, + "hostname": peer.hostname, + }, + ) logger.info(f"Mesh: lost peer {peer.hostname} ({pid[:8]})") async def _refresh_loop(self): @@ -306,10 +319,13 @@ async def _fetch_and_update_peer(self, peer: PeerInfo): # Update verse map with latest capabilities self._verse_map.on_peer_updated(peer) - self._emit_event(EventType.MESH_PEER_UPDATED, { - "instance_id": peer.instance_id, - "hostname": peer.hostname, - }) + self._emit_event( + EventType.MESH_PEER_UPDATED, + { + "instance_id": peer.instance_id, + "hostname": peer.hostname, + }, + ) # ------------------------------------------------------------------ # Pairing integration @@ -343,19 +359,19 @@ def mark_peer_trusted(self, instance_id: str): # Public query API # ------------------------------------------------------------------ - def get_peers(self) -> List[PeerInfo]: + def get_peers(self) -> list[PeerInfo]: """Return all live (non-dead) peers.""" return [p for p in self._peers.values() if not p.is_dead] - def get_all_peers(self) -> List[PeerInfo]: + def get_all_peers(self) -> list[PeerInfo]: """Return all tracked peers including stale/dead ones.""" return list(self._peers.values()) - def get_peer(self, instance_id: str) -> Optional[PeerInfo]: + def get_peer(self, instance_id: str) -> PeerInfo | None: """Get a specific peer by instance_id.""" return self._peers.get(instance_id) - def find_peers_with(self, capability: str) -> List[PeerInfo]: + def find_peers_with(self, capability: str) -> list[PeerInfo]: """ Find live peers that have a given capability flag. @@ -386,11 +402,11 @@ def get_local_info(self) -> dict: } @property - def peer_client(self) -> Optional[PeerClient]: + def peer_client(self) -> PeerClient | None: """Expose the peer client for direct campaign operations.""" return self._peer_client - def find_peer_by_hostname(self, hostname: str) -> Optional[PeerInfo]: + def find_peer_by_hostname(self, hostname: str) -> PeerInfo | None: """Find a live peer by hostname (case-insensitive).""" hostname_lower = hostname.lower() for p in self.get_peers(): diff --git a/gently/mesh/models.py b/gently/mesh/models.py index 8c59877f..721525d0 100644 --- a/gently/mesh/models.py +++ b/gently/mesh/models.py @@ -14,13 +14,14 @@ import time from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any from ..settings import settings class PeerRole(str, Enum): """Dynamic roles a gently node can fill.""" + MICROSCOPE_CONTROLLER = "microscope_controller" ML_TRAINER = "ml_trainer" DATA_SERVER = "data_server" @@ -30,6 +31,7 @@ class PeerRole(str, Enum): @dataclass class GpuInfo: """Details about a single GPU device.""" + device_index: int = 0 name: str = "" vram_gb: float = 0.0 @@ -37,7 +39,7 @@ class GpuInfo: utilization_pct: float = 0.0 memory_used_gb: float = 0.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "device_index": self.device_index, "name": self.name, @@ -48,7 +50,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "GpuInfo": + def from_dict(cls, d: dict[str, Any]) -> "GpuInfo": return cls( device_index=d.get("device_index", 0), name=d.get("name", ""), @@ -62,16 +64,17 @@ def from_dict(cls, d: Dict[str, Any]) -> "GpuInfo": @dataclass class DatasetAdvertisement: """Advertises what data a node has available for training.""" + session_id: str = "" session_name: str = "" embryo_count: int = 0 volume_count: int = 0 has_ground_truth: bool = False ground_truth_count: int = 0 - stages_covered: List[str] = field(default_factory=list) + stages_covered: list[str] = field(default_factory=list) total_size_gb: float = 0.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "session_id": self.session_id, "session_name": self.session_name, @@ -84,7 +87,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "DatasetAdvertisement": + def from_dict(cls, d: dict[str, Any]) -> "DatasetAdvertisement": return cls( session_id=d.get("session_id", ""), session_name=d.get("session_name", ""), @@ -107,19 +110,19 @@ class PeerCapability: gpu_name: str = "" gpu_vram_gb: float = 0.0 storage_free_gb: float = 0.0 - tool_categories: List[str] = field(default_factory=list) + tool_categories: list[str] = field(default_factory=list) organism: str = "" hardware_profile: str = "" # Enhanced capability fields (backward-compatible — old peers get defaults) - gpus: List[GpuInfo] = field(default_factory=list) - roles: List[str] = field(default_factory=list) - datasets: List[DatasetAdvertisement] = field(default_factory=list) + gpus: list[GpuInfo] = field(default_factory=list) + roles: list[str] = field(default_factory=list) + datasets: list[DatasetAdvertisement] = field(default_factory=list) microscope_connected: bool = False cpu_cores: int = 0 ram_gb: float = 0.0 storage_total_gb: float = 0.0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "has_microscope": self.has_microscope, "has_sam": self.has_sam, @@ -140,7 +143,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "PeerCapability": + def from_dict(cls, d: dict[str, Any]) -> "PeerCapability": return cls( has_microscope=d.get("has_microscope", False), has_sam=d.get("has_sam", False), @@ -174,7 +177,7 @@ class PeerStatus: active_plan: str = "" version: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "session_id": self.session_id, "acquisition_status": self.acquisition_status, @@ -187,7 +190,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "PeerStatus": + def from_dict(cls, d: dict[str, Any]) -> "PeerStatus": return cls( session_id=d.get("session_id", ""), acquisition_status=d.get("acquisition_status", "idle"), @@ -232,7 +235,7 @@ def is_dead(self) -> bool: """True if no heartbeat beyond the dead threshold.""" return (time.time() - self.last_seen) > settings.mesh.dead_threshold_s - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "instance_id": self.instance_id, "hostname": self.hostname, @@ -252,7 +255,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "PeerInfo": + def from_dict(cls, d: dict[str, Any]) -> "PeerInfo": return cls( instance_id=d.get("instance_id", ""), hostname=d.get("hostname", ""), @@ -289,15 +292,15 @@ class PersistedPeer: # Persistence fields online: bool = True last_online: float = field(default_factory=time.time) - roles: List[str] = field(default_factory=list) - datasets: List[DatasetAdvertisement] = field(default_factory=list) + roles: list[str] = field(default_factory=list) + datasets: list[DatasetAdvertisement] = field(default_factory=list) @property def base_url(self) -> str: scheme = "https" if self.tls_enabled else "http" return f"{scheme}://{self.ip_address}:{self.viz_port}" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "instance_id": self.instance_id, "hostname": self.hostname, @@ -316,7 +319,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "PersistedPeer": + def from_dict(cls, d: dict[str, Any]) -> "PersistedPeer": return cls( instance_id=d.get("instance_id", ""), hostname=d.get("hostname", ""), diff --git a/gently/mesh/pairing.py b/gently/mesh/pairing.py index 32e939d5..e8eda9d9 100644 --- a/gently/mesh/pairing.py +++ b/gently/mesh/pairing.py @@ -24,7 +24,6 @@ import uuid from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Tuple logger = logging.getLogger(__name__) @@ -71,7 +70,7 @@ class TrustedPeer: paired_at: str = "" # ISO timestamp cert_fingerprint: str = "" # SHA256 of peer's TLS cert (DER) udp_signing_key: str = "" # hex-encoded key for UDP HMAC verification - scopes: List[str] = field(default_factory=lambda: list(ALL_SCOPES)) + scopes: list[str] = field(default_factory=lambda: list(ALL_SCOPES)) class PairingManager: @@ -94,8 +93,8 @@ def __init__(self, instance_id: str, hostname: str, config_dir: Path, audit_log= self._config_dir = config_dir self._audit_log = audit_log - self._sessions: Dict[str, PairingSession] = {} - self._trusted: Dict[str, TrustedPeer] = {} # keyed by instance_id + self._sessions: dict[str, PairingSession] = {} + self._trusted: dict[str, TrustedPeer] = {} # keyed by instance_id self._trust_file = config_dir / "mesh_trusted_peers.json" # Phase 2: TLS cert fingerprint (set by launch_gently after cert gen) @@ -109,7 +108,7 @@ def __init__(self, instance_id: str, hostname: str, config_dir: Path, audit_log= ).hexdigest() # Phase 2: rate limiting state - self._pair_attempts: Dict[str, List[float]] = {} # IP -> timestamps + self._pair_attempts: dict[str, list[float]] = {} # IP -> timestamps self._load_trusted() @@ -163,7 +162,7 @@ def is_trusted(self, instance_id: str) -> bool: """Check if a peer is trusted.""" return instance_id in self._trusted - def get_token_for_peer(self, instance_id: str) -> Optional[str]: + def get_token_for_peer(self, instance_id: str) -> str | None: """Get the current daily auth token for a trusted peer.""" tp = self._trusted.get(instance_id) if tp is None: @@ -171,7 +170,7 @@ def get_token_for_peer(self, instance_id: str) -> Optional[str]: epoch_day = self._current_epoch_day() return self._derive_daily_token(tp.base_token, epoch_day) - def verify_token(self, token: str) -> Optional[str]: + def verify_token(self, token: str) -> str | None: """ Check if a token matches any trusted peer (timing-safe). @@ -186,26 +185,26 @@ def verify_token(self, token: str) -> Optional[str]: return tp.instance_id return None - def get_all_trusted(self) -> List[TrustedPeer]: + def get_all_trusted(self) -> list[TrustedPeer]: """Return all trusted peers.""" return list(self._trusted.values()) - def get_udp_key_for_peer(self, instance_id: str) -> Optional[str]: + def get_udp_key_for_peer(self, instance_id: str) -> str | None: """Get the UDP signing key for a trusted peer.""" tp = self._trusted.get(instance_id) return tp.udp_signing_key if tp else None - def get_cert_fingerprint_for_peer(self, instance_id: str) -> Optional[str]: + def get_cert_fingerprint_for_peer(self, instance_id: str) -> str | None: """Get the TLS cert fingerprint for a trusted peer.""" tp = self._trusted.get(instance_id) return tp.cert_fingerprint if tp else None - def get_scopes_for_peer(self, instance_id: str) -> List[str]: + def get_scopes_for_peer(self, instance_id: str) -> list[str]: """Get the permission scopes for a trusted peer.""" tp = self._trusted.get(instance_id) return list(tp.scopes) if tp else [] - def set_scopes(self, identifier: str, scopes: List[str]) -> bool: + def set_scopes(self, identifier: str, scopes: list[str]) -> bool: """ Set permission scopes for a peer (by instance_id, prefix, or hostname). @@ -269,8 +268,10 @@ def unpair(self, identifier: str) -> bool: self._save_trusted() if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.PEER_UNPAIRED, outcome="info", + AuditEvent.PEER_UNPAIRED, + outcome="info", peer_id=removed_id, ) return True @@ -281,7 +282,7 @@ def unpair(self, identifier: str) -> bool: # Rate limiting # ------------------------------------------------------------------ - def check_rate_limit(self, ip: str) -> Tuple[bool, float]: + def check_rate_limit(self, ip: str) -> tuple[bool, float]: """ Check if a pairing attempt from this IP is allowed. @@ -298,9 +299,12 @@ def check_rate_limit(self, ip: str) -> Tuple[bool, float]: retry_after = RATE_LIMIT_WINDOW - (now - attempts[0]) if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.RATE_LIMITED, outcome="deny", - ip=ip, detail=f"max_attempts={RATE_LIMIT_MAX}", + AuditEvent.RATE_LIMITED, + outcome="deny", + ip=ip, + detail=f"max_attempts={RATE_LIMIT_MAX}", ) return False, max(retry_after, 1.0) @@ -312,9 +316,12 @@ def check_rate_limit(self, ip: str) -> Tuple[bool, float]: if elapsed < backoff: if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.RATE_LIMITED, outcome="deny", - ip=ip, detail=f"backoff={backoff:.1f}s", + AuditEvent.RATE_LIMITED, + outcome="deny", + ip=ip, + detail=f"backoff={backoff:.1f}s", ) return False, backoff - elapsed @@ -405,7 +412,7 @@ def handle_pair_request( # Confirmation # ------------------------------------------------------------------ - def confirm_pairing(self, pairing_id: str, confirmer_id: str) -> Optional[PairingSession]: + def confirm_pairing(self, pairing_id: str, confirmer_id: str) -> PairingSession | None: """ Mark one side as confirmed. @@ -428,36 +435,39 @@ def confirm_pairing(self, pairing_id: str, confirmer_id: str) -> Optional[Pairin return session - def reject_pairing(self, pairing_id: str) -> Optional[PairingSession]: + def reject_pairing(self, pairing_id: str) -> PairingSession | None: """Reject a pending pairing session.""" session = self._sessions.get(pairing_id) if session and session.status == "pending": session.status = "rejected" if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.PAIR_REJECTED, outcome="deny", + AuditEvent.PAIR_REJECTED, + outcome="deny", peer_id=session.initiator_id, ) return session - def get_session(self, pairing_id: str) -> Optional[PairingSession]: + def get_session(self, pairing_id: str) -> PairingSession | None: """Get a pairing session by ID.""" return self._sessions.get(pairing_id) - def get_pending_sessions(self) -> List[PairingSession]: + def get_pending_sessions(self) -> list[PairingSession]: """Get all pending pairing sessions (for /pair accept).""" return [ - s for s in self._sessions.values() - if s.status == "pending" - and s.responder_id == self.instance_id + s + for s in self._sessions.values() + if s.status == "pending" and s.responder_id == self.instance_id ] def cleanup_expired(self): """Remove expired pending sessions.""" now = time.time() expired = [ - pid for pid, s in self._sessions.items() + pid + for pid, s in self._sessions.items() if s.status == "pending" and (now - s.created_at) > PAIRING_EXPIRY ] for pid in expired: @@ -497,9 +507,12 @@ def _finalize_pairing(self, session: PairingSession): logger.info(f"Paired with {peer_hostname} ({peer_id[:8]})") if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.PAIR_COMPLETED, outcome="info", - peer_id=peer_id, detail=f"hostname={peer_hostname}", + AuditEvent.PAIR_COMPLETED, + outcome="info", + peer_id=peer_id, + detail=f"hostname={peer_hostname}", ) def _load_trusted(self): diff --git a/gently/mesh/peer_client.py b/gently/mesh/peer_client.py index c0b1be5e..c6db282b 100644 --- a/gently/mesh/peer_client.py +++ b/gently/mesh/peer_client.py @@ -10,12 +10,12 @@ import asyncio import logging import ssl -from typing import Any, Dict, List, Optional +from typing import Any import aiohttp -from .models import PeerInfo from ..settings import settings +from .models import PeerInfo logger = logging.getLogger(__name__) @@ -24,7 +24,7 @@ class PeerClient: """Fetches full status from a peer's viz server over HTTP.""" def __init__(self, pairing_manager=None, audit_log=None): - self._session: Optional[aiohttp.ClientSession] = None + self._session: aiohttp.ClientSession | None = None self._pairing_manager = pairing_manager self._audit_log = audit_log self._pinning_verified: set = set() # track first-success per peer @@ -40,7 +40,7 @@ async def _ensure_session(self): self._session = aiohttp.ClientSession(timeout=timeout, connector=connector) self._pinning_verified.clear() - def _auth_headers(self, peer: PeerInfo) -> Dict[str, str]: + def _auth_headers(self, peer: PeerInfo) -> dict[str, str]: """Build auth headers for a trusted peer.""" if self._pairing_manager is None: return {} @@ -59,16 +59,13 @@ def _ssl_for_peer(self, peer: PeerInfo): """ if self._pairing_manager is None: return False - fingerprint = self._pairing_manager.get_cert_fingerprint_for_peer( - peer.instance_id - ) + fingerprint = self._pairing_manager.get_cert_fingerprint_for_peer(peer.instance_id) if fingerprint: try: return aiohttp.Fingerprint(bytes.fromhex(fingerprint)) except (ValueError, TypeError): logger.warning( - f"Invalid cert fingerprint for {peer.instance_id[:8]}, " - "falling back to unpinned" + f"Invalid cert fingerprint for {peer.instance_id[:8]}, falling back to unpinned" ) return False @@ -78,9 +75,12 @@ def _log_pinning_success(self, peer: PeerInfo, ssl_fp): self._pinning_verified.add(peer.instance_id) if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.CERT_PIN_OK, outcome="allow", - peer_id=peer.instance_id, ip=peer.ip_address, + AuditEvent.CERT_PIN_OK, + outcome="allow", + peer_id=peer.instance_id, + ip=peer.ip_address, ) def _log_pinning_failure(self, peer: PeerInfo, error): @@ -88,13 +88,16 @@ def _log_pinning_failure(self, peer: PeerInfo, error): logger.warning(f"CERT PINNING FAILED for {peer.instance_id[:8]}: {error}") if self._audit_log: from .audit import AuditEvent + self._audit_log.log( - AuditEvent.CERT_PIN_FAIL, outcome="deny", - peer_id=peer.instance_id, ip=peer.ip_address, + AuditEvent.CERT_PIN_FAIL, + outcome="deny", + peer_id=peer.instance_id, + ip=peer.ip_address, detail=str(error), ) - async def fetch_peer_info(self, peer: PeerInfo) -> Optional[Dict[str, Any]]: + async def fetch_peer_info(self, peer: PeerInfo) -> dict[str, Any] | None: """ GET /api/mesh/status from a peer. @@ -109,9 +112,7 @@ async def fetch_peer_info(self, peer: PeerInfo) -> Optional[Dict[str, Any]]: if resp.status == 200: self._log_pinning_success(peer, ssl_fp) return await resp.json() - logger.debug( - f"Peer {peer.instance_id[:8]} returned HTTP {resp.status}" - ) + logger.debug(f"Peer {peer.instance_id[:8]} returned HTTP {resp.status}") except aiohttp.ServerFingerprintMismatch as e: self._log_pinning_failure(peer, e) except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e: @@ -123,7 +124,7 @@ async def fetch_peer_info(self, peer: PeerInfo) -> Optional[Dict[str, Any]]: # Campaign coordination methods # ------------------------------------------------------------------ - async def fetch_peer_campaigns(self, peer: PeerInfo) -> Optional[List]: + async def fetch_peer_campaigns(self, peer: PeerInfo) -> list | None: """GET /api/campaigns from a peer.""" await self._ensure_session() url = f"{peer.base_url}/api/campaigns" @@ -141,7 +142,7 @@ async def fetch_peer_campaigns(self, peer: PeerInfo) -> Optional[List]: logger.debug(f"Failed to fetch campaigns from {peer.instance_id[:8]}: {e}") return None - async def fetch_campaign_export(self, peer: PeerInfo, campaign_id: str) -> Optional[Dict]: + async def fetch_campaign_export(self, peer: PeerInfo, campaign_id: str) -> dict | None: """GET /api/campaigns/{id}/export from a peer.""" await self._ensure_session() url = f"{peer.base_url}/api/campaigns/{campaign_id}/export" @@ -159,7 +160,11 @@ async def fetch_campaign_export(self, peer: PeerInfo, campaign_id: str) -> Optio return None async def join_campaign( - self, peer: PeerInfo, campaign_id: str, instance_id: str, hostname: str, + self, + peer: PeerInfo, + campaign_id: str, + instance_id: str, + hostname: str, ) -> bool: """POST /api/campaigns/{id}/join on a peer.""" await self._ensure_session() @@ -167,10 +172,15 @@ async def join_campaign( headers = self._auth_headers(peer) ssl_fp = self._ssl_for_peer(peer) try: - async with self._session.post(url, json={ - "instance_id": instance_id, - "hostname": hostname, - }, headers=headers, ssl=ssl_fp) as resp: + async with self._session.post( + url, + json={ + "instance_id": instance_id, + "hostname": hostname, + }, + headers=headers, + ssl=ssl_fp, + ) as resp: if resp.status == 200: self._log_pinning_success(peer, ssl_fp) return resp.status == 200 @@ -194,10 +204,15 @@ async def claim_item( headers = self._auth_headers(peer) ssl_fp = self._ssl_for_peer(peer) try: - async with self._session.post(url, json={ - "instance_id": instance_id, - "hostname": hostname, - }, headers=headers, ssl=ssl_fp) as resp: + async with self._session.post( + url, + json={ + "instance_id": instance_id, + "hostname": hostname, + }, + headers=headers, + ssl=ssl_fp, + ) as resp: if resp.status == 200: self._log_pinning_success(peer, ssl_fp) return resp.status == 200 @@ -208,7 +223,10 @@ async def claim_item( return False async def unclaim_item( - self, peer: PeerInfo, campaign_id: str, item_id: str, + self, + peer: PeerInfo, + campaign_id: str, + item_id: str, ) -> bool: """POST /api/campaigns/{id}/items/{item_id}/unclaim on a peer.""" await self._ensure_session() @@ -232,12 +250,12 @@ async def update_item_status( campaign_id: str, item_id: str, status: str, - outcome: Optional[str] = None, + outcome: str | None = None, ) -> bool: """POST /api/campaigns/{id}/items/{item_id}/status on a peer.""" await self._ensure_session() url = f"{peer.base_url}/api/campaigns/{campaign_id}/items/{item_id}/status" - body: Dict[str, Any] = {"status": status} + body: dict[str, Any] = {"status": status} if outcome is not None: body["outcome"] = outcome headers = self._auth_headers(peer) @@ -258,9 +276,14 @@ async def update_item_status( # ------------------------------------------------------------------ async def send_pair_request( - self, peer: PeerInfo, initiator_id: str, hostname: str, nonce: str, - cert_fingerprint: str = "", udp_sign_key: str = "", - ) -> Optional[Dict]: + self, + peer: PeerInfo, + initiator_id: str, + hostname: str, + nonce: str, + cert_fingerprint: str = "", + udp_sign_key: str = "", + ) -> dict | None: """POST /api/mesh/pair — initiate pairing with a peer. Returns response dict on success, or {"_error": "..."} on failure. @@ -278,8 +301,9 @@ async def send_pair_request( base = f"{peer.ip_address}:{peer.viz_port}" return {"_error": f"Could not reach {base} via HTTPS or HTTP"} - async def _pairing_request(self, peer: PeerInfo, method: str, path: str, - json_body: Optional[Dict] = None) -> Optional[Dict]: + async def _pairing_request( + self, peer: PeerInfo, method: str, path: str, json_body: dict | None = None + ) -> dict | None: """Make an HTTP request trying HTTPS first, then HTTP (for pre-pairing).""" await self._ensure_session() base = f"{peer.ip_address}:{peer.viz_port}" @@ -297,18 +321,25 @@ async def _pairing_request(self, peer: PeerInfo, method: str, path: str, continue return None - async def poll_pair_status(self, peer: PeerInfo, pairing_id: str) -> Optional[Dict]: + async def poll_pair_status(self, peer: PeerInfo, pairing_id: str) -> dict | None: """GET /api/mesh/pair/{id}/status — poll pairing status.""" return await self._pairing_request( - peer, "GET", f"/api/mesh/pair/{pairing_id}/status", + peer, + "GET", + f"/api/mesh/pair/{pairing_id}/status", ) async def confirm_pair_remote( - self, peer: PeerInfo, pairing_id: str, confirmer_id: str, + self, + peer: PeerInfo, + pairing_id: str, + confirmer_id: str, ) -> bool: """POST /api/mesh/pair/{id}/confirm — confirm pairing on remote side.""" resp = await self._pairing_request( - peer, "POST", f"/api/mesh/pair/{pairing_id}/confirm", + peer, + "POST", + f"/api/mesh/pair/{pairing_id}/confirm", json_body={"confirmer_id": confirmer_id}, ) return resp is not None @@ -317,7 +348,7 @@ async def confirm_pair_remote( # Data catalog methods (Phase 2 — requires "data" scope) # ------------------------------------------------------------------ - async def fetch_peer_sessions(self, peer: PeerInfo) -> Optional[List]: + async def fetch_peer_sessions(self, peer: PeerInfo) -> list | None: """GET /api/data/sessions from a peer.""" await self._ensure_session() url = f"{peer.base_url}/api/data/sessions" @@ -335,7 +366,7 @@ async def fetch_peer_sessions(self, peer: PeerInfo) -> Optional[List]: logger.debug(f"Failed to fetch sessions from {peer.instance_id[:8]}: {e}") return None - async def fetch_peer_session_detail(self, peer: PeerInfo, session_id: str) -> Optional[Dict]: + async def fetch_peer_session_detail(self, peer: PeerInfo, session_id: str) -> dict | None: """GET /api/data/sessions/{id} from a peer.""" await self._ensure_session() url = f"{peer.base_url}/api/data/sessions/{session_id}" @@ -352,7 +383,7 @@ async def fetch_peer_session_detail(self, peer: PeerInfo, session_id: str) -> Op logger.debug(f"Failed to fetch session detail from {peer.instance_id[:8]}: {e}") return None - async def fetch_peer_coverage(self, peer: PeerInfo) -> Optional[Dict]: + async def fetch_peer_coverage(self, peer: PeerInfo) -> dict | None: """GET /api/data/coverage from a peer.""" await self._ensure_session() url = f"{peer.base_url}/api/data/coverage" @@ -369,7 +400,7 @@ async def fetch_peer_coverage(self, peer: PeerInfo) -> Optional[Dict]: logger.debug(f"Failed to fetch coverage from {peer.instance_id[:8]}: {e}") return None - async def fetch_peer_stage_distribution(self, peer: PeerInfo) -> Optional[Dict]: + async def fetch_peer_stage_distribution(self, peer: PeerInfo) -> dict | None: """GET /api/data/stages from a peer.""" await self._ensure_session() url = f"{peer.base_url}/api/data/stages" diff --git a/gently/mesh/routes.py b/gently/mesh/routes.py index 1308340e..2f0edd38 100644 --- a/gently/mesh/routes.py +++ b/gently/mesh/routes.py @@ -51,15 +51,21 @@ async def require_mesh_auth(request: Request): if required_scope not in scopes: if audit_log: audit_log.log( - AuditEvent.SCOPE_DENIED, outcome="deny", - peer_id=peer_id, ip=host, + AuditEvent.SCOPE_DENIED, + outcome="deny", + peer_id=peer_id, + ip=host, detail=f"scope={required_scope} path={request.url.path}", ) if viz_server.event_bus is not None: viz_server.event_bus.publish( EventType.MESH_SCOPE_DENIED, - {"peer_id": peer_id, "scope": required_scope, - "ip": host, "path": str(request.url.path)}, + { + "peer_id": peer_id, + "scope": required_scope, + "ip": host, + "path": str(request.url.path), + }, source="mesh", ) raise HTTPException( @@ -68,16 +74,20 @@ async def require_mesh_auth(request: Request): ) if audit_log: audit_log.log( - AuditEvent.AUTH_SUCCESS, outcome="allow", - peer_id=peer_id, ip=host, + AuditEvent.AUTH_SUCCESS, + outcome="allow", + peer_id=peer_id, + ip=host, ) return # Auth failed if audit_log: audit_log.log( - AuditEvent.AUTH_FAILURE, outcome="deny", - ip=host, detail=f"path={request.url.path}", + AuditEvent.AUTH_FAILURE, + outcome="deny", + ip=host, + detail=f"path={request.url.path}", ) if viz_server.event_bus is not None: viz_server.event_bus.publish( @@ -106,13 +116,15 @@ async def mesh_status(): shared_list = [] for c in shared: status = cs.get_plan_status(c.id) - shared_list.append({ - "id": c.id, - "shorthand": c.shorthand, - "description": c.description, - "item_count": status["total"], - "completed_count": status["completed"], - }) + shared_list.append( + { + "id": c.id, + "shorthand": c.shorthand, + "description": c.description, + "item_count": status["total"], + "completed_count": status["completed"], + } + ) info["shared_campaigns"] = shared_list except Exception: pass @@ -123,12 +135,17 @@ async def mesh_status(): async def mesh_peers(): """List all discovered peers.""" peers = mesh_service.get_peers() - return JSONResponse({ - "peers": [p.to_dict() for p in peers], - "count": len(peers), - }) + return JSONResponse( + { + "peers": [p.to_dict() for p in peers], + "count": len(peers), + } + ) - @router.get("/api/mesh/peers/{instance_id}", dependencies=[Depends(_make_auth_dep("status"))]) + @router.get( + "/api/mesh/peers/{instance_id}", + dependencies=[Depends(_make_auth_dep("status"))], + ) async def mesh_peer_detail(instance_id: str): """Get specific peer details.""" peer = mesh_service.get_peer(instance_id) @@ -144,11 +161,13 @@ async def mesh_topology(): """Full mesh view: self + all peers.""" local = mesh_service.get_local_info() peers = mesh_service.get_all_peers() - return JSONResponse({ - "self": local, - "peers": [p.to_dict() for p in peers], - "total_nodes": 1 + len(peers), - }) + return JSONResponse( + { + "self": local, + "peers": [p.to_dict() for p in peers], + "total_nodes": 1 + len(peers), + } + ) # ------------------------------------------------------------------ # Pairing endpoints (no auth — these bootstrap trust) @@ -181,15 +200,19 @@ async def pair_request(request: Request): raise HTTPException(status_code=400, detail="initiator_id and nonce required") session = pairing_mgr.handle_pair_request( - initiator_id, hostname, nonce, + initiator_id, + hostname, + nonce, initiator_cert_fingerprint=initiator_cert_fp, initiator_udp_sign_key=initiator_udp_key, ) if audit_log: audit_log.log( - AuditEvent.PAIR_REQUESTED, outcome="info", - peer_id=initiator_id, ip=client_ip, + AuditEvent.PAIR_REQUESTED, + outcome="info", + peer_id=initiator_id, + ip=client_ip, detail=f"hostname={hostname}", ) @@ -205,15 +228,17 @@ async def pair_request(request: Request): source="mesh", ) - return JSONResponse({ - "nonce": session.nonce_responder, - "pairing_id": session.pairing_id, - "status": session.status, - "responder_id": mesh_service.instance_id, - "responder_hostname": mesh_service._hostname, - "cert_fingerprint": pairing_mgr.cert_fingerprint, - "udp_sign_key": pairing_mgr.udp_sign_key, - }) + return JSONResponse( + { + "nonce": session.nonce_responder, + "pairing_id": session.pairing_id, + "status": session.status, + "responder_id": mesh_service.instance_id, + "responder_hostname": mesh_service._hostname, + "cert_fingerprint": pairing_mgr.cert_fingerprint, + "udp_sign_key": pairing_mgr.udp_sign_key, + } + ) @router.get("/api/mesh/pair/{pairing_id}/status") async def pair_status(pairing_id: str): @@ -225,12 +250,14 @@ async def pair_status(pairing_id: str): if session is None: raise HTTPException(status_code=404, detail="Pairing session not found") - return JSONResponse({ - "pairing_id": session.pairing_id, - "status": session.status, - "confirmed_by_initiator": session.confirmed_by_initiator, - "confirmed_by_responder": session.confirmed_by_responder, - }) + return JSONResponse( + { + "pairing_id": session.pairing_id, + "status": session.status, + "confirmed_by_initiator": session.confirmed_by_initiator, + "confirmed_by_responder": session.confirmed_by_responder, + } + ) @router.post("/api/mesh/pair/{pairing_id}/confirm") async def pair_confirm(pairing_id: str, request: Request): @@ -267,10 +294,12 @@ async def pair_confirm(pairing_id: str, request: Request): source="mesh", ) - return JSONResponse({ - "pairing_id": session.pairing_id, - "status": session.status, - }) + return JSONResponse( + { + "pairing_id": session.pairing_id, + "status": session.status, + } + ) @router.post("/api/mesh/pair/{pairing_id}/reject") async def pair_reject(pairing_id: str): @@ -282,10 +311,12 @@ async def pair_reject(pairing_id: str): if session is None: raise HTTPException(status_code=404, detail="Pairing session not found") - return JSONResponse({ - "pairing_id": session.pairing_id, - "status": session.status, - }) + return JSONResponse( + { + "pairing_id": session.pairing_id, + "status": session.status, + } + ) # ------------------------------------------------------------------ # Verse map routes (scope: status) @@ -296,12 +327,14 @@ async def verse_map(): """Full persistent topology — includes offline peers.""" vm = mesh_service.verse_map peers = vm.get_all_peers() - return JSONResponse({ - "peers": [p.to_dict() for p in peers], - "online_count": len(vm.get_online_peers()), - "offline_count": len(vm.get_offline_peers()), - "total_count": len(peers), - }) + return JSONResponse( + { + "peers": [p.to_dict() for p in peers], + "online_count": len(vm.get_online_peers()), + "offline_count": len(vm.get_offline_peers()), + "total_count": len(peers), + } + ) @router.get( "/api/mesh/verse-map/resources/{capability}", @@ -311,11 +344,13 @@ async def verse_map_resources(capability: str): """Find peers matching a capability (route-finding).""" vm = mesh_service.verse_map peers = vm.find_resource(capability) - return JSONResponse({ - "capability": capability, - "peers": [p.to_dict() for p in peers], - "count": len(peers), - }) + return JSONResponse( + { + "capability": capability, + "peers": [p.to_dict() for p in peers], + "count": len(peers), + } + ) # ------------------------------------------------------------------ # Data catalog routes (scope: data) @@ -334,25 +369,32 @@ async def data_sessions(): sid = s.session_id if hasattr(s, "session_id") else s.get("session_id", "") name = s.name if hasattr(s, "name") else s.get("name", "") created = s.created_at if hasattr(s, "created_at") else s.get("created_at", "") - last_active = s.last_active if hasattr(s, "last_active") else s.get("last_active", "") + last_active = ( + s.last_active if hasattr(s, "last_active") else s.get("last_active", "") + ) embryos = store.list_embryos(sid) vol_count = 0 for e in embryos: eid = e.embryo_id if hasattr(e, "embryo_id") else e.get("embryo_id", "") vol_count += len(store.list_volumes(sid, eid)) - result.append({ - "session_id": sid, - "name": name, - "embryo_count": len(embryos), - "volume_count": vol_count, - "created_at": created, - "last_active": last_active, - }) + result.append( + { + "session_id": sid, + "name": name, + "embryo_count": len(embryos), + "volume_count": vol_count, + "created_at": created, + "last_active": last_active, + } + ) return JSONResponse({"sessions": result, "count": len(result)}) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) - @router.get("/api/data/sessions/{session_id}", dependencies=[Depends(_make_auth_dep("data"))]) + @router.get( + "/api/data/sessions/{session_id}", + dependencies=[Depends(_make_auth_dep("data"))], + ) async def data_session_detail(session_id: str): """Detailed session info with embryo list.""" store = getattr(viz_server, "gently_store", None) @@ -376,26 +418,29 @@ async def data_session_detail(session_id: str): try: gts = store.get_ground_truth(session_id, eid) has_gt = len(gts) > 0 - stages = list({ - (gt.stage if hasattr(gt, "stage") else gt.get("stage", "")) - for gt in gts - }) + stages = list( + {(gt.stage if hasattr(gt, "stage") else gt.get("stage", "")) for gt in gts} + ) except Exception: pass - embryo_list.append({ - "embryo_id": eid, - "nickname": nickname, - "volume_count": vol_count, - "has_ground_truth": has_gt, - "stages_annotated": stages, - }) + embryo_list.append( + { + "embryo_id": eid, + "nickname": nickname, + "volume_count": vol_count, + "has_ground_truth": has_gt, + "stages_annotated": stages, + } + ) sname = session.name if hasattr(session, "name") else session.get("name", "") - return JSONResponse({ - "session_id": session_id, - "name": sname, - "embryos": embryo_list, - "total_volumes": total_vols, - }) + return JSONResponse( + { + "session_id": session_id, + "name": sname, + "embryos": embryo_list, + "total_volumes": total_vols, + } + ) except HTTPException: raise except Exception as e: @@ -406,10 +451,16 @@ async def data_coverage(): """Annotation coverage summary across all sessions.""" store = getattr(viz_server, "gently_store", None) if store is None: - return JSONResponse({ - "total_embryos": 0, "annotated_embryos": 0, - "coverage_pct": 0.0, "stage_counts": {}, "imbalance_ratio": 0.0, "gaps": [], - }) + return JSONResponse( + { + "total_embryos": 0, + "annotated_embryos": 0, + "coverage_pct": 0.0, + "stage_counts": {}, + "imbalance_ratio": 0.0, + "gaps": [], + } + ) try: sessions = store.list_sessions() total_embryos = 0 @@ -437,14 +488,16 @@ async def data_coverage(): # Find stages with notably low counts avg = sum(counts) / len(counts) if counts else 0 gaps = [s for s, c in stage_counts.items() if c < avg * 0.5] - return JSONResponse({ - "total_embryos": total_embryos, - "annotated_embryos": annotated_embryos, - "coverage_pct": round(coverage_pct, 1), - "stage_counts": stage_counts, - "imbalance_ratio": round(imbalance_ratio, 2), - "gaps": gaps, - }) + return JSONResponse( + { + "total_embryos": total_embryos, + "annotated_embryos": annotated_embryos, + "coverage_pct": round(coverage_pct, 1), + "stage_counts": stage_counts, + "imbalance_ratio": round(imbalance_ratio, 2), + "gaps": gaps, + } + ) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) @@ -475,10 +528,12 @@ async def data_stages(): pass if session_dist: by_session[sid] = session_dist - return JSONResponse({ - "stage_distribution": total_dist, - "by_session": by_session, - }) + return JSONResponse( + { + "stage_distribution": total_dist, + "by_session": by_session, + } + ) except Exception as e: return JSONResponse({"error": str(e)}, status_code=500) diff --git a/gently/mesh/tls.py b/gently/mesh/tls.py index 9d1ec01c..412cc485 100644 --- a/gently/mesh/tls.py +++ b/gently/mesh/tls.py @@ -12,7 +12,6 @@ import logging import ssl from pathlib import Path -from typing import Optional, Tuple logger = logging.getLogger(__name__) @@ -21,7 +20,7 @@ CERT_DAYS = 3650 # ~10 years -def ensure_tls_cert(config_dir: Path) -> Tuple[Optional[Path], Optional[Path]]: +def ensure_tls_cert(config_dir: Path) -> tuple[Path | None, Path | None]: """ Ensure a TLS cert/key pair exists in config_dir. @@ -51,9 +50,11 @@ def ensure_tls_cert(config_dir: Path) -> Tuple[Optional[Path], Optional[Path]]: private_key = ec.generate_private_key(ec.SECP256R1()) now = datetime.datetime.now(datetime.timezone.utc) - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "gently-mesh"), - ]) + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "gently-mesh"), + ] + ) cert = ( x509.CertificateBuilder() @@ -64,20 +65,24 @@ def ensure_tls_cert(config_dir: Path) -> Tuple[Optional[Path], Optional[Path]]: .not_valid_before(now) .not_valid_after(now + datetime.timedelta(days=CERT_DAYS)) .add_extension( - x509.SubjectAlternativeName([ - x509.IPAddress(ipaddress.IPv4Address("0.0.0.0")), - ]), + x509.SubjectAlternativeName( + [ + x509.IPAddress(ipaddress.IPv4Address("0.0.0.0")), + ] + ), critical=False, ) .sign(private_key, hashes.SHA256()) ) # Write PEM files - key_path.write_bytes(private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - )) + key_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) logger.info(f"Generated TLS cert: {cert_path}") @@ -85,8 +90,7 @@ def ensure_tls_cert(config_dir: Path) -> Tuple[Optional[Path], Optional[Path]]: except ImportError: logger.warning( - "cryptography package not installed — TLS disabled " - "(pip install cryptography)" + "cryptography package not installed — TLS disabled (pip install cryptography)" ) return None, None except Exception as e: @@ -114,7 +118,8 @@ def get_cert_fingerprint(cert_path: Path) -> str: def build_server_ssl_context( - cert_path: Path, key_path: Path, + cert_path: Path, + key_path: Path, ) -> ssl.SSLContext: """Build an SSL context for the uvicorn/FastAPI server.""" ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) diff --git a/gently/mesh/transfer/__init__.py b/gently/mesh/transfer/__init__.py index d7ec6818..cf8df0fc 100644 --- a/gently/mesh/transfer/__init__.py +++ b/gently/mesh/transfer/__init__.py @@ -4,8 +4,14 @@ Resumable, authenticated transfers for datasets and model weights. """ -from .models import TransferFile, TransferJob, TransferManifest, TransferStatus, TransferType from .client import TransferClient +from .models import ( + TransferFile, + TransferJob, + TransferManifest, + TransferStatus, + TransferType, +) from .server import TransferService from .tracker import TransferTracker diff --git a/gently/mesh/transfer/client.py b/gently/mesh/transfer/client.py index bdcd3507..066aea08 100644 --- a/gently/mesh/transfer/client.py +++ b/gently/mesh/transfer/client.py @@ -3,15 +3,12 @@ """ import asyncio -import hashlib import logging import time import uuid from pathlib import Path -from typing import List, Optional from ...core.event_bus import EventType, get_event_bus -from ...settings import settings from .models import TransferJob, TransferStatus, TransferType from .protocol import send_file @@ -42,7 +39,7 @@ async def send_dataset( peer_ip: str, peer_port: int, peer_instance_id: str, - file_paths: List[Path], + file_paths: list[Path], session_id: str = "", ) -> TransferJob: """Send dataset files to a peer. @@ -87,7 +84,10 @@ async def send_dataset( reader, writer = await asyncio.open_connection(peer_ip, peer_port) try: success, sha256 = await send_file( - writer, file_path, job.id, auth_token=token, + writer, + file_path, + job.id, + auth_token=token, ) if success: job.bytes_transferred += file_path.stat().st_size diff --git a/gently/mesh/transfer/models.py b/gently/mesh/transfer/models.py index c1534eae..a932d69e 100644 --- a/gently/mesh/transfer/models.py +++ b/gently/mesh/transfer/models.py @@ -2,14 +2,14 @@ Transfer data models. """ -import time from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any class TransferType(str, Enum): """Type of data being transferred.""" + DATASET = "dataset" MODEL_WEIGHTS = "model_weights" SESSION = "session" @@ -17,6 +17,7 @@ class TransferType(str, Enum): class TransferStatus(str, Enum): """Transfer state machine.""" + PENDING = "pending" TRANSFERRING = "transferring" PAUSED = "paused" @@ -28,12 +29,13 @@ class TransferStatus(str, Enum): @dataclass class TransferFile: """A single file in a transfer manifest.""" + relative_path: str = "" total_size: int = 0 sha256: str = "" transferred: int = 0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "relative_path": self.relative_path, "total_size": self.total_size, @@ -42,7 +44,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "TransferFile": + def from_dict(cls, d: dict[str, Any]) -> "TransferFile": return cls( relative_path=d.get("relative_path", ""), total_size=d.get("total_size", 0), @@ -54,11 +56,12 @@ def from_dict(cls, d: Dict[str, Any]) -> "TransferFile": @dataclass class TransferManifest: """List of files to transfer.""" - files: List[TransferFile] = field(default_factory=list) + + files: list[TransferFile] = field(default_factory=list) total_size: int = 0 file_count: int = 0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "files": [f.to_dict() for f in self.files], "total_size": self.total_size, @@ -66,7 +69,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "TransferManifest": + def from_dict(cls, d: dict[str, Any]) -> "TransferManifest": files = [TransferFile.from_dict(f) for f in d.get("files", [])] return cls( files=files, @@ -78,6 +81,7 @@ def from_dict(cls, d: Dict[str, Any]) -> "TransferManifest": @dataclass class TransferJob: """State of a single transfer (send or receive).""" + id: str = "" transfer_type: str = TransferType.DATASET.value status: str = TransferStatus.PENDING.value @@ -103,7 +107,7 @@ def progress_pct(self) -> float: return 0.0 return (self.bytes_transferred / self.total_bytes) * 100 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "id": self.id, "transfer_type": self.transfer_type, @@ -125,7 +129,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "TransferJob": + def from_dict(cls, d: dict[str, Any]) -> "TransferJob": return cls( id=d.get("id", ""), transfer_type=d.get("transfer_type", TransferType.DATASET.value), diff --git a/gently/mesh/transfer/protocol.py b/gently/mesh/transfer/protocol.py index 89af0258..df72c837 100644 --- a/gently/mesh/transfer/protocol.py +++ b/gently/mesh/transfer/protocol.py @@ -22,7 +22,6 @@ import logging import struct from pathlib import Path -from typing import Optional, Tuple from ...settings import settings @@ -39,7 +38,7 @@ async def send_file( auth_token: str = "", offset: int = 0, chunk_size: int = 0, -) -> Tuple[bool, str]: +) -> tuple[bool, str]: """Send a file over a TCP connection. Parameters @@ -108,7 +107,7 @@ async def send_file( async def receive_file( reader: asyncio.StreamReader, dest_dir: Path, -) -> Tuple[Optional[dict], Optional[Path], str]: +) -> tuple[dict | None, Path | None, str]: """Receive a file over a TCP connection. Parameters diff --git a/gently/mesh/transfer/server.py b/gently/mesh/transfer/server.py index ff2cfb2b..9ecf0fb9 100644 --- a/gently/mesh/transfer/server.py +++ b/gently/mesh/transfer/server.py @@ -4,15 +4,12 @@ import asyncio import logging -import time import uuid from pathlib import Path -from typing import Optional from ...core.event_bus import EventType, get_event_bus from ...core.service import Service from ...settings import settings -from .models import TransferJob, TransferStatus from .protocol import receive_file logger = logging.getLogger(__name__) @@ -46,7 +43,7 @@ def __init__( self._dest_dir = dest_dir self._pairing_manager = pairing_manager self._port = port - self._server: Optional[asyncio.AbstractServer] = None + self._server: asyncio.AbstractServer | None = None self._active_transfers: dict = {} async def on_start(self): diff --git a/gently/mesh/transfer/tracker.py b/gently/mesh/transfer/tracker.py index 43f52cb2..be896b5f 100644 --- a/gently/mesh/transfer/tracker.py +++ b/gently/mesh/transfer/tracker.py @@ -5,7 +5,6 @@ import json import logging from pathlib import Path -from typing import Dict, List, Optional from .models import TransferJob, TransferStatus @@ -24,7 +23,7 @@ class TransferTracker: def __init__(self, config_dir: Path): self._config_dir = config_dir self._state_file = config_dir / "mesh_transfers.json" - self._jobs: Dict[str, TransferJob] = {} + self._jobs: dict[str, TransferJob] = {} self._load() def _load(self): @@ -65,20 +64,21 @@ def update_job(self, job_id: str, **kwargs): setattr(job, k, v) self._save() - def get_job(self, job_id: str) -> Optional[TransferJob]: + def get_job(self, job_id: str) -> TransferJob | None: """Get a transfer job by ID.""" return self._jobs.get(job_id) - def list_jobs(self, status: Optional[str] = None) -> List[TransferJob]: + def list_jobs(self, status: str | None = None) -> list[TransferJob]: """List all jobs, optionally filtered by status.""" if status: return [j for j in self._jobs.values() if j.status == status] return list(self._jobs.values()) - def get_resumable(self) -> List[TransferJob]: + def get_resumable(self) -> list[TransferJob]: """Get transfers that were interrupted and can be resumed.""" return [ - j for j in self._jobs.values() + j + for j in self._jobs.values() if j.status == TransferStatus.TRANSFERRING.value and j.bytes_transferred > 0 and j.bytes_transferred < j.total_bytes @@ -87,9 +87,11 @@ def get_resumable(self) -> List[TransferJob]: def cleanup_completed(self, max_age_hours: float = 24.0): """Remove old completed/failed transfers.""" import time + cutoff = time.time() - (max_age_hours * 3600) to_remove = [ - jid for jid, j in self._jobs.items() + jid + for jid, j in self._jobs.items() if j.status in (TransferStatus.COMPLETED.value, TransferStatus.FAILED.value) and j.completed_at > 0 and j.completed_at < cutoff diff --git a/gently/mesh/verse_map.py b/gently/mesh/verse_map.py index 7309f753..15265548 100644 --- a/gently/mesh/verse_map.py +++ b/gently/mesh/verse_map.py @@ -9,13 +9,9 @@ import logging import time from pathlib import Path -from typing import Dict, List, Optional from .models import ( - DatasetAdvertisement, - PeerCapability, PeerInfo, - PeerStatus, PersistedPeer, ) @@ -28,7 +24,7 @@ class VerseMap: def __init__(self, config_dir: Path): self._config_dir = config_dir self._map_file = config_dir / "mesh_verse_map.json" - self._peers: Dict[str, PersistedPeer] = {} + self._peers: dict[str, PersistedPeer] = {} self._load() # ------------------------------------------------------------------ @@ -109,19 +105,19 @@ def on_peer_returned(self, instance_id: str): # Queries # ------------------------------------------------------------------ - def get_all_peers(self) -> List[PersistedPeer]: + def get_all_peers(self) -> list[PersistedPeer]: """All peers, online and offline.""" return list(self._peers.values()) - def get_online_peers(self) -> List[PersistedPeer]: + def get_online_peers(self) -> list[PersistedPeer]: """Only online peers.""" return [p for p in self._peers.values() if p.online] - def get_offline_peers(self) -> List[PersistedPeer]: + def get_offline_peers(self) -> list[PersistedPeer]: """Only offline peers.""" return [p for p in self._peers.values() if not p.online] - def get_peer(self, instance_id: str) -> Optional[PersistedPeer]: + def get_peer(self, instance_id: str) -> PersistedPeer | None: """Get a specific peer by instance_id.""" return self._peers.get(instance_id) @@ -138,27 +134,25 @@ def was_online(self, instance_id: str) -> bool: # Route-finding: sorted online-first, then by last_seen recency # ------------------------------------------------------------------ - def _sorted_peers(self, peers: List[PersistedPeer]) -> List[PersistedPeer]: + def _sorted_peers(self, peers: list[PersistedPeer]) -> list[PersistedPeer]: """Sort peers: online first, then by last_seen descending.""" return sorted(peers, key=lambda p: (not p.online, -p.last_seen)) - def find_gpu_peers(self) -> List[PersistedPeer]: + def find_gpu_peers(self) -> list[PersistedPeer]: """Find peers with GPU capability, best candidates first.""" - results = [ - p for p in self._peers.values() - if p.capabilities.has_gpu or p.capabilities.gpus - ] + results = [p for p in self._peers.values() if p.capabilities.has_gpu or p.capabilities.gpus] return self._sorted_peers(results) - def find_microscope_peers(self) -> List[PersistedPeer]: + def find_microscope_peers(self) -> list[PersistedPeer]: """Find peers with microscope capability.""" results = [ - p for p in self._peers.values() + p + for p in self._peers.values() if p.capabilities.has_microscope or p.capabilities.microscope_connected ] return self._sorted_peers(results) - def find_data_peers(self, session_id: str = None) -> List[PersistedPeer]: + def find_data_peers(self, session_id: str = None) -> list[PersistedPeer]: """Find peers with data, optionally filtering by session.""" results = [] for p in self._peers.values(): @@ -171,7 +165,7 @@ def find_data_peers(self, session_id: str = None) -> List[PersistedPeer]: results.append(p) return self._sorted_peers(results) - def find_resource(self, capability: str) -> List[PersistedPeer]: + def find_resource(self, capability: str) -> list[PersistedPeer]: """Find peers matching a generic capability attribute. The capability string is checked against: diff --git a/gently/ml/__init__.py b/gently/ml/__init__.py index 9c817a33..7b4c2dc1 100644 --- a/gently/ml/__init__.py +++ b/gently/ml/__init__.py @@ -9,6 +9,7 @@ - Federated averaging for distributed training """ +from .architectures import ARCHITECTURE_REGISTRY, get_suitable_architectures from .models import ( DataSplit, MLPipeline, @@ -18,7 +19,6 @@ TrainingRun, TrainingStatus, ) -from .architectures import ARCHITECTURE_REGISTRY, get_suitable_architectures __all__ = [ "ARCHITECTURE_REGISTRY", diff --git a/gently/ml/_train_worker.py b/gently/ml/_train_worker.py index bc905a87..04448de2 100644 --- a/gently/ml/_train_worker.py +++ b/gently/ml/_train_worker.py @@ -43,7 +43,7 @@ def main(): sys.exit(1) try: - import torchvision.models as models + import torchvision.models as models # noqa: F401 except ImportError: _write_progress(progress_file, {"error": "torchvision not installed"}) sys.exit(1) @@ -56,7 +56,7 @@ def main(): sys.exit(1) # Build datasets - from gently.ml.data_loader import GentlyDataset, create_data_splits + from gently.ml.data_loader import create_data_splits architecture = model_config.get("architecture", "resnet18") num_classes = model_config.get("num_classes", 8) @@ -75,8 +75,11 @@ def main(): # Create datasets from labels train_data, val_data, test_data = create_data_splits( - labels_data, data_root, input_size, - train_ratio=0.7, val_ratio=0.15, + labels_data, + data_root, + input_size, + train_ratio=0.7, + val_ratio=0.15, ) train_loader = DataLoader(train_data, batch_size=batch_size, shuffle=True, num_workers=2) @@ -171,17 +174,20 @@ def main(): patience_counter += 1 # Write progress - _write_progress(progress_file, { - "epoch": epoch + 1, - "total_epochs": epochs, - "train_loss": round(train_loss, 4), - "train_accuracy": round(train_acc, 4), - "val_loss": round(val_loss, 4), - "val_accuracy": round(val_acc, 4), - "best_val_accuracy": round(best_val_acc, 4), - "lr": optimizer.param_groups[0]["lr"], - "timestamp": time.time(), - }) + _write_progress( + progress_file, + { + "epoch": epoch + 1, + "total_epochs": epochs, + "train_loss": round(train_loss, 4), + "train_accuracy": round(train_acc, 4), + "val_loss": round(val_loss, 4), + "val_accuracy": round(val_acc, 4), + "best_val_accuracy": round(best_val_acc, 4), + "lr": optimizer.param_groups[0]["lr"], + "timestamp": time.time(), + }, + ) # Early stopping if patience_counter >= early_stopping_patience: @@ -218,7 +224,12 @@ def _build_model(architecture, num_classes, pretrained, input_channels, dropout) if input_channels != 3: old_conv = model.conv1 model.conv1 = nn.Conv2d( - input_channels, 64, kernel_size=7, stride=2, padding=3, bias=False, + input_channels, + 64, + kernel_size=7, + stride=2, + padding=3, + bias=False, ) if pretrained and input_channels == 1: # Average RGB weights for grayscale @@ -241,7 +252,8 @@ def _build_model(architecture, num_classes, pretrained, input_channels, dropout) old_conv = model.features[0][0] out_channels = old_conv.out_channels model.features[0][0] = nn.Conv2d( - input_channels, out_channels, + input_channels, + out_channels, kernel_size=old_conv.kernel_size, stride=old_conv.stride, padding=old_conv.padding, @@ -260,7 +272,8 @@ def _build_model(architecture, num_classes, pretrained, input_channels, dropout) old_conv = model.features[0][0] out_channels = old_conv.out_channels model.features[0][0] = nn.Conv2d( - input_channels, out_channels, + input_channels, + out_channels, kernel_size=old_conv.kernel_size, stride=old_conv.stride, padding=old_conv.padding, @@ -278,7 +291,8 @@ def _build_model(architecture, num_classes, pretrained, input_channels, dropout) old_conv = model.features[0][0] out_channels = old_conv.out_channels model.features[0][0] = nn.Conv2d( - input_channels, out_channels, + input_channels, + out_channels, kernel_size=old_conv.kernel_size, stride=old_conv.stride, padding=old_conv.padding, diff --git a/gently/ml/architectures.py b/gently/ml/architectures.py index 41f4fe77..2c6f91a8 100644 --- a/gently/ml/architectures.py +++ b/gently/ml/architectures.py @@ -5,12 +5,12 @@ for a given task, dataset size, and hardware constraints. """ -from typing import Any, Dict, List +from typing import Any from .models import ModelArchitectureType # Architecture registry: metadata per architecture -ARCHITECTURE_REGISTRY: Dict[str, Dict[str, Any]] = { +ARCHITECTURE_REGISTRY: dict[str, dict[str, Any]] = { ModelArchitectureType.RESNET_18.value: { "name": "ResNet-18", "family": "resnet", @@ -147,7 +147,7 @@ def get_suitable_architectures( dataset_size: int, vram_gb: float, image_type: str = "microscopy", -) -> List[Dict[str, Any]]: +) -> list[dict[str, Any]]: """Filter architectures suitable for given constraints. Parameters diff --git a/gently/ml/data_loader.py b/gently/ml/data_loader.py index 082fae1e..ccf080b0 100644 --- a/gently/ml/data_loader.py +++ b/gently/ml/data_loader.py @@ -2,11 +2,10 @@ GentlyDataset — PyTorch Dataset loading projections + ground_truth from FileStore. """ -import json import logging import random from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any logger = logging.getLogger(__name__) @@ -14,9 +13,11 @@ import numpy as np import torch from torch.utils.data import Dataset + HAS_TORCH = True except ImportError: HAS_TORCH = False + # Stub for import-time safety class Dataset: pass @@ -37,7 +38,7 @@ class GentlyDataset(Dataset): def __init__( self, - samples: List[Tuple[str, int]], + samples: list[tuple[str, int]], input_size: int = 224, augment: bool = False, ): @@ -57,6 +58,7 @@ def __getitem__(self, idx): # Load image try: from PIL import Image + img = Image.open(img_path).convert("L") # grayscale img = img.resize((self.input_size, self.input_size)) img_np = np.array(img, dtype=np.float32) / 255.0 @@ -92,13 +94,13 @@ def _apply_augmentations(self, img: np.ndarray) -> np.ndarray: def create_data_splits( - labels_data: Dict[str, Any], + labels_data: dict[str, Any], data_root: Path, input_size: int = 224, train_ratio: float = 0.7, val_ratio: float = 0.15, random_seed: int = 42, -) -> Tuple: +) -> tuple: """Create train/val/test datasets from a labels file. Parameters @@ -141,15 +143,15 @@ def create_data_splits( val_samples = [] test_samples = [] - for label, items in by_label.items(): + for _label, items in by_label.items(): random.shuffle(items) n = len(items) n_train = max(1, int(n * train_ratio)) n_val = max(1, int(n * val_ratio)) train_samples.extend(items[:n_train]) - val_samples.extend(items[n_train:n_train + n_val]) - test_samples.extend(items[n_train + n_val:]) + val_samples.extend(items[n_train : n_train + n_val]) + test_samples.extend(items[n_train + n_val :]) train_ds = GentlyDataset(train_samples, input_size=input_size, augment=True) val_ds = GentlyDataset(val_samples, input_size=input_size, augment=False) @@ -158,7 +160,7 @@ def create_data_splits( return train_ds, val_ds, test_ds -def build_labels_from_store(gently_store, session_ids: Optional[List[str]] = None) -> Dict: +def build_labels_from_store(gently_store, session_ids: list[str] | None = None) -> dict: """Build a labels dict from FileStore ground truth. Returns @@ -193,13 +195,15 @@ def build_labels_from_store(gently_store, session_ids: Optional[List[str]] = Non try: proj_path = gently_store.get_projection_path(sid, eid, start_tp) if proj_path: - samples.append({ - "path": str(proj_path), - "label": stage_to_idx[stage], - "session_id": sid, - "embryo_id": eid, - "stage": stage, - }) + samples.append( + { + "path": str(proj_path), + "label": stage_to_idx[stage], + "session_id": sid, + "embryo_id": eid, + "stage": stage, + } + ) except Exception: pass except Exception: diff --git a/gently/ml/evaluation.py b/gently/ml/evaluation.py index b03f35b5..deffce4d 100644 --- a/gently/ml/evaluation.py +++ b/gently/ml/evaluation.py @@ -6,7 +6,7 @@ import logging from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any logger = logging.getLogger(__name__) @@ -14,17 +14,18 @@ @dataclass class EvaluationReport: """Complete evaluation report for a trained model.""" + run_id: str = "" accuracy: float = 0.0 - per_stage_precision: Dict[str, float] = field(default_factory=dict) - per_stage_recall: Dict[str, float] = field(default_factory=dict) - per_stage_f1: Dict[str, float] = field(default_factory=dict) - confusion_matrix: List[List[int]] = field(default_factory=list) - class_names: List[str] = field(default_factory=list) + per_stage_precision: dict[str, float] = field(default_factory=dict) + per_stage_recall: dict[str, float] = field(default_factory=dict) + per_stage_f1: dict[str, float] = field(default_factory=dict) + confusion_matrix: list[list[int]] = field(default_factory=list) + class_names: list[str] = field(default_factory=list) total_samples: int = 0 correct: int = 0 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "run_id": self.run_id, "accuracy": self.accuracy, @@ -38,7 +39,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "EvaluationReport": + def from_dict(cls, d: dict[str, Any]) -> "EvaluationReport": return cls( run_id=d.get("run_id", ""), accuracy=d.get("accuracy", 0.0), @@ -66,7 +67,7 @@ def summary(self) -> str: def evaluate_model( model, data_loader, - class_names: List[str], + class_names: list[str], device=None, run_id: str = "", ) -> EvaluationReport: @@ -111,7 +112,7 @@ def evaluate_model( outputs = model(batch_x) _, predicted = outputs.max(1) - for true, pred in zip(batch_y.cpu().tolist(), predicted.cpu().tolist()): + for true, pred in zip(batch_y.cpu().tolist(), predicted.cpu().tolist(), strict=False): cm[true][pred] += 1 total += 1 if true == pred: diff --git a/gently/ml/federated.py b/gently/ml/federated.py index c114b18e..f257bbd5 100644 --- a/gently/ml/federated.py +++ b/gently/ml/federated.py @@ -12,11 +12,9 @@ import asyncio import copy -import json import logging -import time from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from ..core.event_bus import EventType, get_event_bus @@ -24,9 +22,9 @@ def federated_average( - state_dicts: List[Dict[str, Any]], - weights: List[float], -) -> Dict[str, Any]: + state_dicts: list[dict[str, Any]], + weights: list[float], +) -> dict[str, Any]: """Compute weighted average of model state dicts. Parameters @@ -50,7 +48,7 @@ def federated_average( try: import torch except ImportError: - raise ImportError("PyTorch required for federated averaging") + raise ImportError("PyTorch required for federated averaging") from None total_weight = sum(weights) if total_weight == 0: @@ -66,7 +64,7 @@ def federated_average( averaged[key] = torch.zeros_like(state_dicts[0][key], dtype=torch.float32) # Weighted sum - for sd, w in zip(state_dicts, norm_weights): + for sd, w in zip(state_dicts, norm_weights, strict=False): for key in averaged: averaged[key] += sd[key].float() * w @@ -98,14 +96,14 @@ def __init__(self, verse_map, transfer_client=None, peer_client=None): async def run_federated_training( self, pipeline_id: str, - worker_peers: List, + worker_peers: list, initial_weights_path: Path, local_epochs_per_round: int = 5, max_rounds: int = 20, convergence_threshold: float = 0.001, - training_config: Optional[Dict] = None, - model_config: Optional[Dict] = None, - ) -> Dict[str, Any]: + training_config: dict | None = None, + model_config: dict | None = None, + ) -> dict[str, Any]: """Run federated averaging across mesh peers. Parameters @@ -175,10 +173,12 @@ async def run_federated_training( # 3. Federated average state_dicts = [r["state_dict"] for r in worker_results if r.get("state_dict")] - dataset_sizes = [r.get("dataset_size", 1) for r in worker_results if r.get("state_dict")] + dataset_sizes = [ + r.get("dataset_size", 1) for r in worker_results if r.get("state_dict") + ] if state_dicts: - global_state = federated_average(state_dicts, dataset_sizes) + federated_average(state_dicts, dataset_sizes) else: logger.warning(f"Round {round_num}: no state dicts to average") continue @@ -234,13 +234,13 @@ async def run_federated_training( async def _train_workers( self, - workers: List, + workers: list, pipeline_id: str, round_num: int, local_epochs: int, - training_config: Optional[Dict], - model_config: Optional[Dict], - ) -> List[Dict]: + training_config: dict | None, + model_config: dict | None, + ) -> list[dict]: """Send training jobs to all workers and collect results. In production this uses PeerClient to POST /api/ml/train on each @@ -253,18 +253,20 @@ async def _train_workers( for worker in workers: tasks.append( self._train_single_worker( - worker, pipeline_id, round_num, local_epochs, - training_config, model_config, + worker, + pipeline_id, + round_num, + local_epochs, + training_config, + model_config, ) ) completed = await asyncio.gather(*tasks, return_exceptions=True) - for worker, result in zip(workers, completed): + for worker, result in zip(workers, completed, strict=False): if isinstance(result, Exception): - logger.warning( - f"Worker {worker.hostname} failed in round {round_num}: {result}" - ) + logger.warning(f"Worker {worker.hostname} failed in round {round_num}: {result}") continue if result: results.append(result) @@ -277,9 +279,9 @@ async def _train_single_worker( pipeline_id: str, round_num: int, local_epochs: int, - training_config: Optional[Dict], - model_config: Optional[Dict], - ) -> Optional[Dict]: + training_config: dict | None, + model_config: dict | None, + ) -> dict | None: """Train on a single worker peer via HTTP API. Returns worker result dict with state_dict, val_accuracy, dataset_size. @@ -289,7 +291,8 @@ async def _train_single_worker( # Build a PeerInfo for the HTTP client from ..models import PeerInfo - peer = PeerInfo( + + PeerInfo( instance_id=worker.instance_id, hostname=worker.hostname, ip_address=worker.ip_address, diff --git a/gently/ml/models.py b/gently/ml/models.py index 0a5b5c00..dcf689ab 100644 --- a/gently/ml/models.py +++ b/gently/ml/models.py @@ -4,11 +4,12 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any class TrainingStatus(str, Enum): """Status of an ML pipeline or training run.""" + PLANNED = "planned" DATA_PREP = "data_prep" TRAINING = "training" @@ -20,6 +21,7 @@ class TrainingStatus(str, Enum): class ModelArchitectureType(str, Enum): """Supported model architecture families.""" + RESNET_18 = "resnet18" RESNET_50 = "resnet50" EFFICIENTNET_B0 = "efficientnet_b0" @@ -33,6 +35,7 @@ class ModelArchitectureType(str, Enum): @dataclass class ModelConfig: """Configuration for a model architecture.""" + architecture: str = "resnet18" num_classes: int = 8 pretrained: bool = True @@ -41,7 +44,7 @@ class ModelConfig: dropout: float = 0.2 freeze_backbone_epochs: int = 5 # freeze backbone for N epochs, then unfreeze - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "architecture": self.architecture, "num_classes": self.num_classes, @@ -53,7 +56,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "ModelConfig": + def from_dict(cls, d: dict[str, Any]) -> "ModelConfig": return cls( architecture=d.get("architecture", "resnet18"), num_classes=d.get("num_classes", 8), @@ -68,6 +71,7 @@ def from_dict(cls, d: Dict[str, Any]) -> "ModelConfig": @dataclass class TrainingConfig: """Training hyperparameters.""" + batch_size: int = 32 epochs: int = 50 learning_rate: float = 1e-4 @@ -76,13 +80,15 @@ class TrainingConfig: warmup_epochs: int = 5 mixed_precision: bool = True # AMP on A5000 early_stopping_patience: int = 10 - augmentations: List[str] = field(default_factory=lambda: [ - "random_horizontal_flip", - "random_rotation", - "random_brightness", - ]) + augmentations: list[str] = field( + default_factory=lambda: [ + "random_horizontal_flip", + "random_rotation", + "random_brightness", + ] + ) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "batch_size": self.batch_size, "epochs": self.epochs, @@ -96,7 +102,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "TrainingConfig": + def from_dict(cls, d: dict[str, Any]) -> "TrainingConfig": return cls( batch_size=d.get("batch_size", 32), epochs=d.get("epochs", 50), @@ -113,14 +119,15 @@ def from_dict(cls, d: Dict[str, Any]) -> "TrainingConfig": @dataclass class DataSplit: """Defines how data is split for training.""" + train_ratio: float = 0.7 val_ratio: float = 0.15 test_ratio: float = 0.15 stratify_by: str = "stage" # stratify splits by stage label - session_ids: List[str] = field(default_factory=list) + session_ids: list[str] = field(default_factory=list) random_seed: int = 42 - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "train_ratio": self.train_ratio, "val_ratio": self.val_ratio, @@ -131,7 +138,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "DataSplit": + def from_dict(cls, d: dict[str, Any]) -> "DataSplit": return cls( train_ratio=d.get("train_ratio", 0.7), val_ratio=d.get("val_ratio", 0.15), @@ -145,12 +152,13 @@ def from_dict(cls, d: Dict[str, Any]) -> "DataSplit": @dataclass class TrainingRun: """State of a single training run.""" + id: str = "" pipeline_id: str = "" status: str = TrainingStatus.PLANNED.value - model_config: Optional[ModelConfig] = None - training_config: Optional[TrainingConfig] = None - data_split: Optional[DataSplit] = None + model_config: ModelConfig | None = None + training_config: TrainingConfig | None = None + data_split: DataSplit | None = None current_epoch: int = 0 total_epochs: int = 0 train_loss: float = 0.0 @@ -164,7 +172,7 @@ class TrainingRun: completed_at: str = "" error_message: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "id": self.id, "pipeline_id": self.pipeline_id, @@ -187,7 +195,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "TrainingRun": + def from_dict(cls, d: dict[str, Any]) -> "TrainingRun": mc = d.get("model_config") tc = d.get("training_config") ds = d.get("data_split") @@ -216,20 +224,21 @@ def from_dict(cls, d: Dict[str, Any]) -> "TrainingRun": @dataclass class MLPipeline: """Top-level pipeline that coordinates one ML task.""" + id: str = "" campaign_id: str = "" name: str = "" task: str = "embryo_stage_classification" status: str = TrainingStatus.PLANNED.value - model_config: Optional[ModelConfig] = None - data_split: Optional[DataSplit] = None - training_config: Optional[TrainingConfig] = None + model_config: ModelConfig | None = None + data_split: DataSplit | None = None + training_config: TrainingConfig | None = None best_run_id: str = "" best_accuracy: float = 0.0 created_at: str = "" updated_at: str = "" - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: return { "id": self.id, "campaign_id": self.campaign_id, @@ -246,7 +255,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, d: Dict[str, Any]) -> "MLPipeline": + def from_dict(cls, d: dict[str, Any]) -> "MLPipeline": mc = d.get("model_config") ds = d.get("data_split") tc = d.get("training_config") diff --git a/gently/ml/trainer.py b/gently/ml/trainer.py index 67da88cb..2ef764f5 100644 --- a/gently/ml/trainer.py +++ b/gently/ml/trainer.py @@ -9,14 +9,12 @@ import asyncio import json import logging -import os import sys from datetime import datetime from pathlib import Path -from typing import Optional from ..core.event_bus import EventType, get_event_bus -from .models import ModelConfig, TrainingConfig, TrainingRun, TrainingStatus +from .models import TrainingRun, TrainingStatus logger = logging.getLogger(__name__) @@ -33,8 +31,8 @@ class LocalTrainer: def __init__(self, run_dir: Path): self._run_dir = run_dir self._run_dir.mkdir(parents=True, exist_ok=True) - self._process: Optional[asyncio.subprocess.Process] = None - self._monitor_task: Optional[asyncio.Task] = None + self._process: asyncio.subprocess.Process | None = None + self._monitor_task: asyncio.Task | None = None @property def progress_file(self) -> Path: @@ -95,16 +93,16 @@ async def start_training( # Launch subprocess train_script = Path(__file__).parent / "_train_worker.py" self._process = await asyncio.create_subprocess_exec( - sys.executable, str(train_script), str(config_file), + sys.executable, + str(train_script), + str(config_file), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(self._run_dir), ) # Start progress monitor - self._monitor_task = asyncio.create_task( - self._monitor_progress(run.id, run.pipeline_id) - ) + self._monitor_task = asyncio.create_task(self._monitor_progress(run.id, run.pipeline_id)) logger.info(f"Training started: run={run.id}, pid={self._process.pid}") return run @@ -175,7 +173,7 @@ async def cancel(self): if self._monitor_task and not self._monitor_task.done(): self._monitor_task.cancel() - def get_latest_progress(self) -> Optional[dict]: + def get_latest_progress(self) -> dict | None: """Read the last line from progress.jsonl.""" if not self.progress_file.exists(): return None diff --git a/gently/organisms/__init__.py b/gently/organisms/__init__.py index e4f9d522..1db2c317 100644 --- a/gently/organisms/__init__.py +++ b/gently/organisms/__init__.py @@ -16,18 +16,19 @@ import logging import pkgutil from types import ModuleType -from typing import List, Optional logger = logging.getLogger(__name__) -_active_organism: Optional[ModuleType] = None +_active_organism: ModuleType | None = None -def available_organisms() -> List[str]: +def available_organisms() -> list[str]: """Names of the organism plugins shipped under gently.organisms.""" import gently.organisms as _pkg + return sorted( - m.name for m in pkgutil.iter_modules(_pkg.__path__) + m.name + for m in pkgutil.iter_modules(_pkg.__path__) if m.ispkg and not m.name.startswith("_") ) @@ -82,7 +83,6 @@ def get_organism() -> ModuleType: """ if _active_organism is None: raise RuntimeError( - "No organism loaded. Call load_organism() at startup, " - "or set 'organism' in config.yml." + "No organism loaded. Call load_organism() at startup, or set 'organism' in config.yml." ) return _active_organism diff --git a/gently/organisms/celegans/__init__.py b/gently/organisms/celegans/__init__.py index b8d353a6..afd4db01 100644 --- a/gently/organisms/celegans/__init__.py +++ b/gently/organisms/celegans/__init__.py @@ -10,21 +10,45 @@ from pathlib import Path +from .biology import BIOLOGY_KNOWLEDGE +from .detection_defaults import DETECTION_DEFAULTS +from .detector_presets import get_detector_presets +from .perception_prompt import PERCEPTION_SYSTEM_PROMPT from .stages import ( - DevelopmentalStage, - STAGES, STAGE_CRITERIA, + STAGES, TRANSITION_ZONES, - get_transition_zone, - get_adjacent_stages, - get_stage_description, + DevelopmentalStage, format_stage_criteria_for_prompt, + get_adjacent_stages, get_all_criteria_for_prompt, + get_stage_description, + get_transition_zone, ) -from .biology import BIOLOGY_KNOWLEDGE -from .detector_presets import get_detector_presets -from .detection_defaults import DETECTION_DEFAULTS -from .perception_prompt import PERCEPTION_SYSTEM_PROMPT + +__all__ = [ + "BIOLOGY_KNOWLEDGE", + "DETECTION_DEFAULTS", + "get_detector_presets", + "PERCEPTION_SYSTEM_PROMPT", + "STAGE_CRITERIA", + "STAGES", + "TRANSITION_ZONES", + "DevelopmentalStage", + "format_stage_criteria_for_prompt", + "get_adjacent_stages", + "get_all_criteria_for_prompt", + "get_stage_description", + "get_transition_zone", + "ORGANISM_NAME", + "ORGANISM_DISPLAY_NAME", + "SAMPLE_TERM", + "SAMPLE_TERM_PLURAL", + "TERMINAL_STAGES", + "STOP_CONDITIONS", + "PRE_TERMINAL_SPEEDUP_STAGE", + "EXAMPLES_PATH", +] # --- Organism identity --- ORGANISM_NAME = "celegans" diff --git a/gently/organisms/celegans/biology.py b/gently/organisms/celegans/biology.py index be2f44f1..6c47ae70 100644 --- a/gently/organisms/celegans/biology.py +++ b/gently/organisms/celegans/biology.py @@ -8,7 +8,8 @@ BIOLOGY_KNOWLEDGE = """ # C. elegans Embryonic Development -C. elegans embryogenesis is highly stereotyped and invariant, proceeding through well-defined stages: +C. elegans embryogenesis is highly stereotyped and invariant, proceeding through +well-defined stages: ## Key Developmental Stages diff --git a/gently/organisms/celegans/detector_presets.py b/gently/organisms/celegans/detector_presets.py index b2a0be4d..30d8d191 100644 --- a/gently/organisms/celegans/detector_presets.py +++ b/gently/organisms/celegans/detector_presets.py @@ -5,10 +5,8 @@ (hatching, comma stage, pretzel, gastrulation, first division). """ -from typing import Dict - -def get_detector_presets() -> Dict: +def get_detector_presets() -> dict: """ Get predefined detector presets for common C. elegans stages. @@ -18,10 +16,11 @@ def get_detector_presets() -> Dict: Preset detector configurations keyed by event name. """ return { - 'hatching': { - 'name': 'hatching', - 'description': 'Detects when C. elegans embryo hatches from eggshell', - 'prompt': """Analyze this C. elegans embryo image (diSPIM light sheet max projection) and determine if the embryo has HATCHED. + "hatching": { + "name": "hatching", + "description": "Detects when C. elegans embryo hatches from eggshell", + "prompt": """Analyze this C. elegans embryo image (diSPIM light sheet max +projection) and determine if the embryo has HATCHED. TRUE HATCHING looks like (must meet at least one): - Most or all of the worm body is OUTSIDE the eggshell boundary @@ -46,16 +45,16 @@ def get_detector_presets() -> Dict: DETECTED: [YES/NO] CONFIDENCE: [HIGH/MEDIUM/LOW] REASONING: [Brief explanation - specifically state if worm is INSIDE or OUTSIDE the shell]""", - 'use_temporal_context': True, - 'temporal_context_size': 10, - 'confidence_threshold': 'HIGH', - 'stop_timelapse': True, # Auto-stop when hatching detected + "use_temporal_context": True, + "temporal_context_size": 10, + "confidence_threshold": "HIGH", + "stop_timelapse": True, # Auto-stop when hatching detected }, - - 'comma': { - 'name': 'comma', - 'description': 'Detects comma stage (major morphogenesis)', - 'prompt': """Analyze this C. elegans embryo and determine if it has reached the COMMA STAGE. + "comma": { + "name": "comma", + "description": "Detects comma stage (major morphogenesis)", + "prompt": """Analyze this C. elegans embryo and determine if it has reached the +COMMA STAGE. Key characteristics of comma stage (~400 minutes, ~6.5 hours): - Distinct comma or bean shape (ventral curvature) @@ -70,15 +69,15 @@ def get_detector_presets() -> Dict: DETECTED: [YES/NO] CONFIDENCE: [HIGH/MEDIUM/LOW] REASONING: [Brief explanation]""", - 'use_temporal_context': True, - 'temporal_context_size': 5, - 'confidence_threshold': 'MEDIUM' + "use_temporal_context": True, + "temporal_context_size": 5, + "confidence_threshold": "MEDIUM", }, - - 'pretzel': { - 'name': 'pretzel', - 'description': 'Detects pretzel/3-fold stage (highly elongated)', - 'prompt': """Analyze this C. elegans embryo and determine if it has reached the PRETZEL/3-FOLD STAGE. + "pretzel": { + "name": "pretzel", + "description": "Detects pretzel/3-fold stage (highly elongated)", + "prompt": """Analyze this C. elegans embryo and determine if it has reached the +PRETZEL/3-FOLD STAGE. Key characteristics of 3-fold stage (~550 minutes, ~9 hours): - Highly elongated, approximately 3x the eggshell length @@ -93,15 +92,14 @@ def get_detector_presets() -> Dict: DETECTED: [YES/NO] CONFIDENCE: [HIGH/MEDIUM/LOW] REASONING: [Brief explanation]""", - 'use_temporal_context': True, - 'temporal_context_size': 5, - 'confidence_threshold': 'MEDIUM' + "use_temporal_context": True, + "temporal_context_size": 5, + "confidence_threshold": "MEDIUM", }, - - 'gastrulation': { - 'name': 'gastrulation', - 'description': 'Detects onset of gastrulation', - 'prompt': """Analyze this C. elegans embryo and determine if GASTRULATION has begun. + "gastrulation": { + "name": "gastrulation", + "description": "Detects onset of gastrulation", + "prompt": """Analyze this C. elegans embryo and determine if GASTRULATION has begun. Key characteristics of gastrulation (~210 minutes, ~3.5 hours): - Visible internalization of cells (especially E cells - gut precursors) @@ -115,15 +113,15 @@ def get_detector_presets() -> Dict: DETECTED: [YES/NO] CONFIDENCE: [HIGH/MEDIUM/LOW] REASONING: [Brief explanation]""", - 'use_temporal_context': True, - 'temporal_context_size': 5, - 'confidence_threshold': 'MEDIUM' + "use_temporal_context": True, + "temporal_context_size": 5, + "confidence_threshold": "MEDIUM", }, - - 'first_division': { - 'name': 'first_division', - 'description': 'Detects first cell division (1-cell to 2-cell)', - 'prompt': """Analyze this C. elegans embryo and determine if FIRST CELL DIVISION has occurred. + "first_division": { + "name": "first_division", + "description": "Detects first cell division (1-cell to 2-cell)", + "prompt": """Analyze this C. elegans embryo and determine if FIRST CELL DIVISION +has occurred. Key characteristics: - Transition from single large cell to two cells @@ -137,8 +135,8 @@ def get_detector_presets() -> Dict: DETECTED: [YES/NO] CONFIDENCE: [HIGH/MEDIUM/LOW] REASONING: [Brief explanation]""", - 'use_temporal_context': True, - 'temporal_context_size': 3, - 'confidence_threshold': 'HIGH' + "use_temporal_context": True, + "temporal_context_size": 3, + "confidence_threshold": "HIGH", }, } diff --git a/gently/organisms/celegans/developmental_tracker.py b/gently/organisms/celegans/developmental_tracker.py index bd10a01e..1750ba36 100644 --- a/gently/organisms/celegans/developmental_tracker.py +++ b/gently/organisms/celegans/developmental_tracker.py @@ -11,8 +11,8 @@ import logging from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Tuple from enum import Enum +from typing import Any import anthropic @@ -29,6 +29,7 @@ class DevelopmentalStage(str, Enum): distinguish. The perception enum (in stages.py) maps "early" to everything before comma. """ + ONE_CELL = "1-cell" TWO_CELL = "2-cell" FOUR_CELL = "4-cell" @@ -97,12 +98,13 @@ class DevelopmentalStage(str, Enum): @dataclass class HatchingPrediction: """Prediction of time to hatching with confidence interval""" + embryo_id: str current_stage: DevelopmentalStage predicted_minutes: int min_minutes: int # Lower bound (optimistic) max_minutes: int # Upper bound (conservative) - confidence: str # Based on stage classification confidence + confidence: str # Based on stage classification confidence timestamp: datetime = field(default_factory=datetime.now) @property @@ -110,20 +112,20 @@ def predicted_hours(self) -> float: return self.predicted_minutes / 60 @property - def range_hours(self) -> Tuple[float, float]: + def range_hours(self) -> tuple[float, float]: return (self.min_minutes / 60, self.max_minutes / 60) - def to_dict(self) -> Dict: + def to_dict(self) -> dict: return { - 'embryo_id': self.embryo_id, - 'current_stage': self.current_stage.value, - 'predicted_minutes': self.predicted_minutes, - 'predicted_hours': self.predicted_hours, - 'min_minutes': self.min_minutes, - 'max_minutes': self.max_minutes, - 'range_hours': self.range_hours, - 'confidence': self.confidence, - 'timestamp': self.timestamp.isoformat(), + "embryo_id": self.embryo_id, + "current_stage": self.current_stage.value, + "predicted_minutes": self.predicted_minutes, + "predicted_hours": self.predicted_hours, + "min_minutes": self.min_minutes, + "max_minutes": self.max_minutes, + "range_hours": self.range_hours, + "confidence": self.confidence, + "timestamp": self.timestamp.isoformat(), } def __str__(self) -> str: @@ -137,25 +139,27 @@ def __str__(self) -> str: @dataclass class StageClassification: """Result of a stage classification""" + stage: DevelopmentalStage confidence: str # HIGH, MEDIUM, LOW reasoning: str timestamp: datetime = field(default_factory=datetime.now) timepoint: int = 0 - predicted_minutes_to_hatching: Optional[int] = None + predicted_minutes_to_hatching: int | None = None - def to_dict(self) -> Dict: + def to_dict(self) -> dict: return { - 'stage': self.stage.value, - 'confidence': self.confidence, - 'reasoning': self.reasoning, - 'timestamp': self.timestamp.isoformat(), - 'timepoint': self.timepoint, - 'predicted_minutes_to_hatching': self.predicted_minutes_to_hatching, + "stage": self.stage.value, + "confidence": self.confidence, + "reasoning": self.reasoning, + "timestamp": self.timestamp.isoformat(), + "timepoint": self.timepoint, + "predicted_minutes_to_hatching": self.predicted_minutes_to_hatching, } -STAGE_CLASSIFICATION_PROMPT = """Analyze this C. elegans embryo image and determine its DEVELOPMENTAL STAGE. +STAGE_CLASSIFICATION_PROMPT = """Analyze this C. elegans embryo image and determine its +DEVELOPMENTAL STAGE. Stages in order (earliest to latest): - 1-cell: Single cell, spherical, no division @@ -194,7 +198,7 @@ class DevelopmentalTracker: def __init__( self, - claude_client: Optional[anthropic.Anthropic] = None, + claude_client: anthropic.Anthropic | None = None, model: str = settings.models.perception, ): """ @@ -209,14 +213,14 @@ def __init__( self.model = model # Stage history per embryo - self._stage_history: Dict[str, List[StageClassification]] = {} + self._stage_history: dict[str, list[StageClassification]] = {} def classify_stage( self, image_b64: str, embryo_id: str, timepoint: int = 0, - recent_images: Optional[List[Dict]] = None, + recent_images: list[dict] | None = None, ) -> StageClassification: """ Classify the developmental stage of an embryo @@ -242,49 +246,51 @@ def classify_stage( # Add temporal context if available if recent_images and len(recent_images) > 1: - content.append({ - "type": "text", - "text": f"Recent images from {embryo_id} (for temporal context):" - }) - for img in recent_images[:-1]: # All but last - content.append({ + content.append( + { "type": "text", - "text": f"Timepoint {img.get('timepoint', '?')}" - }) - content.append({ - "type": "image", - "source": { - "type": "base64", - "media_type": "image/jpeg", - "data": img['b64_image'] + "text": f"Recent images from {embryo_id} (for temporal context):", + } + ) + for img in recent_images[:-1]: # All but last + content.append({"type": "text", "text": f"Timepoint {img.get('timepoint', '?')}"}) + content.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": img["b64_image"], + }, } - }) + ) # Add current image - content.append({ - "type": "text", - "text": f"CURRENT image (timepoint {timepoint}) - classify this one:" - }) - content.append({ - "type": "image", - "source": { - "type": "base64", - "media_type": "image/jpeg", - "data": image_b64 + content.append( + { + "type": "text", + "text": f"CURRENT image (timepoint {timepoint}) - classify this one:", } - }) + ) + content.append( + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": image_b64, + }, + } + ) # Add prompt - content.append({ - "type": "text", - "text": STAGE_CLASSIFICATION_PROMPT - }) + content.append({"type": "text", "text": STAGE_CLASSIFICATION_PROMPT}) try: response = self.claude.messages.create( model=self.model, max_tokens=500, - messages=[{"role": "user", "content": content}] + messages=[{"role": "user", "content": content}], ) result = self._parse_classification(response.content[0].text) @@ -321,31 +327,31 @@ def _parse_classification(self, response_text: str) -> StageClassification: confidence = "LOW" reasoning = "" - lines = response_text.strip().split('\n') + lines = response_text.strip().split("\n") for line in lines: line = line.strip() - if line.startswith('STAGE:'): - stage_str = line.split(':', 1)[1].strip().lower() + if line.startswith("STAGE:"): + stage_str = line.split(":", 1)[1].strip().lower() # Map to enum stage = self._parse_stage_name(stage_str) - elif line.startswith('CONFIDENCE:'): - confidence = line.split(':', 1)[1].strip().upper() - elif line.startswith('REASONING:'): - reasoning = line.split(':', 1)[1].strip() + elif line.startswith("CONFIDENCE:"): + confidence = line.split(":", 1)[1].strip().upper() + elif line.startswith("REASONING:"): + reasoning = line.split(":", 1)[1].strip() # Capture multi-line reasoning if not reasoning: in_reasoning = False reasoning_lines = [] for line in lines: - if line.startswith('REASONING:'): + if line.startswith("REASONING:"): in_reasoning = True - reasoning_lines.append(line.split(':', 1)[1].strip()) + reasoning_lines.append(line.split(":", 1)[1].strip()) elif in_reasoning and line: reasoning_lines.append(line) if reasoning_lines: - reasoning = ' '.join(reasoning_lines) + reasoning = " ".join(reasoning_lines) return StageClassification( stage=stage, @@ -359,43 +365,43 @@ def _parse_stage_name(self, name: str) -> DevelopmentalStage: # Direct matches mappings = { - '1-cell': DevelopmentalStage.ONE_CELL, - 'one-cell': DevelopmentalStage.ONE_CELL, - '2-cell': DevelopmentalStage.TWO_CELL, - 'two-cell': DevelopmentalStage.TWO_CELL, - '4-cell': DevelopmentalStage.FOUR_CELL, - 'four-cell': DevelopmentalStage.FOUR_CELL, - '8-cell': DevelopmentalStage.EIGHT_CELL, - 'eight-cell': DevelopmentalStage.EIGHT_CELL, - 'gastrulation': DevelopmentalStage.GASTRULATION, - 'comma': DevelopmentalStage.COMMA, - '1.5-fold': DevelopmentalStage.ONE_POINT_FIVE_FOLD, - '1.5 fold': DevelopmentalStage.ONE_POINT_FIVE_FOLD, - '2-fold': DevelopmentalStage.TWO_FOLD, - '2 fold': DevelopmentalStage.TWO_FOLD, - 'pretzel': DevelopmentalStage.PRETZEL, - '3-fold': DevelopmentalStage.PRETZEL, - '3 fold': DevelopmentalStage.PRETZEL, - 'pre-hatching': DevelopmentalStage.PRE_HATCHING, - 'prehatching': DevelopmentalStage.PRE_HATCHING, - 'hatching': DevelopmentalStage.HATCHING, - 'hatched': DevelopmentalStage.HATCHED, - 'dead': DevelopmentalStage.DEAD, - 'unknown': DevelopmentalStage.UNKNOWN, + "1-cell": DevelopmentalStage.ONE_CELL, + "one-cell": DevelopmentalStage.ONE_CELL, + "2-cell": DevelopmentalStage.TWO_CELL, + "two-cell": DevelopmentalStage.TWO_CELL, + "4-cell": DevelopmentalStage.FOUR_CELL, + "four-cell": DevelopmentalStage.FOUR_CELL, + "8-cell": DevelopmentalStage.EIGHT_CELL, + "eight-cell": DevelopmentalStage.EIGHT_CELL, + "gastrulation": DevelopmentalStage.GASTRULATION, + "comma": DevelopmentalStage.COMMA, + "1.5-fold": DevelopmentalStage.ONE_POINT_FIVE_FOLD, + "1.5 fold": DevelopmentalStage.ONE_POINT_FIVE_FOLD, + "2-fold": DevelopmentalStage.TWO_FOLD, + "2 fold": DevelopmentalStage.TWO_FOLD, + "pretzel": DevelopmentalStage.PRETZEL, + "3-fold": DevelopmentalStage.PRETZEL, + "3 fold": DevelopmentalStage.PRETZEL, + "pre-hatching": DevelopmentalStage.PRE_HATCHING, + "prehatching": DevelopmentalStage.PRE_HATCHING, + "hatching": DevelopmentalStage.HATCHING, + "hatched": DevelopmentalStage.HATCHED, + "dead": DevelopmentalStage.DEAD, + "unknown": DevelopmentalStage.UNKNOWN, } return mappings.get(name, DevelopmentalStage.UNKNOWN) - def get_stage_history(self, embryo_id: str) -> List[StageClassification]: + def get_stage_history(self, embryo_id: str) -> list[StageClassification]: """Get stage classification history for an embryo""" return self._stage_history.get(embryo_id, []) - def get_current_stage(self, embryo_id: str) -> Optional[StageClassification]: + def get_current_stage(self, embryo_id: str) -> StageClassification | None: """Get the most recent stage classification""" history = self._stage_history.get(embryo_id, []) return history[-1] if history else None - def predict_time_to_hatching(self, embryo_id: str) -> Optional[timedelta]: + def predict_time_to_hatching(self, embryo_id: str) -> timedelta | None: """ Predict time to hatching based on current stage @@ -422,7 +428,7 @@ def predict_time_to_stage( self, embryo_id: str, target_stage: DevelopmentalStage, - ) -> Optional[timedelta]: + ) -> timedelta | None: """ Predict time until embryo reaches target stage @@ -457,7 +463,7 @@ def predict_time_to_stage( minutes = target_timing - current_timing return timedelta(minutes=minutes) - def get_progression_summary(self, embryo_id: str) -> Dict[str, Any]: + def get_progression_summary(self, embryo_id: str) -> dict[str, Any]: """ Get a summary of stage progression for an embryo @@ -475,28 +481,28 @@ def get_progression_summary(self, embryo_id: str) -> Dict[str, Any]: if not history: return { - 'embryo_id': embryo_id, - 'observations': 0, - 'current_stage': None, - 'stages_observed': [], - 'predicted_hatching': None, + "embryo_id": embryo_id, + "observations": 0, + "current_stage": None, + "stages_observed": [], + "predicted_hatching": None, } current = history[-1] stages_observed = list(set(h.stage.value for h in history)) return { - 'embryo_id': embryo_id, - 'observations': len(history), - 'current_stage': current.stage.value, - 'current_confidence': current.confidence, - 'stages_observed': stages_observed, - 'first_observation': history[0].timestamp.isoformat(), - 'last_observation': current.timestamp.isoformat(), - 'predicted_minutes_to_hatching': current.predicted_minutes_to_hatching, + "embryo_id": embryo_id, + "observations": len(history), + "current_stage": current.stage.value, + "current_confidence": current.confidence, + "stages_observed": stages_observed, + "first_observation": history[0].timestamp.isoformat(), + "last_observation": current.timestamp.isoformat(), + "predicted_minutes_to_hatching": current.predicted_minutes_to_hatching, } - def get_hatching_prediction(self, embryo_id: str) -> Optional[HatchingPrediction]: + def get_hatching_prediction(self, embryo_id: str) -> HatchingPrediction | None: """ Get detailed hatching prediction with confidence interval @@ -525,9 +531,9 @@ def get_hatching_prediction(self, embryo_id: str) -> Optional[HatchingPrediction # Adjust confidence interval based on classification confidence confidence_multiplier = { - 'HIGH': 1.0, - 'MEDIUM': 1.5, - 'LOW': 2.0, + "HIGH": 1.0, + "MEDIUM": 1.5, + "LOW": 2.0, }.get(current.confidence, 2.0) adjusted_variability = int(variability * confidence_multiplier) @@ -541,7 +547,7 @@ def get_hatching_prediction(self, embryo_id: str) -> Optional[HatchingPrediction confidence=current.confidence, ) - def get_all_predictions(self, embryo_ids: List[str]) -> Dict[str, HatchingPrediction]: + def get_all_predictions(self, embryo_ids: list[str]) -> dict[str, HatchingPrediction]: """ Get predictions for multiple embryos @@ -562,7 +568,7 @@ def get_all_predictions(self, embryo_ids: List[str]) -> Dict[str, HatchingPredic predictions[embryo_id] = pred return predictions - def estimate_development_rate(self, embryo_id: str) -> Optional[float]: + def estimate_development_rate(self, embryo_id: str) -> float | None: """ Estimate relative development rate compared to standard @@ -584,7 +590,9 @@ def estimate_development_rate(self, embryo_id: str) -> Optional[float]: return None # Need at least two different stages - stages_seen = [(h.stage, h.timestamp) for h in history if h.stage != DevelopmentalStage.UNKNOWN] + stages_seen = [ + (h.stage, h.timestamp) for h in history if h.stage != DevelopmentalStage.UNKNOWN + ] if len(stages_seen) < 2: return None diff --git a/gently/organisms/celegans/perception_prompt.py b/gently/organisms/celegans/perception_prompt.py index 2604a6c7..895fa3e6 100644 --- a/gently/organisms/celegans/perception_prompt.py +++ b/gently/organisms/celegans/perception_prompt.py @@ -5,7 +5,8 @@ Extracted from gently/agent/perception/engine.py. """ -PERCEPTION_SYSTEM_PROMPT = """You are an expert microscopy perception system analyzing C. elegans embryo development. +PERCEPTION_SYSTEM_PROMPT = """You are an expert microscopy perception system analyzing +C. elegans embryo development. IMPORTANT PRINCIPLES: 1. DESCRIBE FIRST: Always describe what you actually see BEFORE classifying @@ -23,27 +24,35 @@ - XY (top-left): Looking DOWN - Best for end asymmetry, ventral indentation, folding - YZ (top-right): Looking from SIDE - Best for body height/thickness -- XZ (bottom): Looking from FRONT - CRITICAL for early->bean transition (look for "peanut" or central constriction) +- XZ (bottom): Looking from FRONT - CRITICAL for early->bean transition (look for "peanut" + or central constriction) -**ALWAYS ANALYZE XZ VIEW**: The XZ view often shows bean-stage features (central constriction, "peanut" shape) BEFORE they're visible in XY. If XZ shows ANY central narrowing or figure-8 appearance, this suggests bean stage even if XY looks symmetric. +**ALWAYS ANALYZE XZ VIEW**: The XZ view often shows bean-stage features (central +constriction, "peanut" shape) BEFORE they're visible in XY. If XZ shows ANY central +narrowing or figure-8 appearance, this suggests bean stage even if XY looks symmetric. DEVELOPMENTAL STAGES: EARLY: Elongated oval (~2:1), SYMMETRIC ENDS, both edges CONVEX, NO central constriction in XZ -BEAN: Even SUBTLE end asymmetry OR central constriction/"peanut" shape in XZ view, edges still CONVEX -COMMA: One edge FLAT or curves INWARD (ventral indentation). XZ shows side-by-side lobes (horizontal figure-8) -1.5-FOLD: Body folding back. XZ shows STACKED horizontal layers (two parallel bands, one above the other) +BEAN: Even SUBTLE end asymmetry OR central constriction/"peanut" shape in XZ view, edges + still CONVEX +COMMA: One edge FLAT or curves INWARD (ventral indentation). XZ shows side-by-side lobes + (horizontal figure-8) +1.5-FOLD: Body folding back. XZ shows STACKED horizontal layers (two parallel bands, one + above the other) 2-FOLD: Body doubled back completely. XZ shows TWO DISTINCT HORIZONTAL LINES with dark gap between PRETZEL: Tightly coiled, 3+ body segments visible as multiple stacked layers HATCHED: Worm exited shell CRITICAL FOR EARLY vs BEAN vs COMMA: - EARLY: Both ends symmetric AND both edges convex AND no central constriction in XZ -- BEAN: ANY of these: subtle end tapering, central constriction in XZ, "peanut" shape - edges still convex +- BEAN: ANY of these: subtle end tapering, central constriction in XZ, "peanut" shape - + edges still convex - COMMA: One edge is flat or curves INWARD (not convex) CRITICAL FOR BEAN/COMMA vs FOLD STAGES (examine XZ view carefully): -The XZ view shows two masses in BOTH bean/comma AND fold stages - the key is their VERTICAL ARRANGEMENT: +The XZ view shows two masses in BOTH bean/comma AND fold stages - the key is their VERTICAL +ARRANGEMENT: BEAN/COMMA XZ: Two lobes at the SAME VERTICAL LEVEL - Lobes are side-by-side horizontally, spanning the same vertical range @@ -67,11 +76,14 @@ - Figure-8 or peanut appearance in any view Mark as TRANSITIONAL (early->bean) or BEAN with appropriate confidence. -SPECIAL: If the field of view is EMPTY (no embryo, no eggshell, only background/debris), return "no_object". +SPECIAL: If the field of view is EMPTY (no embryo, no eggshell, only background/debris), +return "no_object". Respond with JSON: { - "observed_features": {"shape": "...", "curvature": "...", "shell_status": "...", "emergence": "..."}, + "observed_features": { + "shape": "...", "curvature": "...", "shell_status": "...", "emergence": "..." + }, "contrastive_reasoning": {"why_not_previous_stage": "...", "why_not_next_stage": "..."}, "stage": "early|bean|comma|1.5fold|2fold|pretzel|hatching|hatched|arrested|no_object", "is_transitional": true/false, diff --git a/gently/organisms/celegans/stages.py b/gently/organisms/celegans/stages.py index 4ad16970..b75f8d34 100644 --- a/gently/organisms/celegans/stages.py +++ b/gently/organisms/celegans/stages.py @@ -5,7 +5,7 @@ """ from enum import Enum -from typing import List, Dict, Any +from typing import Any class DevelopmentalStage(str, Enum): @@ -21,27 +21,34 @@ class DevelopmentalStage(str, Enum): Special states: - "arrested" is not part of normal progression (dead/arrested embryo) """ - EARLY = "early" # Gastrulation through early morphogenesis, oval shape - BEAN = "bean" # Elongated oval, "bean-shaped", pre-comma curvature - COMMA = "comma" # Clear C-shape, head/tail distinguishable - FOLD_1_5 = "1.5fold" # Elongation, ~1.5x eggshell length - FOLD_2 = "2fold" # Body folded back twice, between 1.5fold and pretzel - PRETZEL = "pretzel" # Tight coil, 3+ body segments (formerly 3fold) - HATCHING = "hatching" # Active emergence, shell breach visible - HATCHED = "hatched" # Fully emerged L1 larva - ARRESTED = "arrested" # Dead or developmentally arrested embryo (special state) - NO_OBJECT = "no_object" # No embryo visible in field of view (special state) + + EARLY = "early" # Gastrulation through early morphogenesis, oval shape + BEAN = "bean" # Elongated oval, "bean-shaped", pre-comma curvature + COMMA = "comma" # Clear C-shape, head/tail distinguishable + FOLD_1_5 = "1.5fold" # Elongation, ~1.5x eggshell length + FOLD_2 = "2fold" # Body folded back twice, between 1.5fold and pretzel + PRETZEL = "pretzel" # Tight coil, 3+ body segments (formerly 3fold) + HATCHING = "hatching" # Active emergence, shell breach visible + HATCHED = "hatched" # Fully emerged L1 larva + ARRESTED = "arrested" # Dead or developmentally arrested embryo (special state) + NO_OBJECT = "no_object" # No embryo visible in field of view (special state) @classmethod - def ordered_list(cls) -> List["DevelopmentalStage"]: + def ordered_list(cls) -> list["DevelopmentalStage"]: """Return stages in developmental order.""" return [ - cls.EARLY, cls.BEAN, cls.COMMA, cls.FOLD_1_5, - cls.FOLD_2, cls.PRETZEL, cls.HATCHING, cls.HATCHED + cls.EARLY, + cls.BEAN, + cls.COMMA, + cls.FOLD_1_5, + cls.FOLD_2, + cls.PRETZEL, + cls.HATCHING, + cls.HATCHED, ] @classmethod - def ordered_values(cls) -> List[str]: + def ordered_values(cls) -> list[str]: """Return stage string values in developmental order.""" return [s.value for s in cls.ordered_list()] @@ -61,7 +68,7 @@ def is_valid(cls, stage: str) -> bool: return stage in cls.all_valid_values() @classmethod - def all_valid_values(cls) -> List[str]: + def all_valid_values(cls) -> list[str]: """Return all valid stage values including special states like 'arrested'.""" return cls.ordered_values() + ["arrested", "no_object"] @@ -97,7 +104,7 @@ def compare(cls, stage_a: str, stage_b: str) -> int: # Each stage has: # - features: what to look for (positive indicators) # - NOT_if: what rules out this stage (negative indicators) -STAGE_CRITERIA: Dict[str, Dict[str, Any]] = { +STAGE_CRITERIA: dict[str, dict[str, Any]] = { "early": { "features": [ "oval/elliptical shape", @@ -249,7 +256,7 @@ def compare(cls, stage_a: str, stage_b: str) -> int: # Transition zones between stages # Used for detecting transitional states and setting expectations for temporal analysis -TRANSITION_ZONES: Dict[str, Dict[str, Any]] = { +TRANSITION_ZONES: dict[str, dict[str, Any]] = { "early_to_bean": { "from_stage": "early", "to_stage": "bean", @@ -325,7 +332,7 @@ def compare(cls, stage_a: str, stage_b: str) -> int: } -def get_transition_zone(from_stage: str, to_stage: str) -> Dict[str, Any]: +def get_transition_zone(from_stage: str, to_stage: str) -> dict[str, Any]: """Get transition zone info between two stages.""" key = f"{from_stage}_to_{to_stage}" return TRANSITION_ZONES.get(key, {}) diff --git a/gently/settings.py b/gently/settings.py index 5a0bdb89..68cebd38 100644 --- a/gently/settings.py +++ b/gently/settings.py @@ -4,6 +4,7 @@ All configurable values live here. Override via environment variables prefixed with GENTLY_ (e.g., GENTLY_VIZ_PORT=9090). """ + import os from dataclasses import dataclass, field from pathlib import Path @@ -29,6 +30,7 @@ def _env(key: str, default): @dataclass(frozen=True) class NetworkSettings: """Ports, hosts, and bind addresses.""" + viz_port: int = field(default_factory=lambda: _env("VIZ_PORT", 8080)) viz_host: str = field(default_factory=lambda: _env("VIZ_HOST", "0.0.0.0")) device_port: int = field(default_factory=lambda: _env("DEVICE_PORT", 60610)) @@ -40,7 +42,10 @@ class NetworkSettings: @dataclass(frozen=True) class MeshSettings: """Mesh networking parameters.""" - broadcast_interval_s: float = field(default_factory=lambda: _env("MESH_BROADCAST_INTERVAL", 5.0)) + + broadcast_interval_s: float = field( + default_factory=lambda: _env("MESH_BROADCAST_INTERVAL", 5.0) + ) replay_window_s: float = field(default_factory=lambda: _env("MESH_REPLAY_WINDOW", 30.0)) reaper_interval_s: float = field(default_factory=lambda: _env("MESH_REAPER_INTERVAL", 10.0)) status_refresh_s: float = field(default_factory=lambda: _env("MESH_STATUS_REFRESH", 30.0)) @@ -52,8 +57,11 @@ class MeshSettings: @dataclass(frozen=True) class ModelSettings: """Claude model identifiers.""" + main: str = field(default_factory=lambda: _env("MODEL_MAIN", "claude-opus-4-6")) - perception: str = field(default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-5-20251101")) + perception: str = field( + default_factory=lambda: _env("MODEL_PERCEPTION", "claude-opus-4-5-20251101") + ) fast: str = field(default_factory=lambda: _env("MODEL_FAST", "claude-haiku-4-5-20251001")) medium: str = field(default_factory=lambda: _env("MODEL_MEDIUM", "claude-sonnet-4-5-20250929")) @@ -61,6 +69,7 @@ class ModelSettings: @dataclass(frozen=True) class StorageSettings: """File paths for data storage.""" + base_path: Path = field(default_factory=lambda: _env("STORAGE_PATH", Path("D:/Gently3"))) @property @@ -75,6 +84,7 @@ def traces_dir(self) -> Path: @dataclass(frozen=True) class TimeoutSettings: """Timeout values in seconds.""" + plan_execution: int = field(default_factory=lambda: _env("TIMEOUT_PLAN", 300)) rpc_call: int = field(default_factory=lambda: _env("TIMEOUT_RPC", 60)) volume_acquisition: int = field(default_factory=lambda: _env("TIMEOUT_VOLUME", 15)) @@ -84,6 +94,7 @@ class TimeoutSettings: @dataclass(frozen=True) class ApiSettings: """External API configuration.""" + ncbi_tool: str = field(default_factory=lambda: _env("NCBI_TOOL", "gently")) ncbi_email: str = field(default_factory=lambda: _env("NCBI_EMAIL", "pskeshu@gmail.com")) @@ -91,6 +102,7 @@ class ApiSettings: @dataclass(frozen=True) class MlSettings: """Machine learning training parameters.""" + model_cache_dir: Path = field(default_factory=lambda: _env("ML_MODEL_CACHE", Path("models"))) default_batch_size: int = field(default_factory=lambda: _env("ML_BATCH_SIZE", 32)) default_epochs: int = field(default_factory=lambda: _env("ML_EPOCHS", 50)) @@ -100,14 +112,18 @@ class MlSettings: @dataclass(frozen=True) class TransferSettings: """Bulk transfer protocol parameters.""" + transfer_port: int = field(default_factory=lambda: _env("TRANSFER_PORT", 19548)) chunk_size: int = field(default_factory=lambda: _env("TRANSFER_CHUNK_SIZE", 1048576)) # 1MB - max_concurrent_transfers: int = field(default_factory=lambda: _env("TRANSFER_MAX_CONCURRENT", 4)) + max_concurrent_transfers: int = field( + default_factory=lambda: _env("TRANSFER_MAX_CONCURRENT", 4) + ) @dataclass(frozen=True) class Settings: """Top-level settings container.""" + network: NetworkSettings = field(default_factory=NetworkSettings) mesh: MeshSettings = field(default_factory=MeshSettings) models: ModelSettings = field(default_factory=ModelSettings) diff --git a/gently/ui/web/__init__.py b/gently/ui/web/__init__.py index 1ef5ab2d..ab5bcd57 100644 --- a/gently/ui/web/__init__.py +++ b/gently/ui/web/__init__.py @@ -13,20 +13,23 @@ from .embryo_marker import mark_embryos_web from .plots import ( - generate_focus_curve_plot, generate_calibration_summary_plot, generate_edge_detection_plot, + generate_focus_curve_plot, ) + # Lazy import for server (requires FastAPI) def get_visualization_server(): from .server import VisualizationServer, create_visualization_server + return VisualizationServer, create_visualization_server + __all__ = [ - 'mark_embryos_web', - 'get_visualization_server', - 'generate_focus_curve_plot', - 'generate_calibration_summary_plot', - 'generate_edge_detection_plot', + "mark_embryos_web", + "get_visualization_server", + "generate_focus_curve_plot", + "generate_calibration_summary_plot", + "generate_edge_detection_plot", ] diff --git a/gently/ui/web/accounts.py b/gently/ui/web/accounts.py index 2a534c44..3da1c1a0 100644 --- a/gently/ui/web/accounts.py +++ b/gently/ui/web/accounts.py @@ -27,7 +27,6 @@ import time from datetime import datetime from pathlib import Path -from typing import Optional, Tuple import yaml @@ -72,8 +71,7 @@ def _load_users(self) -> dict: def _save_users(self) -> None: tmp = self.users_path.with_suffix(".yaml.tmp") - tmp.write_text(yaml.safe_dump({"users": self._users}, sort_keys=True), - encoding="utf-8") + tmp.write_text(yaml.safe_dump({"users": self._users}, sort_keys=True), encoding="utf-8") tmp.replace(self.users_path) # atomic def _load_or_create_secret(self) -> bytes: @@ -92,10 +90,11 @@ def has_users(self) -> bool: return bool(self._users) def list_users(self) -> list: - return [{"username": u, "role": r.get("role", "viewer")} - for u, r in sorted(self._users.items())] + return [ + {"username": u, "role": r.get("role", "viewer")} for u, r in sorted(self._users.items()) + ] - def get_role(self, username: str) -> Optional[str]: + def get_role(self, username: str) -> str | None: rec = self._users.get(username) return rec.get("role") if rec else None @@ -118,7 +117,7 @@ def create_user(self, username: str, password: str, role: str = "viewer") -> Non } self._save_users() - def verify_password(self, username: str, password: str) -> Optional[str]: + def verify_password(self, username: str, password: str) -> str | None: """Return the user's role if the password matches, else None.""" rec = self._users.get((username or "").strip()) if not rec: @@ -134,7 +133,7 @@ def verify_password(self, username: str, password: str) -> Optional[str]: return rec.get("role", "viewer") return None - def bootstrap_admin_if_empty(self) -> Optional[Tuple[str, str]]: + def bootstrap_admin_if_empty(self) -> tuple[str, str] | None: """If no users exist, create an admin with a random password. Returns (username, password) so the launcher can print it once, or @@ -150,11 +149,11 @@ def bootstrap_admin_if_empty(self) -> Optional[Tuple[str, str]]: # ── Sessions (stateless signed cookie) ──────────────────── def issue_session(self, username: str, ttl: int = _SESSION_TTL_SECONDS) -> str: expiry = int(time.time()) + ttl - payload = f"{username}|{expiry}".encode("utf-8") + payload = f"{username}|{expiry}".encode() sig = hmac.new(self._secret, payload, hashlib.sha256).digest() return f"{_b64(payload)}.{_b64(sig)}" - def verify_session(self, token: str) -> Optional[str]: + def verify_session(self, token: str) -> str | None: """Return the username for a valid, unexpired token, else None.""" if not token or "." not in token: return None @@ -178,13 +177,13 @@ def verify_session(self, token: str) -> Optional[str]: # ── Module-level singleton (set during server init) ─────────── -_store: Optional[AccountStore] = None +_store: AccountStore | None = None -def set_account_store(store: Optional[AccountStore]) -> None: +def set_account_store(store: AccountStore | None) -> None: global _store _store = store -def get_account_store() -> Optional[AccountStore]: +def get_account_store() -> AccountStore | None: return _store diff --git a/gently/ui/web/auth.py b/gently/ui/web/auth.py index 2f2d9927..f57ccb2d 100644 --- a/gently/ui/web/auth.py +++ b/gently/ui/web/auth.py @@ -36,14 +36,14 @@ async def foo(_=Depends(require_control)): # Header name used to upgrade a remote session to control role (legacy # single-shared-token path, used only when no user accounts are configured). _TOKEN_HEADER = "X-Gently-Token" -_TOKEN_ENV = "GENTLY_CONTROL_TOKEN" +_TOKEN_ENV = "GENTLY_CONTROL_TOKEN" # Browser session cookie set by the login flow (see routes/auth_routes.py). SESSION_COOKIE = "gently_session" class Role(str, Enum): - VIEW = "view" + VIEW = "view" CONTROL = "control" @@ -53,6 +53,7 @@ def current_username(request: Request) -> str | None: None when no account store is configured or the cookie is missing/invalid. """ from gently.ui.web.accounts import get_account_store + store = get_account_store() if store is None: return None @@ -82,7 +83,8 @@ def resolve_role(request: Request) -> Role: GENTLY_CONTROL_TOKEN. This keeps existing single-operator rigs working until an admin provisions accounts. """ - from gently.ui.web.accounts import get_account_store, CONTROL_ROLES + from gently.ui.web.accounts import CONTROL_ROLES, get_account_store + store = get_account_store() if store is not None and store.has_users(): username = current_username(request) @@ -116,11 +118,10 @@ def require_control(request: Request) -> Role: if role is Role.CONTROL: return role host = request.client.host if request.client else "unknown" - logger.warning("control-route 403 for %s -> %s %s", - host, request.method, request.url.path) + logger.warning("control-route 403 for %s -> %s %s", host, request.method, request.url.path) raise HTTPException( status_code=403, detail="control role required (this endpoint moves hardware or " - "mutates persistent state; localhost has it by default, " - "remote callers need X-Gently-Token)", + "mutates persistent state; localhost has it by default, " + "remote callers need X-Gently-Token)", ) diff --git a/gently/ui/web/connection_manager.py b/gently/ui/web/connection_manager.py index 266e9cb2..ffaaf11a 100644 --- a/gently/ui/web/connection_manager.py +++ b/gently/ui/web/connection_manager.py @@ -9,7 +9,6 @@ import json import logging from datetime import datetime -from typing import Dict, Optional from .models import ClientInfo, ImageData @@ -18,6 +17,7 @@ # Optional imports try: from fastapi import WebSocket + FASTAPI_AVAILABLE = True except ImportError: FASTAPI_AVAILABLE = False @@ -28,13 +28,25 @@ class ConnectionManager: # Colors for avatar backgrounds (pleasant, distinct colors) AVATAR_COLORS = [ - '#4a9eff', '#ff6b6b', '#51cf66', '#ffd43b', '#cc5de8', - '#ff922b', '#20c997', '#748ffc', '#f06595', '#69db7c', - '#ffa94d', '#9775fa', '#38d9a9', '#e599f7', '#74c0fc' + "#4a9eff", + "#ff6b6b", + "#51cf66", + "#ffd43b", + "#cc5de8", + "#ff922b", + "#20c997", + "#748ffc", + "#f06595", + "#69db7c", + "#ffa94d", + "#9775fa", + "#38d9a9", + "#e599f7", + "#74c0fc", ] def __init__(self): - self.active_connections: Dict[WebSocket, ClientInfo] = {} + self.active_connections: dict[WebSocket, ClientInfo] = {} self._lock = asyncio.Lock() def _generate_color(self, client_id: str) -> str: @@ -48,6 +60,7 @@ async def connect(self, websocket: WebSocket, client_id: str = None, name: str = # Generate defaults if not provided if not client_id: import uuid + client_id = str(uuid.uuid4())[:8] if not name: name = f"Anonymous {client_id[:4]}" @@ -56,12 +69,14 @@ async def connect(self, websocket: WebSocket, client_id: str = None, name: str = client_id=client_id, name=name, color=self._generate_color(client_id), - connected_at=datetime.now().isoformat() + connected_at=datetime.now().isoformat(), ) async with self._lock: self.active_connections[websocket] = client_info - logger.info(f"WebSocket connected: {name} ({client_id}). Total: {len(self.active_connections)}") + logger.info( + f"WebSocket connected: {name} ({client_id}). Total: {len(self.active_connections)}" + ) # Broadcast updated presence to all clients await self.broadcast_presence() @@ -70,7 +85,9 @@ async def disconnect(self, websocket: WebSocket): async with self._lock: client_info = self.active_connections.pop(websocket, None) if client_info: - logger.info(f"WebSocket disconnected: {client_info.name}. Total: {len(self.active_connections)}") + logger.info( + f"WebSocket disconnected: {client_info.name}. Total: {len(self.active_connections)}" + ) else: logger.info(f"WebSocket disconnected. Total: {len(self.active_connections)}") @@ -86,11 +103,11 @@ async def update_client_name(self, websocket: WebSocket, name: str): client_id=old_info.client_id, name=name, color=old_info.color, - connected_at=old_info.connected_at + connected_at=old_info.connected_at, ) await self.broadcast_presence() - def get_client_info(self, websocket: WebSocket) -> Optional[ClientInfo]: + def get_client_info(self, websocket: WebSocket) -> ClientInfo | None: """Get client info for a websocket""" return self.active_connections.get(websocket) @@ -102,12 +119,12 @@ async def broadcast_presence(self): # Deduplicate by client_id (same user in multiple tabs = one avatar) async with self._lock: seen_clients = {} - for ws, info in self.active_connections.items(): + for _ws, info in self.active_connections.items(): # Keep the most recent entry for each client_id seen_clients[info.client_id] = { - 'client_id': info.client_id, - 'name': info.name, - 'color': info.color + "client_id": info.client_id, + "name": info.name, + "color": info.color, } clients_list = list(seen_clients.values()) @@ -117,14 +134,8 @@ async def broadcast_presence(self): try: personalized = [] for client in clients_list: - personalized.append({ - **client, - 'is_you': client['client_id'] == info.client_id - }) - await ws.send_json({ - 'type': 'presence', - 'clients': personalized - }) + personalized.append({**client, "is_you": client["client_id"] == info.client_id}) + await ws.send_json({"type": "presence", "clients": personalized}) except Exception: disconnected.append(ws) @@ -133,7 +144,7 @@ async def broadcast_presence(self): for ws in disconnected: self.active_connections.pop(ws, None) - async def broadcast(self, message: Dict): + async def broadcast(self, message: dict): """Broadcast message to all connected clients""" if not self.active_connections: return @@ -154,18 +165,19 @@ async def broadcast(self, message: Dict): async def send_image(self, image_data: ImageData): """Send image data to all connected clients""" - await self.broadcast({ - 'type': 'image', - 'data': image_data.to_dict() - }) + await self.broadcast({"type": "image", "data": image_data.to_dict()}) - async def send_event(self, event_type: str, data: Dict, source: str = None, event_id: str = None): + async def send_event( + self, event_type: str, data: dict, source: str = None, event_id: str = None + ): """Send event notification to all clients""" - await self.broadcast({ - 'type': 'event', - 'event_type': event_type, - 'data': data, - 'source': source or 'unknown', - 'event_id': event_id or '', - 'timestamp': datetime.now().isoformat() - }) + await self.broadcast( + { + "type": "event", + "event_type": event_type, + "data": data, + "source": source or "unknown", + "event_id": event_id or "", + "timestamp": datetime.now().isoformat(), + } + ) diff --git a/gently/ui/web/embryo_marker.py b/gently/ui/web/embryo_marker.py index e84b26a4..b6671f71 100644 --- a/gently/ui/web/embryo_marker.py +++ b/gently/ui/web/embryo_marker.py @@ -13,9 +13,7 @@ """ import logging -from typing import List, Dict, Tuple, Optional from pathlib import Path -from datetime import datetime import numpy as np @@ -25,13 +23,13 @@ async def mark_embryos_web( viz_server, image: np.ndarray, - initial_stage_position: Tuple[float, float], + initial_stage_position: tuple[float, float], pixel_size_um: float = 0.65, - timeout: Optional[float] = None, - save_image_path: Optional[Path] = None, - initial_markers: Optional[List[Dict]] = None, + timeout: float | None = None, + save_image_path: Path | None = None, + initial_markers: list[dict] | None = None, default_role: str = "test", -) -> List[Dict]: +) -> list[dict]: """ Interactive embryo marking via the web map view. @@ -86,43 +84,52 @@ async def mark_embryos_web( return embryos -def _save_marked_image(image: np.ndarray, embryos: List[Dict], output_path: Path): +def _save_marked_image(image: np.ndarray, embryos: list[dict], output_path: Path): """Save image with embryo markers drawn on it.""" - from PIL import Image as PILImage, ImageDraw, ImageFont + from PIL import Image as PILImage + from PIL import ImageDraw, ImageFont output_path = Path(output_path) if image.dtype != np.uint8: - img_normalized = ((image - image.min()) / - max(image.max() - image.min(), 1) * 255).astype(np.uint8) + img_normalized = ((image - image.min()) / max(image.max() - image.min(), 1) * 255).astype( + np.uint8 + ) else: img_normalized = image pil_image = PILImage.fromarray(img_normalized) - if pil_image.mode != 'RGB': - pil_image = pil_image.convert('RGB') + if pil_image.mode != "RGB": + pil_image = pil_image.convert("RGB") draw = ImageDraw.Draw(pil_image) for embryo in embryos: - pixel_x, pixel_y = embryo['pixel_position'] - embryo_num = embryo.get('embryo_number') or embryo.get('embryo_id') or '?' + pixel_x, pixel_y = embryo["pixel_position"] + embryo_num = embryo.get("embryo_number") or embryo.get("embryo_id") or "?" marker_size = 20 draw.line( [(pixel_x - marker_size, pixel_y), (pixel_x + marker_size, pixel_y)], - fill=(0, 255, 255), width=3 + fill=(0, 255, 255), + width=3, ) draw.line( [(pixel_x, pixel_y - marker_size), (pixel_x, pixel_y + marker_size)], - fill=(0, 255, 255), width=3 + fill=(0, 255, 255), + width=3, ) circle_radius = 40 draw.ellipse( - [pixel_x - circle_radius, pixel_y - circle_radius, - pixel_x + circle_radius, pixel_y + circle_radius], - outline=(0, 255, 255), width=2 + [ + pixel_x - circle_radius, + pixel_y - circle_radius, + pixel_x + circle_radius, + pixel_y + circle_radius, + ], + outline=(0, 255, 255), + width=2, ) try: @@ -130,8 +137,12 @@ def _save_marked_image(image: np.ndarray, embryos: List[Dict], output_path: Path except Exception: font = ImageFont.load_default() - draw.text((pixel_x - 10, pixel_y + circle_radius + 5), - str(embryo_num), fill=(0, 255, 255), font=font) + draw.text( + (pixel_x - 10, pixel_y + circle_radius + 5), + str(embryo_num), + fill=(0, 255, 255), + font=font, + ) pil_image.save(output_path) logger.info("Saved marked image: %s", output_path) diff --git a/gently/ui/web/image_store.py b/gently/ui/web/image_store.py index d27b236c..09fcd49f 100644 --- a/gently/ui/web/image_store.py +++ b/gently/ui/web/image_store.py @@ -5,11 +5,13 @@ Organized storage for images by type and embryo. """ -from typing import Dict, List, Optional - from .models import ( - ImageData, EmbryoImageCache, Volume3DData, - CALIBRATION_TYPES, VOLUME_TYPES, ANALYSIS_TYPES, + ANALYSIS_TYPES, + CALIBRATION_TYPES, + VOLUME_TYPES, + EmbryoImageCache, + ImageData, + Volume3DData, ) @@ -17,11 +19,11 @@ class ImageStore: """Organized storage for images by type and embryo (unlimited)""" def __init__(self): - self._embryo_caches: Dict[str, EmbryoImageCache] = {} - self._global_images: List[ImageData] = [] # Images without embryo_id - self._calibration_images: List[ImageData] = [] # Global calibration - self._volume_images: List[ImageData] = [] # Global volumes - self._volumes_3d: Dict[str, Volume3DData] = {} # 3D volumes by UID + self._embryo_caches: dict[str, EmbryoImageCache] = {} + self._global_images: list[ImageData] = [] # Images without embryo_id + self._calibration_images: list[ImageData] = [] # Global calibration + self._volume_images: list[ImageData] = [] # Global volumes + self._volumes_3d: dict[str, Volume3DData] = {} # 3D volumes by UID def _get_embryo_cache(self, embryo_id: str) -> EmbryoImageCache: if embryo_id not in self._embryo_caches: @@ -30,7 +32,7 @@ def _get_embryo_cache(self, embryo_id: str) -> EmbryoImageCache: def add_image(self, image: ImageData): """Add image to appropriate storage based on type and embryo""" - embryo_id = image.metadata.get('embryo_id') + embryo_id = image.metadata.get("embryo_id") data_type = image.data_type if data_type in CALIBRATION_TYPES or data_type in ANALYSIS_TYPES: @@ -55,7 +57,7 @@ def add_image(self, image: ImageData): else: self._global_images.append(image) - def get_all_calibration(self, embryo_id: Optional[str] = None) -> List[ImageData]: + def get_all_calibration(self, embryo_id: str | None = None) -> list[ImageData]: """Get calibration images, optionally filtered by embryo""" if embryo_id: cache = self._embryo_caches.get(embryo_id) @@ -66,7 +68,7 @@ def get_all_calibration(self, embryo_id: Optional[str] = None) -> List[ImageData all_cal.extend(cache.calibration) return sorted(all_cal, key=lambda x: x.timestamp) - def get_all_volumes(self, embryo_id: Optional[str] = None) -> List[ImageData]: + def get_all_volumes(self, embryo_id: str | None = None) -> list[ImageData]: """Get volume images, optionally filtered by embryo""" if embryo_id: cache = self._embryo_caches.get(embryo_id) @@ -76,7 +78,7 @@ def get_all_volumes(self, embryo_id: Optional[str] = None) -> List[ImageData]: all_vol.extend(cache.volumes) return sorted(all_vol, key=lambda x: x.timestamp) - def get_all_snapshots(self, embryo_id: Optional[str] = None) -> List[ImageData]: + def get_all_snapshots(self, embryo_id: str | None = None) -> list[ImageData]: """Get snapshot images (including volume projections), optionally filtered by embryo""" if embryo_id: cache = self._embryo_caches.get(embryo_id) @@ -91,11 +93,11 @@ def get_all_snapshots(self, embryo_id: Optional[str] = None) -> List[ImageData]: all_snap.extend(cache.volumes) return sorted(all_snap, key=lambda x: x.timestamp) - def get_embryo_ids(self) -> List[str]: + def get_embryo_ids(self) -> list[str]: """Get list of all embryo IDs with images""" return list(self._embryo_caches.keys()) - def get_image_by_uid(self, uid: str) -> Optional[ImageData]: + def get_image_by_uid(self, uid: str) -> ImageData | None: """Find image by UID across all storage""" for img in self._global_images: if img.uid == uid: @@ -120,11 +122,11 @@ def add_volume_3d(self, volume_data: Volume3DData): oldest_uid = next(iter(self._volumes_3d)) del self._volumes_3d[oldest_uid] - def get_volume_3d(self, uid: str) -> Optional[Volume3DData]: + def get_volume_3d(self, uid: str) -> Volume3DData | None: """Get a 3D volume by UID""" return self._volumes_3d.get(uid) - def get_all_volumes_3d(self) -> List[Dict]: + def get_all_volumes_3d(self) -> list[dict]: """Get info for all 3D volumes (without heavy data)""" return [v.to_info_dict() for v in self._volumes_3d.values()] @@ -132,9 +134,9 @@ def get_sequence( self, embryo_id: str, start: int = 0, - end: Optional[int] = None, - data_type: Optional[str] = None - ) -> List[ImageData]: + end: int | None = None, + data_type: str | None = None, + ) -> list[ImageData]: """Get ordered sequence of images for an embryo within a timepoint range. Args: @@ -158,8 +160,8 @@ def get_sequence( all_images = [img for img in all_images if img.data_type == data_type] # Filter by timepoint range - def get_timepoint(img: ImageData) -> Optional[int]: - tp = img.metadata.get('timepoint') + def get_timepoint(img: ImageData) -> int | None: + tp = img.metadata.get("timepoint") if tp is not None: return int(tp) return None @@ -179,7 +181,7 @@ def get_timepoint(img: ImageData) -> Optional[int]: filtered.sort(key=lambda x: get_timepoint(x) or 0) return filtered - def get_stats(self) -> Dict: + def get_stats(self) -> dict: """Get storage statistics""" total_cal = len(self._calibration_images) total_vol = len(self._volume_images) @@ -191,10 +193,10 @@ def get_stats(self) -> Dict: total_snap += len(cache.snapshots) return { - 'embryo_count': len(self._embryo_caches), - 'calibration_count': total_cal, - 'volume_count': total_vol, - 'snapshot_count': total_snap, - 'volumes_3d_count': len(self._volumes_3d), - 'embryo_ids': list(self._embryo_caches.keys()), + "embryo_count": len(self._embryo_caches), + "calibration_count": total_cal, + "volume_count": total_vol, + "snapshot_count": total_snap, + "volumes_3d_count": len(self._volumes_3d), + "embryo_ids": list(self._embryo_caches.keys()), } diff --git a/gently/ui/web/models.py b/gently/ui/web/models.py index b61147fd..43c05498 100644 --- a/gently/ui/web/models.py +++ b/gently/ui/web/models.py @@ -5,38 +5,47 @@ Dataclasses and type constants used across the visualization package. """ -from dataclasses import dataclass, field, asdict -from typing import Any, Dict, List, Optional +from dataclasses import asdict, dataclass, field +from typing import Any import numpy as np - # Data types for routing to tabs CALIBRATION_TYPES = { - 'focus_sweep', 'focus_plot', 'edge_detection', 'calibration_summary', - 'focus_snap', 'focus_coarse', 'focus_curve', 'focus_assess' + "focus_sweep", + "focus_plot", + "edge_detection", + "calibration_summary", + "focus_snap", + "focus_coarse", + "focus_curve", + "focus_assess", } -VOLUME_TYPES = { - 'volume', 'volume_projection', 'z_stack', 'timelapse' -} +VOLUME_TYPES = {"volume", "volume_projection", "z_stack", "timelapse"} # CV/Analysis types - shown in a separate "Analysis" category within Calibration ANALYSIS_TYPES = { - 'segmentation', 'detection', 'classification', 'tracking', + "segmentation", + "detection", + "classification", + "tracking", # CV agent visualization types - 'roi_detection', 'cropped_roi', 'vision_prepared', 'timeline', 'cv_visualization' + "roi_detection", + "cropped_roi", + "vision_prepared", + "timeline", + "cv_visualization", } # 3D types that support Z-slider browsing -VOLUME_3D_TYPES = { - 'segmentation_3d' -} +VOLUME_3D_TYPES = {"segmentation_3d"} @dataclass class ClientInfo: """Information about a connected WebSocket client for presence tracking""" + client_id: str name: str color: str # Hex color for avatar background @@ -46,13 +55,14 @@ class ClientInfo: @dataclass class Volume3DData: """Container for 3D volume data with segmentation overlay""" + uid: str data_type: str timestamp: str volume: np.ndarray # Original volume (Z, H, W) - masks: np.ndarray # Segmentation masks (Z, H, W) + masks: np.ndarray # Segmentation masks (Z, H, W) colors: np.ndarray # Cell colors (num_labels, 3) - metadata: Dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) @property def num_slices(self) -> int: @@ -89,37 +99,39 @@ def get_slice_overlay(self, z: int, alpha: float = 0.4) -> np.ndarray: return rgb - def to_info_dict(self) -> Dict: + def to_info_dict(self) -> dict: """Return metadata without the heavy arrays""" return { - 'uid': self.uid, - 'data_type': self.data_type, - 'timestamp': self.timestamp, - 'shape': list(self.shape), - 'num_slices': self.num_slices, - 'num_cells': int(self.masks.max()), - 'metadata': self.metadata + "uid": self.uid, + "data_type": self.data_type, + "timestamp": self.timestamp, + "shape": list(self.shape), + "num_slices": self.num_slices, + "num_cells": int(self.masks.max()), + "metadata": self.metadata, } @dataclass class ImageData: """Container for image data sent to clients""" + uid: str data_type: str # 'volume', 'projection', 'snapshot', 'detection', 'focus_sweep', etc. timestamp: str - metadata: Dict[str, Any] = field(default_factory=dict) - base64_png: Optional[str] = None - shape: Optional[tuple] = None + metadata: dict[str, Any] = field(default_factory=dict) + base64_png: str | None = None + shape: tuple | None = None - def to_dict(self) -> Dict: + def to_dict(self) -> dict: return asdict(self) @dataclass class EmbryoImageCache: """Per-embryo image organization""" + embryo_id: str - volumes: List[ImageData] = field(default_factory=list) - calibration: List[ImageData] = field(default_factory=list) - snapshots: List[ImageData] = field(default_factory=list) + volumes: list[ImageData] = field(default_factory=list) + calibration: list[ImageData] = field(default_factory=list) + snapshots: list[ImageData] = field(default_factory=list) diff --git a/gently/ui/web/plots.py b/gently/ui/web/plots.py index 437f792d..45575f6d 100644 --- a/gently/ui/web/plots.py +++ b/gently/ui/web/plots.py @@ -5,11 +5,10 @@ Uses matplotlib with Agg backend for thread safety. """ +import matplotlib import numpy as np -from typing import Optional, Tuple, List -import matplotlib -matplotlib.use('Agg') # Non-interactive backend for thread safety +matplotlib.use("Agg") # Non-interactive backend for thread safety import matplotlib.pyplot as plt @@ -17,10 +16,10 @@ def generate_focus_curve_plot( positions: np.ndarray, scores: np.ndarray, best_position: float, - fit_params: Optional[np.ndarray] = None, + fit_params: np.ndarray | None = None, r_squared: float = 0.0, title: str = "Focus Curve", - figsize: Tuple[int, int] = (6, 4), + figsize: tuple[int, int] = (6, 4), dpi: int = 100, ) -> np.ndarray: """ @@ -53,24 +52,34 @@ def generate_focus_curve_plot( fig, ax = plt.subplots(figsize=figsize, dpi=dpi) # Data points - ax.scatter(positions, scores, c='#2196F3', s=50, zorder=3, label='Measurements') + ax.scatter(positions, scores, c="#2196F3", s=50, zorder=3, label="Measurements") # Gaussian fit curve if fit_params is not None and len(fit_params) >= 4: a, mu, sigma, c = fit_params[:4] x_fit = np.linspace(positions.min(), positions.max(), 200) - y_fit = a * np.exp(-((x_fit - mu) ** 2) / (2 * sigma ** 2)) + c - ax.plot(x_fit, y_fit, color='#F44336', linewidth=2, - label=f'Gaussian fit (R²={r_squared:.3f})') + y_fit = a * np.exp(-((x_fit - mu) ** 2) / (2 * sigma**2)) + c + ax.plot( + x_fit, + y_fit, + color="#F44336", + linewidth=2, + label=f"Gaussian fit (R²={r_squared:.3f})", + ) # Best position marker - ax.axvline(best_position, color='#4CAF50', linestyle='--', linewidth=2, - label=f'Best: {best_position:.2f} µm') + ax.axvline( + best_position, + color="#4CAF50", + linestyle="--", + linewidth=2, + label=f"Best: {best_position:.2f} µm", + ) - ax.set_xlabel('Piezo Position (µm)', fontsize=11) - ax.set_ylabel('Focus Score', fontsize=11) - ax.set_title(title, fontsize=12, fontweight='bold') - ax.legend(loc='upper right', framealpha=0.9) + ax.set_xlabel("Piezo Position (µm)", fontsize=11) + ax.set_ylabel("Focus Score", fontsize=11) + ax.set_title(title, fontsize=12, fontweight="bold") + ax.legend(loc="upper right", framealpha=0.9) ax.grid(True, alpha=0.3) # Tight layout @@ -94,7 +103,7 @@ def generate_calibration_summary_plot( offset: float, r_squared_top: float = 0.0, r_squared_bottom: float = 0.0, - figsize: Tuple[int, int] = (7, 5), + figsize: tuple[int, int] = (7, 5), dpi: int = 100, ) -> np.ndarray: """ @@ -135,32 +144,46 @@ def generate_calibration_summary_plot( # Calibration points galvos = [galvo_top, galvo_bottom] piezos = [piezo_top, piezo_bottom] - ax.scatter(galvos, piezos, c='#2196F3', s=100, zorder=3, - label='Calibration points') + ax.scatter(galvos, piezos, c="#2196F3", s=100, zorder=3, label="Calibration points") # Linear fit line margin = 0.05 galvo_range = np.linspace( min(galvo_top, galvo_bottom) - margin, max(galvo_top, galvo_bottom) + margin, - 100 + 100, ) piezo_fit = slope * galvo_range + offset - ax.plot(galvo_range, piezo_fit, color='#F44336', linewidth=2, - label=f'Linear fit: piezo = {slope:.1f}·galvo + {offset:.1f}') + ax.plot( + galvo_range, + piezo_fit, + color="#F44336", + linewidth=2, + label=f"Linear fit: piezo = {slope:.1f}·galvo + {offset:.1f}", + ) # Annotations - ax.annotate(f'Top\nR²={r_squared_top:.3f}', - (galvo_top, piezo_top), textcoords="offset points", - xytext=(10, 10), fontsize=9, color='#666') - ax.annotate(f'Bottom\nR²={r_squared_bottom:.3f}', - (galvo_bottom, piezo_bottom), textcoords="offset points", - xytext=(10, -20), fontsize=9, color='#666') - - ax.set_xlabel('Galvo Position (degrees)', fontsize=11) - ax.set_ylabel('Piezo Position (µm)', fontsize=11) - ax.set_title(f'{embryo_id} - Piezo-Galvo Calibration', fontsize=12, fontweight='bold') - ax.legend(loc='upper left', framealpha=0.9) + ax.annotate( + f"Top\nR²={r_squared_top:.3f}", + (galvo_top, piezo_top), + textcoords="offset points", + xytext=(10, 10), + fontsize=9, + color="#666", + ) + ax.annotate( + f"Bottom\nR²={r_squared_bottom:.3f}", + (galvo_bottom, piezo_bottom), + textcoords="offset points", + xytext=(10, -20), + fontsize=9, + color="#666", + ) + + ax.set_xlabel("Galvo Position (degrees)", fontsize=11) + ax.set_ylabel("Piezo Position (µm)", fontsize=11) + ax.set_title(f"{embryo_id} - Piezo-Galvo Calibration", fontsize=12, fontweight="bold") + ax.legend(loc="upper left", framealpha=0.9) ax.grid(True, alpha=0.3) fig.tight_layout() @@ -172,12 +195,12 @@ def generate_calibration_summary_plot( def generate_edge_detection_plot( - galvo_positions: List[float], - visibility: List[bool], - edge_top: Optional[float] = None, - edge_bottom: Optional[float] = None, + galvo_positions: list[float], + visibility: list[bool], + edge_top: float | None = None, + edge_bottom: float | None = None, embryo_id: str = "embryo", - figsize: Tuple[int, int] = (6, 4), + figsize: tuple[int, int] = (6, 4), dpi: int = 100, ) -> np.ndarray: """ @@ -211,31 +234,47 @@ def generate_edge_detection_plot( vis_numeric = [1 if v else 0 for v in visibility] # Plot visibility as step function - colors = ['#4CAF50' if v else '#F44336' for v in visibility] + colors = ["#4CAF50" if v else "#F44336" for v in visibility] ax.scatter(galvo_positions, vis_numeric, c=colors, s=80, zorder=3) # Draw step-like connecting lines for i in range(len(galvo_positions) - 1): - color = '#4CAF50' if visibility[i] else '#F44336' - ax.hlines(vis_numeric[i], galvo_positions[i], galvo_positions[i+1], - color=color, alpha=0.3, linewidth=2) + color = "#4CAF50" if visibility[i] else "#F44336" + ax.hlines( + vis_numeric[i], + galvo_positions[i], + galvo_positions[i + 1], + color=color, + alpha=0.3, + linewidth=2, + ) # Mark edges if provided if edge_top is not None: - ax.axvline(edge_top, color='#2196F3', linestyle='--', linewidth=2, - label=f'Top edge: {edge_top:.3f}°') + ax.axvline( + edge_top, + color="#2196F3", + linestyle="--", + linewidth=2, + label=f"Top edge: {edge_top:.3f}°", + ) if edge_bottom is not None: - ax.axvline(edge_bottom, color='#FF9800', linestyle='--', linewidth=2, - label=f'Bottom edge: {edge_bottom:.3f}°') - - ax.set_xlabel('Galvo Position (degrees)', fontsize=11) - ax.set_ylabel('Embryo Visible', fontsize=11) + ax.axvline( + edge_bottom, + color="#FF9800", + linestyle="--", + linewidth=2, + label=f"Bottom edge: {edge_bottom:.3f}°", + ) + + ax.set_xlabel("Galvo Position (degrees)", fontsize=11) + ax.set_ylabel("Embryo Visible", fontsize=11) ax.set_yticks([0, 1]) - ax.set_yticklabels(['No', 'Yes']) - ax.set_title(f'{embryo_id} - Edge Detection', fontsize=12, fontweight='bold') + ax.set_yticklabels(["No", "Yes"]) + ax.set_title(f"{embryo_id} - Edge Detection", fontsize=12, fontweight="bold") if edge_top is not None or edge_bottom is not None: - ax.legend(loc='best', framealpha=0.9) - ax.grid(True, alpha=0.3, axis='x') + ax.legend(loc="best", framealpha=0.9) + ax.grid(True, alpha=0.3, axis="x") fig.tight_layout() fig.canvas.draw() diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 7b76f94d..ebd90770 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -6,17 +6,17 @@ a FastAPI ``APIRouter`` bound to the server instance. """ -from .pages import create_router as create_pages_router -from .sessions import create_router as create_sessions_router -from .images import create_router as create_images_router -from .volumes import create_router as create_volumes_router -from .data import create_router as create_data_router -from .websocket import create_router as create_websocket_router from .agent_ws import create_router as create_agent_ws_router +from .auth_routes import create_router as create_auth_router from .campaigns import create_router as create_campaigns_router from .chat import create_router as create_chat_router +from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router -from .auth_routes import create_router as create_auth_router +from .images import create_router as create_images_router +from .pages import create_router as create_pages_router +from .sessions import create_router as create_sessions_router +from .volumes import create_router as create_volumes_router +from .websocket import create_router as create_websocket_router def register_all_routes(server): diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py index 8f3a3262..fdf2fc5f 100644 --- a/gently/ui/web/routes/agent_ws.py +++ b/gently/ui/web/routes/agent_ws.py @@ -9,8 +9,8 @@ import asyncio import json import logging +from collections.abc import Callable from datetime import datetime -from typing import Callable, Dict, Optional from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -29,7 +29,7 @@ def create_router(server) -> APIRouter: router = APIRouter() # Pending choice futures keyed by request_id - _choice_futures: Dict[str, asyncio.Future] = {} + _choice_futures: dict[str, asyncio.Future] = {} # ── Single-driver control arbitration ───────────────────── # Shared across all /ws/agent clients (the router is created once). @@ -37,11 +37,11 @@ def create_router(server) -> APIRouter: # everyone else is an observer until they take control. This is the # seed of the multi-user control lock and also prevents the shared # agent conversation from being corrupted when >1 client connects. - _control: Dict[str, Optional[str]] = {"holder": None} - _clients: Dict[str, Callable] = {} - _client_labels: Dict[str, str] = {} + _control: dict[str, str | None] = {"holder": None} + _clients: dict[str, Callable] = {} + _client_labels: dict[str, str] = {} _client_counter = {"n": 0} - _raw_clients: Dict[str, WebSocket] = {} # client_id -> websocket (broadcast) + _raw_clients: dict[str, WebSocket] = {} # client_id -> websocket (broadcast) # ── Uniform display transcript ──────────────────────────── # A single conversation history shared by every client of this session. @@ -56,12 +56,14 @@ async def _broadcast_control_status(): holder_label = _client_labels.get(holder) if holder else None for cid, fn in list(_clients.items()): try: - await fn({ - "type": "control_status", - "holder": holder, - "holder_label": holder_label, - "you_have_control": (cid == holder), - }) + await fn( + { + "type": "control_status", + "holder": holder, + "holder_label": holder_label, + "you_have_control": (cid == holder), + } + ) except Exception: pass @@ -101,12 +103,13 @@ def _load_history_for_session(bridge): if not _history and store and sid: try: snap = store.load_session_snapshot(sid) or {} - for m in (snap.get("conversation_history") or []): + for m in snap.get("conversation_history") or []: role = m.get("role") content = m.get("content") if isinstance(content, list): text = "".join( - b.get("text", "") for b in content + b.get("text", "") + for b in content if isinstance(b, dict) and b.get("type") == "text" ) else: @@ -153,8 +156,13 @@ def _record_display(msg): if t == "user_message": _flush_agent_buf() _history_state["autonomous"] = False - _record({"role": "user", "text": msg.get("text", ""), - "author": msg.get("author")}) + _record( + { + "role": "user", + "text": msg.get("text", ""), + "author": msg.get("author"), + } + ) elif t == "autonomous_start": # An autonomous wake turn is beginning — record the trigger banner # and mark following text as autonomous until stream_end. @@ -165,9 +173,14 @@ def _record_display(msg): _history_state["agent_buf"] = (_history_state["agent_buf"] or "") + msg.get("text", "") elif t == "tool_call": _flush_agent_buf() - _record({"role": "tool", "name": msg.get("tool_name"), - "duration": msg.get("duration"), - "summary": msg.get("result_summary")}) + _record( + { + "role": "tool", + "name": msg.get("tool_name"), + "duration": msg.get("duration"), + "summary": msg.get("result_summary"), + } + ) elif t == "stream_end": _flush_agent_buf() _history_state["autonomous"] = False @@ -175,19 +188,21 @@ def _record_display(msg): async def _broadcast(msg): """Record to history + send a display message to ALL clients.""" _record_display(msg) - for cid, ws in list(_raw_clients.items()): + for _cid, ws in list(_raw_clients.items()): try: await ws.send_json(msg) except Exception: pass - async def _run_wizard(wizard, websocket, send_fn, _choice_futures, bridge=None, log_transcript=None): + async def _run_wizard( + wizard, websocket, send_fn, _choice_futures, bridge=None, log_transcript=None + ): """Run the wizard's interactive loop. Returns the wizard task so callers can check for exceptions. Used both at startup and for the /wizard command. """ - _wizard_input_future: Optional[asyncio.Future] = None + _wizard_input_future: asyncio.Future | None = None async def _wizard_wait_for_input() -> str: nonlocal _wizard_input_future @@ -198,11 +213,13 @@ async def _wizard_wait_for_input() -> str: async def _wizard_wait_for_choice(choice_data: dict) -> str: request_id = _make_request_id() choice_data["request_id"] = request_id - await send_fn({ - "type": "choice_request", - "choice_data": choice_data, - "request_id": request_id, - }) + await send_fn( + { + "type": "choice_request", + "choice_data": choice_data, + "request_id": request_id, + } + ) loop = asyncio.get_event_loop() future = loop.create_future() _choice_futures[request_id] = future @@ -215,7 +232,8 @@ async def _wizard_wait_for_choice(choice_data: dict) -> str: while not wizard_task.done(): try: raw = await asyncio.wait_for( - websocket.receive_text(), timeout=60.0, + websocket.receive_text(), + timeout=60.0, ) except asyncio.TimeoutError: await websocket.send_json({"type": "ping"}) @@ -251,12 +269,18 @@ async def _wizard_wait_for_choice(choice_data: dict) -> str: # /reset-context kills the wizard — context is gone if command.strip().lower() == "/reset-context": wizard_task.cancel() - await send_fn({ - "type": "stream_end", - "tokens": {"input_tokens": 0, "output_tokens": 0, - "total_tokens": 0, "api_calls": 0}, - "wizard_complete": True, - }) + await send_fn( + { + "type": "stream_end", + "tokens": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "api_calls": 0, + }, + "wizard_complete": True, + } + ) return wizard_task elif msg_type == "ping": @@ -279,10 +303,12 @@ async def agent_websocket(websocket: WebSocket): bridge = getattr(server, "agent_bridge", None) if bridge is None: - await websocket.send_json({ - "type": "error", - "error": "Agent bridge not initialized", - }) + await websocket.send_json( + { + "type": "error", + "error": "Agent bridge not initialized", + } + ) await websocket.close() return @@ -296,8 +322,9 @@ async def agent_websocket(websocket: WebSocket): # session cookie (set at login). Viewers may watch but not drive; # operators/admins may take the control lock. With no accounts # configured we fall back to the legacy "anyone connected can drive". - from gently.ui.web.accounts import get_account_store, CONTROL_ROLES + from gently.ui.web.accounts import CONTROL_ROLES, get_account_store from gently.ui.web.auth import SESSION_COOKIE + _acct = get_account_store() username = None can_control = True # legacy default when no accounts are configured @@ -369,10 +396,16 @@ async def _push_peer_discovered(event): "_type": "single", "question": f"New peer discovered: {hostname}", "options": [ - {"id": "pair", "label": "Pair", - "description": f"Start pairing with {hostname}"}, - {"id": "ignore", "label": "Ignore", - "description": "Dismiss (you can pair later via /pair)"}, + { + "id": "pair", + "label": "Pair", + "description": f"Start pairing with {hostname}", + }, + { + "id": "ignore", + "label": "Ignore", + "description": "Dismiss (you can pair later via /pair)", + }, ], "allow_multiple": False, }, @@ -421,12 +454,20 @@ async def _push_pairing_requested(event): "type": "choice_request", "choice_data": { "_type": "single", - "question": f"{hostname} wants to pair\nVerify this code matches: {pin}", + "question": ( + f"{hostname} wants to pair\nVerify this code matches: {pin}" + ), "options": [ - {"id": "accept", "label": "Accept pairing", - "description": f"Trust {hostname} and allow mesh communication"}, - {"id": "reject", "label": "Reject", - "description": "Decline this pairing request"}, + { + "id": "accept", + "label": "Accept pairing", + "description": f"Trust {hostname} and allow mesh communication", + }, + { + "id": "reject", + "label": "Reject", + "description": "Decline this pairing request", + }, ], "allow_multiple": False, }, @@ -507,7 +548,9 @@ async def _push_scope_denied(event): pass # Peer discovery - unsub = server.event_bus.subscribe_async(_ET.MESH_PEER_DISCOVERED, _push_peer_discovered) + unsub = server.event_bus.subscribe_async( + _ET.MESH_PEER_DISCOVERED, _push_peer_discovered + ) _mesh_unsubs.append(unsub) unsub = server.event_bus.subscribe_async(_ET.MESH_PEER_LOST, _push_peer_lost) _mesh_unsubs.append(unsub) @@ -515,23 +558,29 @@ async def _push_scope_denied(event): _mesh_unsubs.append(unsub) # Pairing events - unsub = server.event_bus.subscribe_async(_ET.MESH_PAIRING_REQUESTED, _push_pairing_requested) + unsub = server.event_bus.subscribe_async( + _ET.MESH_PAIRING_REQUESTED, _push_pairing_requested + ) _mesh_unsubs.append(unsub) - unsub = server.event_bus.subscribe_async(_ET.MESH_PAIRING_COMPLETED, _push_pairing_completed) + unsub = server.event_bus.subscribe_async( + _ET.MESH_PAIRING_COMPLETED, _push_pairing_completed + ) _mesh_unsubs.append(unsub) # Security events unsub = server.event_bus.subscribe_async(_ET.MESH_AUTH_FAILURE, _push_auth_failure) _mesh_unsubs.append(unsub) - unsub = server.event_bus.subscribe_async(_ET.MESH_CERT_PIN_FAILURE, _push_cert_pin_failure) + unsub = server.event_bus.subscribe_async( + _ET.MESH_CERT_PIN_FAILURE, _push_cert_pin_failure + ) _mesh_unsubs.append(unsub) unsub = server.event_bus.subscribe_async(_ET.MESH_SCOPE_DENIED, _push_scope_denied) _mesh_unsubs.append(unsub) # Active streaming task (so we can cancel on disconnect) - active_task: Optional[asyncio.Task] = None + active_task: asyncio.Task | None = None wizard_task = None - bootstrap_task: Optional[asyncio.Task] = None + bootstrap_task: asyncio.Task | None = None # ── Session transcript ──────────────────────────────── # Log every WebSocket message (both directions) to a JSONL @@ -547,7 +596,9 @@ async def _push_scope_denied(event): sdir = store._session_dir(sid) if sdir and sdir.exists(): _transcript_file = open( - sdir / "transcript.jsonl", "a", encoding="utf-8", + sdir / "transcript.jsonl", + "a", + encoding="utf-8", ) logger.info("Transcript logging to %s", sdir / "transcript.jsonl") except Exception as e: @@ -626,18 +677,28 @@ def _discard_choice(request_id: str) -> None: wizard = getattr(bridge, "_wizard", None) if wizard is not None and wizard.needed and getattr(server, "wizard_autorun", False): wizard_task = await _run_wizard( - wizard, websocket, send_fn, _choice_futures, bridge, + wizard, + websocket, + send_fn, + _choice_futures, + bridge, log_transcript=_log_transcript, ) exc = _handle_wizard_result(wizard_task) if exc: logger.error(f"Wizard error: {exc}", exc_info=exc) - await send_fn({ - "type": "stream_end", - "tokens": {"input_tokens": 0, "output_tokens": 0, - "total_tokens": 0, "api_calls": 0}, - "wizard_complete": True, - }) + await send_fn( + { + "type": "stream_end", + "tokens": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "api_calls": 0, + }, + "wizard_complete": True, + } + ) # ── Auto-briefing or resolution picker ──────────────── # New sessions with multiple unblocked imaging candidates @@ -652,26 +713,33 @@ def _discard_choice(request_id: str) -> None: async def _run_resolution_bootstrap(): try: await bridge.bootstrap_resolution_picker( - send_fn, choice_future_factory, + send_fn, + choice_future_factory, ) except asyncio.CancelledError: raise except Exception as exc: logger.error( - "Resolution picker failed; falling back to " - "static briefing: %s", - exc, exc_info=exc, + "Resolution picker failed; falling back to static briefing: %s", + exc, + exc_info=exc, ) try: briefing = bridge.get_session_briefing() if briefing: await send_fn({"type": "stream_start"}) await send_fn({"type": "text", "text": briefing}) - await send_fn({ - "type": "stream_end", - "tokens": {"input_tokens": 0, "output_tokens": 0, - "total_tokens": 0, "api_calls": 0}, - }) + await send_fn( + { + "type": "stream_end", + "tokens": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "api_calls": 0, + }, + } + ) except Exception: pass @@ -683,17 +751,24 @@ async def _run_resolution_bootstrap(): if briefing: await send_fn({"type": "stream_start"}) await send_fn({"type": "text", "text": briefing}) - await send_fn({ - "type": "stream_end", - "tokens": {"input_tokens": 0, "output_tokens": 0, - "total_tokens": 0, "api_calls": 0}, - }) + await send_fn( + { + "type": "stream_end", + "tokens": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "api_calls": 0, + }, + } + ) # ── Main REPL loop ──────────────────────────────────── while True: try: raw = await asyncio.wait_for( - websocket.receive_text(), timeout=60.0, + websocket.receive_text(), + timeout=60.0, ) except asyncio.TimeoutError: await websocket.send_json({"type": "ping"}) @@ -712,24 +787,28 @@ async def _run_resolution_bootstrap(): # A client requesting the wheel. if msg_type == "take_control": if not can_control: - await send_fn({ - "type": "notification", - "level": "warning", - "title": "View-only role", - "body": "Your account can watch but not control the microscope.", - }) + await send_fn( + { + "type": "notification", + "level": "warning", + "title": "View-only role", + "body": "Your account can watch but not control the microscope.", + } + ) await _broadcast_control_status() continue prev = _control["holder"] _control["holder"] = client_id if prev and prev != client_id and prev in _clients: try: - await _clients[prev]({ - "type": "notification", - "level": "warning", - "title": f"Control taken by {client_label}", - "body": "You are now viewing.", - }) + await _clients[prev]( + { + "type": "notification", + "level": "warning", + "title": f"Control taken by {client_label}", + "body": "You are now viewing.", + } + ) except Exception: pass await _broadcast_control_status() @@ -740,12 +819,14 @@ async def _run_resolution_bootstrap(): # single shared conversation. if msg_type in ("chat", "command", "cancel") and client_id != _control["holder"]: holder_label = _client_labels.get(_control["holder"]) or "another client" - await send_fn({ - "type": "notification", - "level": "info", - "title": f"Viewing only — control is held by {holder_label}", - "body": "Take control to drive the microscope.", - }) + await send_fn( + { + "type": "notification", + "level": "info", + "title": f"Viewing only — control is held by {holder_label}", + "body": "Take control to drive the microscope.", + } + ) await _broadcast_control_status() continue @@ -760,8 +841,7 @@ async def _run_resolution_bootstrap(): # Echo the user's message to ALL clients (so observers see # what was asked), then stream the reply to everyone. - await _broadcast({"type": "user_message", "text": text, - "author": client_label}) + await _broadcast({"type": "user_message", "text": text, "author": client_label}) active_task = asyncio.create_task( bridge.stream_response(text, _broadcast, choice_future_factory) ) @@ -795,11 +875,13 @@ async def _run_resolution_bootstrap(): if command.lower() in ("/wizard",): w = getattr(bridge, "_wizard", None) if w is None: - await send_fn({ - "type": "command_result", - "command": "/wizard", - "error": "Wizard not available", - }) + await send_fn( + { + "type": "command_result", + "command": "/wizard", + "error": "Wizard not available", + } + ) else: # Re-create wizard so it re-assesses gaps cs = getattr(bridge, "_context_store", None) @@ -808,40 +890,60 @@ async def _run_resolution_bootstrap(): w = bridge._wizard # Tell TUI we're entering wizard mode - await send_fn({ - "type": "command_result", - "command": "/wizard", - "content": {"wizard_active": True}, - }) + await send_fn( + { + "type": "command_result", + "command": "/wizard", + "content": {"wizard_active": True}, + } + ) wizard_task = await _run_wizard( - w, websocket, send_fn, _choice_futures, bridge, + w, + websocket, + send_fn, + _choice_futures, + bridge, log_transcript=_log_transcript, ) exc = _handle_wizard_result(wizard_task) if exc: logger.error(f"Wizard error: {exc}", exc_info=exc) - await send_fn({ - "type": "stream_end", - "tokens": {"input_tokens": 0, "output_tokens": 0, - "total_tokens": 0, "api_calls": 0}, - "wizard_complete": True, - }) + await send_fn( + { + "type": "stream_end", + "tokens": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "api_calls": 0, + }, + "wizard_complete": True, + } + ) else: try: - await bridge.handle_command(command, send_fn, choice_futures=_choice_futures) + await bridge.handle_command( + command, send_fn, choice_futures=_choice_futures + ) except Exception as e: logger.error("Command '%s' failed: %s", command, e, exc_info=True) - await send_fn({ - "type": "command_result", - "command": command, - "error": str(e), - }) + await send_fn( + { + "type": "command_result", + "command": command, + "error": str(e), + } + ) elif msg_type == "browse": target = data.get("target", "") await _handle_browse( - target, data, server, bridge, send_fn, + target, + data, + server, + bridge, + send_fn, ) elif msg_type == "ping": @@ -916,13 +1018,20 @@ def serialize_campaign(c): else: children = [] items_raw = cs.get_plan_items(campaign_id=c.id) - items = [{ - "id": item.id, - "title": item.title, - "status": item.status.value if hasattr(item.status, "value") else str(item.status), - "type": item.type.value if hasattr(item.type, "value") else str(item.type), - "claimed_by_hostname": getattr(item, "claimed_by_hostname", None), - } for item in items_raw] + items = [ + { + "id": item.id, + "title": item.title, + "status": item.status.value + if hasattr(item.status, "value") + else str(item.status), + "type": item.type.value + if hasattr(item.type, "value") + else str(item.type), + "claimed_by_hostname": getattr(item, "claimed_by_hostname", None), + } + for item in items_raw + ] return { "id": c.id, "shorthand": c.shorthand or "", @@ -949,17 +1058,19 @@ def serialize_campaign(c): peers = mesh_svc.get_peers() result = [] for p in peers: - result.append({ - "instance_id": p.instance_id, - "hostname": p.hostname, - "ip_address": p.ip_address, - "viz_port": p.viz_port, - "mode": p.status.agent_mode if p.status else "unknown", - "embryo_count": p.status.embryo_count if p.status else 0, - "is_trusted": p.is_trusted, - "tls_enabled": p.tls_enabled, - "shared_campaigns": [], - }) + result.append( + { + "instance_id": p.instance_id, + "hostname": p.hostname, + "ip_address": p.ip_address, + "viz_port": p.viz_port, + "mode": p.status.agent_mode if p.status else "unknown", + "embryo_count": p.status.embryo_count if p.status else 0, + "is_trusted": p.is_trusted, + "tls_enabled": p.tls_enabled, + "shared_campaigns": [], + } + ) await send_fn({"type": "browse_result", "target": "peers", "data": result}) elif target == "peer_campaigns": @@ -981,25 +1092,29 @@ def serialize_campaign(c): campaigns = [] if p.instance_id == peer.instance_id: for c in shared: - campaigns.append({ - "id": c.get("id", ""), - "shorthand": c.get("shorthand", ""), - "description": c.get("description", ""), - "total": c.get("item_count", 0), - "completed": c.get("completed_count", 0), - "items": [], - }) - result.append({ - "instance_id": p.instance_id, - "hostname": p.hostname, - "ip_address": p.ip_address, - "viz_port": p.viz_port, - "mode": p.status.agent_mode if p.status else "unknown", - "embryo_count": p.status.embryo_count if p.status else 0, - "is_trusted": p.is_trusted, - "tls_enabled": p.tls_enabled, - "shared_campaigns": campaigns, - }) + campaigns.append( + { + "id": c.get("id", ""), + "shorthand": c.get("shorthand", ""), + "description": c.get("description", ""), + "total": c.get("item_count", 0), + "completed": c.get("completed_count", 0), + "items": [], + } + ) + result.append( + { + "instance_id": p.instance_id, + "hostname": p.hostname, + "ip_address": p.ip_address, + "viz_port": p.viz_port, + "mode": p.status.agent_mode if p.status else "unknown", + "embryo_count": p.status.embryo_count if p.status else 0, + "is_trusted": p.is_trusted, + "tls_enabled": p.tls_enabled, + "shared_campaigns": campaigns, + } + ) await send_fn({"type": "browse_result", "target": "peer_campaigns", "data": result}) elif target == "peer_campaign_items": @@ -1007,31 +1122,53 @@ def serialize_campaign(c): campaign_id = data.get("campaign_id", "") mesh_svc = getattr(server, "mesh_service", None) if not mesh_svc or not hostname or not campaign_id: - await send_fn({"type": "browse_result", "target": "peer_campaign_items", "data": []}) + await send_fn( + { + "type": "browse_result", + "target": "peer_campaign_items", + "data": [], + } + ) return peer = mesh_svc.find_peer_by_hostname(hostname) if not peer or not mesh_svc.peer_client: - await send_fn({"type": "browse_result", "target": "peer_campaign_items", "data": []}) + await send_fn( + { + "type": "browse_result", + "target": "peer_campaign_items", + "data": [], + } + ) return export = await mesh_svc.peer_client.fetch_campaign_export(peer, campaign_id) if not export: - await send_fn({"type": "browse_result", "target": "peer_campaign_items", "data": []}) + await send_fn( + { + "type": "browse_result", + "target": "peer_campaign_items", + "data": [], + } + ) return items = [] for item in export.get("items", []): - items.append({ - "id": item.get("id", ""), - "title": item.get("title", ""), - "status": item.get("status", "planned"), - "claimed_by_hostname": item.get("claimed_by_hostname"), - }) - await send_fn({ - "type": "browse_result", - "target": "peer_campaign_items", - "data": items, - "campaign_id": campaign_id, - "hostname": hostname, - }) + items.append( + { + "id": item.get("id", ""), + "title": item.get("title", ""), + "status": item.get("status", "planned"), + "claimed_by_hostname": item.get("claimed_by_hostname"), + } + ) + await send_fn( + { + "type": "browse_result", + "target": "peer_campaign_items", + "data": items, + "campaign_id": campaign_id, + "hostname": hostname, + } + ) except Exception as e: logger.debug(f"Browse error ({target}): {e}") diff --git a/gently/ui/web/routes/auth_routes.py b/gently/ui/web/routes/auth_routes.py index dd4e0629..07e338f1 100644 --- a/gently/ui/web/routes/auth_routes.py +++ b/gently/ui/web/routes/auth_routes.py @@ -10,7 +10,12 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse -from gently.ui.web.accounts import get_account_store, CONTROL_ROLES, ROLES, _SESSION_TTL_SECONDS +from gently.ui.web.accounts import ( + _SESSION_TTL_SECONDS, + CONTROL_ROLES, + ROLES, + get_account_store, +) from gently.ui.web.auth import SESSION_COOKIE, current_username logger = logging.getLogger(__name__) @@ -52,9 +57,13 @@ async def login(request: Request): token = store.issue_session(username) resp = JSONResponse({"ok": True, "username": username, "role": role}) resp.set_cookie( - SESSION_COOKIE, token, - httponly=True, samesite="lax", secure=_secure(request), - max_age=_SESSION_TTL_SECONDS, path="/", + SESSION_COOKIE, + token, + httponly=True, + samesite="lax", + secure=_secure(request), + max_age=_SESSION_TTL_SECONDS, + path="/", ) logger.info("login ok: %s (%s)", username, role) return resp @@ -74,11 +83,15 @@ async def me(request: Request): if not username: return JSONResponse({"accounts": True, "authenticated": False}) role = store.get_role(username) - return JSONResponse({ - "accounts": True, "authenticated": True, - "username": username, "role": role, - "can_control": role in CONTROL_ROLES, - }) + return JSONResponse( + { + "accounts": True, + "authenticated": True, + "username": username, + "role": role, + "can_control": role in CONTROL_ROLES, + } + ) @router.post("/api/auth/users") async def create_user(request: Request): diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index e5e59611..a3fea08c 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -4,7 +4,7 @@ import logging from dataclasses import asdict from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request @@ -121,13 +121,13 @@ async def get_campaign_document(campaign_id: str): # Pre-index every item in the tree once. The naive enrichment used to call # cs.get_plan_item(...) per dep + per dependent, each one walking the on-disk # campaign index — O(items × deps × campaigns) YAML reads per request. - items_by_id: Dict[str, Dict] = {} - dependents_map: Dict[str, List[str]] = {} + items_by_id: dict[str, dict] = {} + dependents_map: dict[str, list[str]] = {} def _index(node): for it in node.get("items", []): items_by_id[it["id"]] = it - for dep_id in (it.get("depends_on") or []): + for dep_id in it.get("depends_on") or []: dependents_map.setdefault(dep_id, []).append(it["id"]) for child in node.get("children", []): _index(child) @@ -147,16 +147,12 @@ def _enrich_tree(node): for item in node.get("items", []): item_id = item["id"] dep_ids = list(item.get("depends_on") or []) - item["dependencies"] = [ - {"id": d, "title": _resolve_title(d)} for d in dep_ids - ] + item["dependencies"] = [{"id": d, "title": _resolve_title(d)} for d in dep_ids] dnt_ids = dependents_map.get(item_id, []) - item["dependents"] = [ - {"id": d, "title": _resolve_title(d)} for d in dnt_ids - ] + item["dependents"] = [{"id": d, "title": _resolve_title(d)} for d in dnt_ids] # Collect references into bibliography - for ref in (item.get("references") or []): + for ref in item.get("references") or []: source = ref.get("source", "") key = ref.get("key", ref.get("id", ref.get("title", ""))) dedup_key = (source, key) @@ -227,16 +223,26 @@ async def get_item_detail(campaign_id: str, item_id: str): dependencies = [] for did in dep_ids: dep = cs.get_plan_item(did) - dependencies.append({"id": did, "title": dep.title if dep else did[:8], - "status": dep.status.value if dep else None}) + dependencies.append( + { + "id": did, + "title": dep.title if dep else did[:8], + "status": dep.status.value if dep else None, + } + ) # Dependents with titles dnt_ids = cs.get_plan_item_dependents(item_id) dependents = [] for did in dnt_ids: dnt = cs.get_plan_item(did) - dependents.append({"id": did, "title": dnt.title if dnt else did[:8], - "status": dnt.status.value if dnt else None}) + dependents.append( + { + "id": did, + "title": dnt.title if dnt else did[:8], + "status": dnt.status.value if dnt else None, + } + ) # Sessions linked to this campaign sessions = cs.get_sessions_for_campaign(item.campaign_id) @@ -280,9 +286,12 @@ async def _require(request: Request): if required_scope not in scopes: if _audit: from gently.mesh.audit import AuditEvent + _audit.log( - AuditEvent.SCOPE_DENIED, outcome="deny", - peer_id=peer_id, ip=host, + AuditEvent.SCOPE_DENIED, + outcome="deny", + peer_id=peer_id, + ip=host, detail=f"scope={required_scope} path={request.url.path}", ) raise HTTPException( @@ -291,36 +300,51 @@ async def _require(request: Request): ) if _audit: from gently.mesh.audit import AuditEvent + _audit.log( - AuditEvent.AUTH_SUCCESS, outcome="allow", - peer_id=peer_id, ip=host, + AuditEvent.AUTH_SUCCESS, + outcome="allow", + peer_id=peer_id, + ip=host, ) return if _audit: from gently.mesh.audit import AuditEvent + _audit.log( - AuditEvent.AUTH_FAILURE, outcome="deny", - ip=host, detail=f"path={request.url.path}", + AuditEvent.AUTH_FAILURE, + outcome="deny", + ip=host, + detail=f"path={request.url.path}", ) raise HTTPException(status_code=403, detail="Mesh authentication required") return _require - @router.post("/api/campaigns/{campaign_id}/share", dependencies=[Depends(_make_campaign_auth("campaigns:admin"))]) + @router.post( + "/api/campaigns/{campaign_id}/share", + dependencies=[Depends(_make_campaign_auth("campaigns:admin"))], + ) async def share_campaign(campaign_id: str): cs = _get_store() campaign = _resolve(cs, campaign_id) cs.share_campaign(campaign.id) return {"ok": True} - @router.post("/api/campaigns/{campaign_id}/unshare", dependencies=[Depends(_make_campaign_auth("campaigns:admin"))]) + @router.post( + "/api/campaigns/{campaign_id}/unshare", + dependencies=[Depends(_make_campaign_auth("campaigns:admin"))], + ) async def unshare_campaign(campaign_id: str): cs = _get_store() campaign = _resolve(cs, campaign_id) cs.unshare_campaign(campaign.id) return {"ok": True} - @router.get("/api/campaigns/{campaign_id}/export", dependencies=[Depends(_make_campaign_auth("campaigns"))]) + @router.get( + "/api/campaigns/{campaign_id}/export", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) async def export_campaign(campaign_id: str): cs = _get_store() campaign = _resolve(cs, campaign_id) @@ -328,7 +352,10 @@ async def export_campaign(campaign_id: str): _enrich_export_with_claims(tree, cs, campaign.id) return tree - @router.post("/api/campaigns/{campaign_id}/join", dependencies=[Depends(_make_campaign_auth("campaigns"))]) + @router.post( + "/api/campaigns/{campaign_id}/join", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) async def join_campaign(campaign_id: str, request: Request): cs = _get_store() campaign = _resolve(cs, campaign_id) @@ -340,14 +367,20 @@ async def join_campaign(campaign_id: str, request: Request): cs.add_campaign_participant(campaign.id, instance_id, hostname) return {"ok": True} - @router.get("/api/campaigns/{campaign_id}/participants", dependencies=[Depends(_make_campaign_auth("campaigns"))]) + @router.get( + "/api/campaigns/{campaign_id}/participants", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) async def get_participants(campaign_id: str): cs = _get_store() campaign = _resolve(cs, campaign_id) participants = cs.get_campaign_participants(campaign.id) return {"participants": participants} - @router.post("/api/campaigns/{campaign_id}/items/{item_id}/claim", dependencies=[Depends(_make_campaign_auth("campaigns"))]) + @router.post( + "/api/campaigns/{campaign_id}/items/{item_id}/claim", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) async def claim_item(campaign_id: str, item_id: str, request: Request): cs = _get_store() _resolve(cs, campaign_id) @@ -361,14 +394,20 @@ async def claim_item(campaign_id: str, item_id: str, request: Request): raise HTTPException(status_code=409, detail="Item already claimed by another node") return {"ok": True} - @router.post("/api/campaigns/{campaign_id}/items/{item_id}/unclaim", dependencies=[Depends(_make_campaign_auth("campaigns"))]) + @router.post( + "/api/campaigns/{campaign_id}/items/{item_id}/unclaim", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) async def unclaim_item(campaign_id: str, item_id: str): cs = _get_store() _resolve(cs, campaign_id) cs.unclaim_plan_item(item_id) return {"ok": True} - @router.post("/api/campaigns/{campaign_id}/items/{item_id}/status", dependencies=[Depends(_make_campaign_auth("campaigns"))]) + @router.post( + "/api/campaigns/{campaign_id}/items/{item_id}/status", + dependencies=[Depends(_make_campaign_auth("campaigns"))], + ) async def update_item_status(campaign_id: str, item_id: str, request: Request): cs = _get_store() _resolve(cs, campaign_id) @@ -380,7 +419,7 @@ async def update_item_status(campaign_id: str, item_id: str, request: Request): try: item_status = PlanItemStatus(status_str) except ValueError: - raise HTTPException(status_code=400, detail=f"Invalid status: {status_str}") + raise HTTPException(status_code=400, detail=f"Invalid status: {status_str}") from None cs.update_plan_item(item_id, status=item_status, outcome=outcome) return {"ok": True} @@ -388,7 +427,7 @@ async def update_item_status(campaign_id: str, item_id: str, request: Request): # Helpers # ------------------------------------------------------------------ - def _build_campaign_tree(cs, campaign_id: str) -> Optional[Dict]: + def _build_campaign_tree(cs, campaign_id: str) -> dict | None: """Recursively build campaign tree with plan items and status.""" campaign = cs.get_campaign(campaign_id) if not campaign: @@ -410,13 +449,10 @@ def _build_campaign_tree(cs, campaign_id: str) -> Optional[Dict]: "in_progress": status["in_progress"], "planned": status["planned"], }, - "children": [ - _build_campaign_tree(cs, child.id) - for child in children - ], + "children": [_build_campaign_tree(cs, child.id) for child in children], } - def _enrich_export_with_claims(tree: Dict, cs, campaign_id: str): + def _enrich_export_with_claims(tree: dict, cs, campaign_id: str): """Walk a serialized campaign tree and annotate items with IDs and claim info.""" items = cs.get_plan_items(campaign_id=campaign_id) items.sort(key=lambda x: x.phase_order) diff --git a/gently/ui/web/routes/chat.py b/gently/ui/web/routes/chat.py index 1cdd15f7..8c66dd45 100644 --- a/gently/ui/web/routes/chat.py +++ b/gently/ui/web/routes/chat.py @@ -14,7 +14,6 @@ import logging from datetime import datetime from pathlib import Path -from typing import Optional from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse @@ -45,21 +44,21 @@ class ChatRequest(BaseModel): message: str -def _resolve_session_dir(server, sid: str) -> Optional[Path]: +def _resolve_session_dir(server, sid: str) -> Path | None: store = getattr(server, "gently_store", None) if store is None: return None return store._session_dir(sid) -def _trace_path(server, sid: str, eid: str, tp: int) -> Optional[Path]: +def _trace_path(server, sid: str, eid: str, tp: int) -> Path | None: sd = _resolve_session_dir(server, sid) if sd is None: return None return sd / "embryos" / eid / "traces" / f"t{tp:04d}.json" -def _chat_path(server, sid: str, eid: str, tp: int) -> Optional[Path]: +def _chat_path(server, sid: str, eid: str, tp: int) -> Path | None: sd = _resolve_session_dir(server, sid) if sd is None: return None @@ -70,7 +69,7 @@ def _load_history(path: Path) -> list[dict]: if not path.exists(): return [] turns: list[dict] = [] - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: @@ -108,8 +107,13 @@ async def get_chat(sid: str, eid: str, tp: int): return {"turns": _load_history(path)} @router.post("/api/perception/chat/{sid}/{eid}/{tp}") - async def post_chat(sid: str, eid: str, tp: int, body: ChatRequest, - _control=Depends(require_control)): + async def post_chat( + sid: str, + eid: str, + tp: int, + body: ChatRequest, + _control=Depends(require_control), # noqa: B008 + ): """Append a user message and stream the assistant reply as SSE. Each SSE event is JSON: ``{"type": "delta", "text": "..."}`` for @@ -129,7 +133,7 @@ async def post_chat(sid: str, eid: str, tp: int, body: ChatRequest, detail=f"No perception trace for T{tp}", ) - with open(trace_path, "r", encoding="utf-8") as f: + with open(trace_path, encoding="utf-8") as f: trace = json.load(f) stage = trace.get("predicted_stage", "unknown") reasoning = trace.get("reasoning", "") @@ -167,9 +171,7 @@ async def post_chat(sid: str, eid: str, tp: int, body: ChatRequest, } seed_assistant = { "role": "assistant", - "content": [ - {"type": "text", "text": f"Stage: {stage}\n\n{reasoning}"} - ], + "content": [{"type": "text", "text": f"Stage: {stage}\n\n{reasoning}"}], } messages: list[dict] = [seed_user, seed_assistant] @@ -177,9 +179,7 @@ async def post_chat(sid: str, eid: str, tp: int, body: ChatRequest, role = turn.get("role") content = turn.get("content", "") if role in ("user", "assistant") and content: - messages.append( - {"role": role, "content": [{"type": "text", "text": content}]} - ) + messages.append({"role": role, "content": [{"type": "text", "text": content}]}) messages.append( { "role": "user", diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index c8c0ebe7..93d49451 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -3,7 +3,6 @@ import logging from datetime import datetime from pathlib import Path -from typing import Optional import yaml from fastapi import APIRouter, Body, Depends, HTTPException @@ -28,7 +27,7 @@ async def get_status(): "status": "running", "connections": len(server.manager.active_connections), **stats, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } @router.get("/api/device-status") @@ -77,11 +76,10 @@ def _require_agent_with_experiment(): raise HTTPException(status_code=503, detail="Agent not ready") return agent - @router.put("/api/embryos/{embryo_id}/position", - dependencies=[Depends(require_control)]) + @router.put("/api/embryos/{embryo_id}/position", dependencies=[Depends(require_control)]) async def update_embryo_position( embryo_id: str, - body: dict = Body(...), + body: dict = Body(...), # noqa: B008 ): """Update an embryo's coarse XY position. @@ -102,9 +100,9 @@ async def update_embryo_position( x = float(body.get("x")) y = float(body.get("y")) except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="Body needs numeric x and y") + raise HTTPException(status_code=400, detail="Body needs numeric x and y") from None old_coarse = dict(emb.position_coarse) if emb.position_coarse else None - had_fine = bool(emb.position_fine) + had_fine = bool(emb.position_fine) emb.position_coarse = {"x": x, "y": y} emb.position_fine = {} agent.experiment.notify_embryos_changed() @@ -112,6 +110,7 @@ async def update_embryo_position( bus = getattr(agent, "_event_bus", None) if bus is not None: from gently.core.event_bus import EventType + try: bus.publish( event_type=EventType.OPERATOR_EDITED_EMBRYO, @@ -127,8 +126,7 @@ async def update_embryo_position( logger.exception("Failed to publish OPERATOR_EDITED_EMBRYO") return emb.to_dict() - @router.delete("/api/embryos/{embryo_id}", - dependencies=[Depends(require_control)]) + @router.delete("/api/embryos/{embryo_id}", dependencies=[Depends(require_control)]) async def delete_embryo(embryo_id: str): """Remove an embryo from the experiment. @@ -144,7 +142,7 @@ async def delete_embryo(embryo_id: str): if emb is not None: last_position = { "coarse": dict(emb.position_coarse) if emb.position_coarse else None, - "fine": dict(emb.position_fine) if emb.position_fine else None, + "fine": dict(emb.position_fine) if emb.position_fine else None, } if not agent.experiment.remove_embryo(embryo_id): raise HTTPException(status_code=404, detail=f"Embryo {embryo_id} not found") @@ -152,6 +150,7 @@ async def delete_embryo(embryo_id: str): bus = getattr(agent, "_event_bus", None) if bus is not None: from gently.core.event_bus import EventType + try: bus.publish( event_type=EventType.OPERATOR_REMOVED_EMBRYO, @@ -201,17 +200,19 @@ async def get_coverslip(): block in this config and no zone endpoint here. """ try: - with open(_HARDWARE_CONFIG_PATH, "r") as f: + with open(_HARDWARE_CONFIG_PATH) as f: cfg = yaml.safe_load(f) or {} except FileNotFoundError: return {"coverslip": None} cs = cfg.get("coverslip") if not isinstance(cs, dict): return {"coverslip": None} - return {"coverslip": { - "center_um": list(cs.get("center_um") or [0.0, 0.0]), - "size_mm": list(cs.get("size_mm") or [50.0, 24.0]), - }} + return { + "coverslip": { + "center_um": list(cs.get("center_um") or [0.0, 0.0]), + "size_mm": list(cs.get("size_mm") or [50.0, 24.0]), + } + } @router.get("/api/devices/bottom_camera/status") async def get_bottom_camera_status(): @@ -225,8 +226,10 @@ async def get_bottom_camera_status(): "last_frame_ts": getattr(monitor, "_last_frame_ts", None) if monitor else None, } - @router.post("/api/devices/bottom_camera/stream/start", - dependencies=[Depends(require_control)]) + @router.post( + "/api/devices/bottom_camera/stream/start", + dependencies=[Depends(require_control)], + ) async def start_bottom_camera_stream(): """Start the bottom-camera stream bridge. @@ -244,11 +247,13 @@ async def start_bottom_camera_stream(): await monitor.start() except Exception as exc: logger.exception("Failed to start bottom-camera monitor") - raise HTTPException(status_code=500, detail=f"start failed: {exc}") + raise HTTPException(status_code=500, detail=f"start failed: {exc}") from exc return {"streaming": monitor.running} - @router.post("/api/devices/bottom_camera/stream/stop", - dependencies=[Depends(require_control)]) + @router.post( + "/api/devices/bottom_camera/stream/stop", + dependencies=[Depends(require_control)], + ) async def stop_bottom_camera_stream(): """Stop the bottom-camera stream bridge. Idempotent.""" bridge = getattr(server, "agent_bridge", None) @@ -260,7 +265,7 @@ async def stop_bottom_camera_stream(): await monitor.stop() except Exception as exc: logger.exception("Failed to stop bottom-camera monitor") - raise HTTPException(status_code=500, detail=f"stop failed: {exc}") + raise HTTPException(status_code=500, detail=f"stop failed: {exc}") from exc return {"streaming": False} def _resolve_client(): @@ -285,9 +290,8 @@ async def get_room_light_status(): "state": res.get("state", "unknown"), } - @router.post("/api/devices/room_light/set", - dependencies=[Depends(require_control)]) - async def set_room_light(payload: dict = Body(...)): + @router.post("/api/devices/room_light/set", dependencies=[Depends(require_control)]) + async def set_room_light(payload: dict = Body(...)): # noqa: B008 """Switch the room light on/off. Body: {"state": "on"|"off"|"press"}.""" state = str(payload.get("state", "")).lower() if state not in ("on", "off", "press"): @@ -299,9 +303,13 @@ async def set_room_light(payload: dict = Body(...)): res = await client.set_room_light(state) except Exception as exc: logger.exception("Room light command failed") - raise HTTPException(status_code=502, detail=f"room light command failed: {exc}") + raise HTTPException( + status_code=502, detail=f"room light command failed: {exc}" + ) from exc if not res.get("success"): - raise HTTPException(status_code=502, detail=res.get("error", "room light command failed")) + raise HTTPException( + status_code=502, detail=res.get("error", "room light command failed") + ) return {"state": res.get("state", state)} @router.get("/api/devices/temperature/status") @@ -328,9 +336,8 @@ async def get_temperature_status(): "peltier_c": res.get("peltier_c"), } - @router.post("/api/devices/temperature/set", - dependencies=[Depends(require_control)]) - async def set_temperature(payload: dict = Body(...)): + @router.post("/api/devices/temperature/set", dependencies=[Depends(require_control)]) + async def set_temperature(payload: dict = Body(...)): # noqa: B008 """Command the temperature setpoint. Body: {"target_c": float}. Non-blocking: the controller ramps and the status poll reflects progress @@ -339,7 +346,7 @@ async def set_temperature(payload: dict = Body(...)): try: target = float(payload.get("target_c")) except (TypeError, ValueError): - raise HTTPException(status_code=400, detail="target_c must be a number") + raise HTTPException(status_code=400, detail="target_c must be a number") from None if not (0.0 <= target <= 99.9): raise HTTPException(status_code=400, detail="target_c must be between 0.0 and 99.9 C") client = _resolve_client() @@ -349,9 +356,13 @@ async def set_temperature(payload: dict = Body(...)): res = await client.set_temperature(target) except Exception as exc: logger.exception("Temperature command failed") - raise HTTPException(status_code=502, detail=f"temperature command failed: {exc}") + raise HTTPException( + status_code=502, detail=f"temperature command failed: {exc}" + ) from exc if not res.get("success"): - raise HTTPException(status_code=502, detail=res.get("error", "temperature command failed")) + raise HTTPException( + status_code=502, detail=res.get("error", "temperature command failed") + ) return { "target_c": res.get("target_c", target), "temperature_c": res.get("temperature_c"), @@ -360,38 +371,27 @@ async def set_temperature(payload: dict = Body(...)): } @router.get("/api/calibration") - async def list_calibration(embryo_id: Optional[str] = None): + async def list_calibration(embryo_id: str | None = None): """Get calibration images""" images = server.store.get_all_calibration(embryo_id) - return { - "calibration": [img.to_dict() for img in images], - "count": len(images) - } + return {"calibration": [img.to_dict() for img in images], "count": len(images)} @router.get("/api/volumes") - async def list_volumes(embryo_id: Optional[str] = None): + async def list_volumes(embryo_id: str | None = None): """Get volume images""" images = server.store.get_all_volumes(embryo_id) - return { - "volumes": [img.to_dict() for img in images], - "count": len(images) - } + return {"volumes": [img.to_dict() for img in images], "count": len(images)} @router.get("/api/snapshots") - async def list_snapshots(embryo_id: Optional[str] = None): + async def list_snapshots(embryo_id: str | None = None): """Get snapshot images""" images = server.store.get_all_snapshots(embryo_id) - return { - "snapshots": [img.to_dict() for img in images], - "count": len(images) - } + return {"snapshots": [img.to_dict() for img in images], "count": len(images)} @router.get("/api/embryos") async def list_embryos(): """Get list of embryos with images""" - return { - "embryos": server.store.get_embryo_ids() - } + return {"embryos": server.store.get_embryo_ids()} @router.get("/api/embryos/positions") async def embryo_positions(): @@ -415,26 +415,28 @@ async def embryo_positions(): # Embryo registered but no position yet (e.g. only the # ID arrived from another path). Skip — nothing to render. continue - points.append({ - "embryo_id": eid, - "uid": emb.get("uid"), - "x": float(x), - "y": float(y), - "role": emb.get("role", "test"), - "user_label": emb.get("user_label"), - "confidence": emb.get("confidence"), - "cadence_phase": emb.get("cadence_phase"), - "is_complete": bool(emb.get("is_complete")), - }) + points.append( + { + "embryo_id": eid, + "uid": emb.get("uid"), + "x": float(x), + "y": float(y), + "role": emb.get("role", "test"), + "user_label": emb.get("user_label"), + "confidence": emb.get("confidence"), + "cadence_phase": emb.get("cadence_phase"), + "is_complete": bool(emb.get("is_complete")), + } + ) return {"embryos": points} @router.get("/api/sequence/{embryo_id}") async def get_image_sequence( embryo_id: str, start: int = 0, - end: Optional[int] = None, + end: int | None = None, data_type: str = "volume_projection", - buffer_percent: float = 0.15 + buffer_percent: float = 0.15, ): """Get ordered sequence of images for timepoint range. @@ -455,7 +457,7 @@ async def get_image_sequence( embryo_id=embryo_id, start=buffered_start, end=buffered_end, - data_type=data_type + data_type=data_type, ) # Return lightweight metadata (no base64 data) @@ -463,26 +465,25 @@ async def get_image_sequence( seen_uids = set() for img in images: seen_uids.add(img.uid) - sequence.append({ - "uid": img.uid, - "timepoint": img.metadata.get("timepoint"), - "timestamp": img.timestamp, - "data_type": img.data_type, - "shape": img.shape, - "embryo_id": img.metadata.get("embryo_id") - }) + sequence.append( + { + "uid": img.uid, + "timepoint": img.metadata.get("timepoint"), + "timestamp": img.timestamp, + "data_type": img.data_type, + "shape": img.shape, + "embryo_id": img.metadata.get("embryo_id"), + } + ) # Fallback to persistent DataStore for missing timepoints if server.data_store and (len(sequence) == 0 or buffered_end is not None): try: - refs = server.data_store.query( - data_type=data_type, - embryo_id=embryo_id - ) + refs = server.data_store.query(data_type=data_type, embryo_id=embryo_id) for ref in refs: if ref.uid in seen_uids: continue - tp = ref.metadata.get('timepoint') + tp = ref.metadata.get("timepoint") if tp is None: continue tp = int(tp) @@ -491,16 +492,18 @@ async def get_image_sequence( if buffered_end is not None and tp > buffered_end: continue seen_uids.add(ref.uid) - sequence.append({ - "uid": ref.uid, - "timepoint": tp, - "timestamp": ref.metadata.get('timestamp', ''), - "data_type": ref.data_type, - "shape": ref.metadata.get('shape'), - "embryo_id": embryo_id - }) + sequence.append( + { + "uid": ref.uid, + "timepoint": tp, + "timestamp": ref.metadata.get("timestamp", ""), + "data_type": ref.data_type, + "shape": ref.metadata.get("shape"), + "embryo_id": embryo_id, + } + ) # Re-sort by timepoint - sequence.sort(key=lambda x: x.get('timepoint') or 0) + sequence.sort(key=lambda x: x.get("timepoint") or 0) except Exception as e: logger.warning(f"DataStore fallback failed: {e}") @@ -509,14 +512,12 @@ async def get_image_sequence( "requested_range": {"start": start, "end": end}, "buffered_range": {"start": buffered_start, "end": buffered_end}, "sequence": sequence, - "count": len(sequence) + "count": len(sequence), } @router.get("/api/events") async def list_events( - event_type: Optional[str] = None, - source: Optional[str] = None, - limit: int = 100 + event_type: str | None = None, source: str | None = None, limit: int = 100 ): """Get event history from EventBus""" if not server.event_bus: @@ -524,6 +525,7 @@ async def list_events( # Get history from event bus from gently.core import EventType + et = None if event_type: try: @@ -531,24 +533,22 @@ async def list_events( except KeyError: pass - events = server.event_bus.get_history( - event_type=et, - source=source, - limit=limit - ) + events = server.event_bus.get_history(event_type=et, source=source, limit=limit) return { "events": [ { - "event_type": e.event_type.name if hasattr(e.event_type, 'name') else str(e.event_type), + "event_type": e.event_type.name + if hasattr(e.event_type, "name") + else str(e.event_type), "data": e.data, "source": e.source, "timestamp": e.timestamp.isoformat(), - "event_id": e.event_id + "event_id": e.event_id, } for e in events ], - "total": len(events) + "total": len(events), } return router diff --git a/gently/ui/web/routes/experiments.py b/gently/ui/web/routes/experiments.py index 3b8c9506..c2c5de77 100644 --- a/gently/ui/web/routes/experiments.py +++ b/gently/ui/web/routes/experiments.py @@ -61,6 +61,6 @@ async def get_strategy(session_id: str): raise HTTPException( status_code=500, detail=f"Failed to build strategy: {e}", - ) + ) from e return router diff --git a/gently/ui/web/routes/images.py b/gently/ui/web/routes/images.py index 5d505b6a..ce690493 100644 --- a/gently/ui/web/routes/images.py +++ b/gently/ui/web/routes/images.py @@ -2,11 +2,10 @@ import base64 import logging -from typing import Optional import numpy as np from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import Response, FileResponse +from fastapi.responses import FileResponse, Response from ..volume_helpers import parse_volume_uid @@ -66,17 +65,22 @@ async def get_image_png(uid: str): if data is None and parsed: embryo_id, timepoint = parsed if embryo_id in server.timelapse_tracker.projection_uids: - real_uid = server.timelapse_tracker.projection_uids[embryo_id].get(timepoint) + real_uid = server.timelapse_tracker.projection_uids[embryo_id].get( + timepoint + ) if real_uid: data = server.data_store.retrieve(real_uid) if data is not None: from io import BytesIO + from PIL import Image + from gently.core.imaging import ( - projection_three_view, - compute_crop_bounds, apply_crop_bounds, + compute_crop_bounds, + projection_three_view, ) + # Handle numpy array if isinstance(data, np.ndarray): # Handle 4D volumes (Views, Z, Y, X) - take View A @@ -87,18 +91,24 @@ async def get_image_png(uid: str): z_depth, height, width = data.shape # Handle dual-view format if width > height * 2: - data = data[:, :, :width // 2] + data = data[:, :, : width // 2] # Auto-crop and project bounds = compute_crop_bounds(data) data = apply_crop_bounds(data, bounds) data, _ = projection_three_view(data) # Normalize to uint8 if needed if data.dtype != np.uint8: - data = ((data - data.min()) / (data.max() - data.min() + 1e-8) * 255).astype(np.uint8) + data = ( + (data - data.min()) / (data.max() - data.min() + 1e-8) * 255 + ).astype(np.uint8) img = Image.fromarray(data) buf = BytesIO() - img.save(buf, format='PNG') - return Response(content=buf.getvalue(), media_type="image/png", headers=cache_headers) + img.save(buf, format="PNG") + return Response( + content=buf.getvalue(), + media_type="image/png", + headers=cache_headers, + ) except Exception as e: logger.warning(f"Failed to load image {uid} from DataStore: {e}") @@ -131,21 +141,18 @@ async def push_image_http(request: Request): data = await request.json() # Decode the image from base64 - image_b64 = data.get('image_b64') - uid = data.get('uid') - shape = data.get('shape') - dtype = data.get('dtype', 'uint8') - data_type = data.get('data_type', 'cv_visualization') - metadata = data.get('metadata', {}) + image_b64 = data.get("image_b64") + uid = data.get("uid") + shape = data.get("shape") + dtype = data.get("dtype", "uint8") + data_type = data.get("data_type", "cv_visualization") + metadata = data.get("metadata", {}) if not all([image_b64, uid, shape]): raise HTTPException(status_code=400, detail="Missing required fields") # Decode array - array = np.frombuffer( - base64.b64decode(image_b64), - dtype=np.dtype(dtype) - ).reshape(shape) + array = np.frombuffer(base64.b64decode(image_b64), dtype=np.dtype(dtype)).reshape(shape) # Push using the existing method await server.push_image(array, uid, data_type, metadata) @@ -154,6 +161,6 @@ async def push_image_http(request: Request): except Exception as e: logger.error(f"Failed to push image via HTTP: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) from e return router diff --git a/gently/ui/web/routes/pages.py b/gently/ui/web/routes/pages.py index 3cf5c37a..3858f4e7 100644 --- a/gently/ui/web/routes/pages.py +++ b/gently/ui/web/routes/pages.py @@ -16,9 +16,7 @@ async def index(request: Request): chat window's "Sign in" affordance), not a gate on the page itself. """ return server.templates.TemplateResponse( - request, - "index.html", - {"active_section": "embryos", "is_live": True} + request, "index.html", {"active_section": "embryos", "is_live": True} ) # Standalone URLs redirect to SPA with hash fragment for tab routing diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index 310ae9cf..ccbc3a93 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -43,15 +43,17 @@ async def list_sessions(): count = len(store.list_embryos(sid) or []) except Exception: count = 0 - sessions.append({ - "session_id": sid, - "name": s.get("name") or sid, - "created_at": s.get("created_at", ""), - "last_active": s.get("last_active", ""), - "embryo_count": count, - "description": s.get("description", ""), - "active": sid == active_id, - }) + sessions.append( + { + "session_id": sid, + "name": s.get("name") or sid, + "created_at": s.get("created_at", ""), + "last_active": s.get("last_active", ""), + "embryo_count": count, + "description": s.get("description", ""), + "active": sid == active_id, + } + ) except Exception as e: logger.warning("Failed to list sessions from FileStore: %s", e) return {"sessions": sessions} @@ -82,7 +84,7 @@ async def recent_images(limit: int = 8, scan: int = 200): scan = max(1, min(int(scan), 500)) out = [] try: - for sid in (store.recent_session_ids(scan) or []): + for sid in store.recent_session_ids(scan) or []: try: eids = store.list_embryo_ids(sid) except Exception: @@ -101,12 +103,14 @@ async def recent_images(limit: int = 8, scan: int = 200): except Exception: info = None sname = (info.get("name") if info else None) or sid - out.append({ - "session_id": sid, - "session_name": sname, - "embryo_id": eid, - "timepoint": int(max(tps)), - }) + out.append( + { + "session_id": sid, + "session_name": sname, + "embryo_id": eid, + "timepoint": int(max(tps)), + } + ) if len(out) >= limit: break if len(out) >= limit: @@ -139,7 +143,7 @@ async def get_session_projection(session_id: str, embryo: str, t: int): except HTTPException: raise except Exception: - raise HTTPException(status_code=404, detail="Not found") + raise HTTPException(status_code=404, detail="Not found") from None try: st = resolved.stat() etag = f'"{int(st.st_mtime)}-{st.st_size}"' @@ -148,11 +152,9 @@ async def get_session_projection(session_id: str, embryo: str, t: int): headers = {"Cache-Control": "private, max-age=60"} if etag: headers["ETag"] = etag - return FileResponse(str(resolved), media_type="image/jpeg", - headers=headers) + return FileResponse(str(resolved), media_type="image/jpeg", headers=headers) - @router.post("/api/sessions/{session_id}/resume", - dependencies=[Depends(require_control)]) + @router.post("/api/sessions/{session_id}/resume", dependencies=[Depends(require_control)]) async def resume_session(session_id: str): """Switch the live agent to a different saved session. @@ -169,13 +171,17 @@ async def resume_session(session_id: str): if store is None or store.get_session(session_id) is None: raise HTTPException(status_code=404, detail="Session not found") if session_id == getattr(agent, "session_id", None): - return {"ok": True, "session_id": session_id, "active": True, - "note": "already active"} + return { + "ok": True, + "session_id": session_id, + "active": True, + "note": "already active", + } try: ok = agent.resume_session(session_id) except Exception as e: logger.exception("Session resume failed") - raise HTTPException(status_code=500, detail=f"resume failed: {e}") + raise HTTPException(status_code=500, detail=f"resume failed: {e}") from e if not ok: raise HTTPException(status_code=500, detail="resume returned false") # Rehydrate the viz image store from disk so the resumed session's @@ -188,12 +194,15 @@ async def resume_session(session_id: str): # Tell every connected browser to reload — they'll reconnect to the # new session's state (embryos, transcript, rehydrated imagery). try: - await server.manager.broadcast({"type": "session_changed", - "session_id": session_id}) + await server.manager.broadcast({"type": "session_changed", "session_id": session_id}) except Exception: pass - return {"ok": True, "session_id": session_id, "active": True, - "rehydrated_projections": rehydrated} + return { + "ok": True, + "session_id": session_id, + "active": True, + "rehydrated_projections": rehydrated, + } @router.get("/api/sessions/{session_id}") async def get_session(session_id: str): diff --git a/gently/ui/web/routes/volumes.py b/gently/ui/web/routes/volumes.py index e48475c3..d2c66dac 100644 --- a/gently/ui/web/routes/volumes.py +++ b/gently/ui/web/routes/volumes.py @@ -3,18 +3,18 @@ import base64 import io import logging -from typing import Optional import numpy as np from fastapi import APIRouter, HTTPException, Request from fastapi.responses import Response -from ..volume_helpers import load_volume_from_disk, image_to_base64_png +from ..volume_helpers import image_to_base64_png, load_volume_from_disk logger = logging.getLogger(__name__) try: from PIL import Image + PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False @@ -31,7 +31,8 @@ async def get_projections(embryo_id: str, timepoint: int, method: str = "all"): Args: embryo_id: Embryo identifier timepoint: Timepoint number (1-indexed) - method: Projection method - 'all', 'three_view', 'dual_view', 'depth_colored', 'multi_slice' + method: Projection method - 'all', 'three_view', 'dual_view', + 'depth_colored', 'multi_slice' Returns: List of projections with method name, description, and base64 PNG data @@ -41,7 +42,10 @@ async def get_projections(embryo_id: str, timepoint: int, method: str = "all"): # Look up volume path (timelapse tracker + FileStore fallback) volume_path = server._resolve_volume_path(embryo_id, timepoint) if not volume_path: - raise HTTPException(status_code=404, detail=f"No volume for {embryo_id} at timepoint {timepoint}") + raise HTTPException( + status_code=404, + detail=f"No volume for {embryo_id} at timepoint {timepoint}", + ) # Load volume from disk try: @@ -52,29 +56,32 @@ async def get_projections(embryo_id: str, timepoint: int, method: str = "all"): vol = (vol - vol.min()) / (vol.max() - vol.min() + 1e-8) except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) from e except Exception as e: logger.error(f"Failed to load volume: {e}") - raise HTTPException(status_code=500, detail=f"Failed to load volume: {e}") + raise HTTPException(status_code=500, detail=f"Failed to load volume: {e}") from e PROJECTION_METHODS = { - 'three_view': projection_three_view, + "three_view": projection_three_view, } # Try to import additional projection methods from explorer try: from gently.dataset.explorer_server import ( - projection_dual_view, projection_depth_colored, + projection_dual_view, projection_multi_slice, projection_spin_3d, ) - PROJECTION_METHODS.update({ - 'dual_view': projection_dual_view, - 'depth_colored': projection_depth_colored, - 'multi_slice': projection_multi_slice, - 'spin_3d': projection_spin_3d, - }) + + PROJECTION_METHODS.update( + { + "dual_view": projection_dual_view, + "depth_colored": projection_depth_colored, + "multi_slice": projection_multi_slice, + "spin_3d": projection_spin_3d, + } + ) except ImportError: pass # Explorer projections not available @@ -84,22 +91,31 @@ async def get_projections(embryo_id: str, timepoint: int, method: str = "all"): for method_name, method_func in PROJECTION_METHODS.items(): try: proj_img, desc = method_func(vol) - projections.append({ - "method": method_name, - "description": desc, - "data": image_to_base64_png(proj_img), - }) + projections.append( + { + "method": method_name, + "description": desc, + "data": image_to_base64_png(proj_img), + } + ) except Exception as e: logger.warning(f"Projection {method_name} failed: {e}") else: if method not in PROJECTION_METHODS: - raise HTTPException(status_code=400, detail=f"Unknown method: {method}. Available: {list(PROJECTION_METHODS.keys())}") + raise HTTPException( + status_code=400, + detail=( + f"Unknown method: {method}. Available: {list(PROJECTION_METHODS.keys())}" + ), + ) proj_img, desc = PROJECTION_METHODS[method](vol) - projections.append({ - "method": method, - "description": desc, - "data": image_to_base64_png(proj_img), - }) + projections.append( + { + "method": method, + "description": desc, + "data": image_to_base64_png(proj_img), + } + ) return { "embryo_id": embryo_id, @@ -120,7 +136,10 @@ async def get_volume_raw(embryo_id: str, timepoint: int): # Look up volume path (timelapse tracker + FileStore fallback) volume_path = server._resolve_volume_path(embryo_id, timepoint) if not volume_path: - raise HTTPException(status_code=404, detail=f"No volume for {embryo_id} at timepoint {timepoint}") + raise HTTPException( + status_code=404, + detail=f"No volume for {embryo_id} at timepoint {timepoint}", + ) try: vol = load_volume_from_disk(volume_path) @@ -137,7 +156,7 @@ async def get_volume_raw(embryo_id: str, timepoint: int): # Encode as base64 vol_bytes = vol_uint8.tobytes() - vol_b64 = base64.b64encode(vol_bytes).decode('utf-8') + vol_b64 = base64.b64encode(vol_bytes).decode("utf-8") # Physical voxel size for isometric 3D rendering. # Matches the default in gently.core.imaging.projection_three_view: @@ -155,17 +174,17 @@ async def get_volume_raw(embryo_id: str, timepoint: int): } except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) from e except Exception as e: logger.error(f"Failed to load volume: {e}") - raise HTTPException(status_code=500, detail=f"Failed to load volume: {e}") + raise HTTPException(status_code=500, detail=f"Failed to load volume: {e}") from e @router.get("/api/volumes3d") async def list_volumes_3d(): """Get list of 3D volumes (without heavy data)""" return { "volumes_3d": server.store.get_all_volumes_3d(), - "count": len(server.store._volumes_3d) + "count": len(server.store._volumes_3d), } @router.get("/api/volumes3d/{uid}") @@ -188,7 +207,7 @@ async def get_volume_3d_slice(uid: str, z: int): if PIL_AVAILABLE: img = Image.fromarray(rgb) buffer = io.BytesIO() - img.save(buffer, format='PNG') + img.save(buffer, format="PNG") return Response(content=buffer.getvalue(), media_type="image/png") raise HTTPException(status_code=500, detail="PIL not available") @@ -209,14 +228,17 @@ async def get_volume_data_for_3d_viewer(uid: str): volume = np.zeros(volume.shape, dtype=np.uint8) return { "shape": list(volume.shape), - "data": base64.b64encode(volume.tobytes()).decode('utf-8'), - "uid": uid + "data": base64.b64encode(volume.tobytes()).decode("utf-8"), + "uid": uid, } # Check if it's a regular image with stored volume data image = server.store.get_image_by_uid(uid) if image and image.shape and len(image.shape) == 3: - raise HTTPException(status_code=404, detail=f"Volume data for {uid} not available - only segmented volumes supported") + raise HTTPException( + status_code=404, + detail=f"Volume data for {uid} not available - only segmented volumes supported", + ) raise HTTPException(status_code=404, detail=f"Volume {uid} not found") @@ -227,27 +249,25 @@ async def push_volume_3d_http(request: Request): data = await request.json() # Decode the volume and masks from base64 - volume_b64 = data.get('volume_b64') - masks_b64 = data.get('masks_b64') - uid = data.get('uid') - shape = data.get('shape') - dtype_vol = data.get('dtype_vol', 'uint16') - dtype_mask = data.get('dtype_mask', 'uint16') - metadata = data.get('metadata', {}) + volume_b64 = data.get("volume_b64") + masks_b64 = data.get("masks_b64") + uid = data.get("uid") + shape = data.get("shape") + dtype_vol = data.get("dtype_vol", "uint16") + dtype_mask = data.get("dtype_mask", "uint16") + metadata = data.get("metadata", {}) if not all([volume_b64, masks_b64, uid, shape]): raise HTTPException(status_code=400, detail="Missing required fields") # Decode arrays - volume = np.frombuffer( - base64.b64decode(volume_b64), - dtype=np.dtype(dtype_vol) - ).reshape(shape) + volume = np.frombuffer(base64.b64decode(volume_b64), dtype=np.dtype(dtype_vol)).reshape( + shape + ) - masks = np.frombuffer( - base64.b64decode(masks_b64), - dtype=np.dtype(dtype_mask) - ).reshape(shape) + masks = np.frombuffer(base64.b64decode(masks_b64), dtype=np.dtype(dtype_mask)).reshape( + shape + ) # Push using the existing method await server.push_volume_3d(volume, masks, uid, metadata) @@ -256,6 +276,6 @@ async def push_volume_3d_http(request: Request): except Exception as e: logger.error(f"Failed to push 3D volume via HTTP: {e}") - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) from e return router diff --git a/gently/ui/web/routes/websocket.py b/gently/ui/web/routes/websocket.py index 3b35f3e1..de66552c 100644 --- a/gently/ui/web/routes/websocket.py +++ b/gently/ui/web/routes/websocket.py @@ -14,9 +14,14 @@ # /ws message types that mutate experiment state (define what gets imaged). # These are control actions and are gated by role; pure read/presence # messages stay open so anyone can watch. -_MARKING_TYPES = frozenset({ - "embryo_marked", "marking_update", "marking_done", "marking_redetect", -}) +_MARKING_TYPES = frozenset( + { + "embryo_marked", + "marking_update", + "marking_done", + "marking_redetect", + } +) def _ws_can_control(websocket: WebSocket) -> bool: @@ -25,8 +30,9 @@ def _ws_can_control(websocket: WebSocket) -> bool: Account mode: operators/admins (by session cookie) only. Legacy mode (no accounts configured): open, preserving prior behavior. """ - from gently.ui.web.accounts import get_account_store, CONTROL_ROLES + from gently.ui.web.accounts import CONTROL_ROLES, get_account_store from gently.ui.web.auth import SESSION_COOKIE + store = get_account_store() if store is None or not store.has_users(): return True @@ -47,11 +53,9 @@ async def websocket_endpoint(websocket: WebSocket): try: # Send current status on connect stats = server.store.get_stats() - await websocket.send_json({ - "type": "connected", - **stats, - "timestamp": datetime.now().isoformat() - }) + await websocket.send_json( + {"type": "connected", **stats, "timestamp": datetime.now().isoformat()} + ) # Always send timelapse state on connect so client can reconcile # (if IDLE with no session_id, client will clear stale cached state) @@ -67,18 +71,12 @@ async def websocket_endpoint(websocket: WebSocket): timelapse_state["session_id"] = live_sid except Exception: pass - await websocket.send_json({ - "type": "timelapse_state", - "data": timelapse_state - }) + await websocket.send_json({"type": "timelapse_state", "data": timelapse_state}) # Keep connection alive and handle incoming messages while True: try: - data = await asyncio.wait_for( - websocket.receive_text(), - timeout=30.0 - ) + data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0) # Handle client messages (e.g., requests) await _handle_ws_message(server, websocket, data) except asyncio.TimeoutError: @@ -119,39 +117,30 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): if msg_type == "get_calibration": images = server.store.get_all_calibration(embryo_id) - await websocket.send_json({ - "type": "calibration", - "data": [img.to_dict() for img in images] - }) + await websocket.send_json( + {"type": "calibration", "data": [img.to_dict() for img in images]} + ) elif msg_type == "get_volumes": images = server.store.get_all_volumes(embryo_id) - await websocket.send_json({ - "type": "volumes", - "data": [img.to_dict() for img in images] - }) + await websocket.send_json( + {"type": "volumes", "data": [img.to_dict() for img in images]} + ) elif msg_type == "get_snapshots": images = server.store.get_all_snapshots(embryo_id) - await websocket.send_json({ - "type": "snapshots", - "data": [img.to_dict() for img in images] - }) + await websocket.send_json( + {"type": "snapshots", "data": [img.to_dict() for img in images]} + ) elif msg_type == "get_embryos": - await websocket.send_json({ - "type": "embryos", - "data": server.store.get_embryo_ids() - }) + await websocket.send_json({"type": "embryos", "data": server.store.get_embryo_ids()}) elif msg_type == "get_image": uid = data.get("uid") image = server.store.get_image_by_uid(uid) if image: - await websocket.send_json({ - "type": "image", - "data": image.to_dict() - }) + await websocket.send_json({"type": "image", "data": image.to_dict()}) elif msg_type == "pong": pass # Client responding to ping @@ -165,7 +154,8 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): # Sanitize name: strip HTML tags, limit length if name: import re - name = re.sub(r'<[^>]+>', '', name)[:50] + + name = re.sub(r"<[^>]+>", "", name)[:50] # Update the client's info async with server.manager._lock: if websocket in server.manager.active_connections: @@ -174,7 +164,7 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): client_id=client_id, name=name or old_info.name, color=server.manager._generate_color(client_id), - connected_at=old_info.connected_at + connected_at=old_info.connected_at, ) await server.manager.broadcast_presence() @@ -184,7 +174,8 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): if name: # Sanitize name: strip HTML tags, limit length import re - name = re.sub(r'<[^>]+>', '', name)[:50] + + name = re.sub(r"<[^>]+>", "", name)[:50] await server.manager.update_client_name(websocket, name) elif msg_type == "get_presence": @@ -195,16 +186,19 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): elif msg_type == "embryo_marked": session_id = data.get("session_id") marker = data.get("marker") - if session_id and marker and hasattr(server, '_marking_sessions'): + if session_id and marker and hasattr(server, "_marking_sessions"): session = server._marking_sessions.get(session_id) if session: session["markers"].append(marker) - logger.info(f"Embryo marked: #{marker['number']} at ({marker['pixelX']}, {marker['pixelY']})") + logger.info( + f"Embryo marked: #{marker['number']}" + f" at ({marker['pixelX']}, {marker['pixelY']})" + ) elif msg_type == "marking_update": session_id = data.get("session_id") markers = data.get("markers", []) - if session_id and hasattr(server, '_marking_sessions'): + if session_id and hasattr(server, "_marking_sessions"): session = server._marking_sessions.get(session_id) if session: session["markers"] = markers @@ -213,7 +207,7 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): elif msg_type == "marking_done": session_id = data.get("session_id") markers = data.get("markers", []) - if session_id and hasattr(server, '_marking_sessions'): + if session_id and hasattr(server, "_marking_sessions"): session = server._marking_sessions.get(session_id) if session: session["markers"] = markers @@ -223,8 +217,7 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): r = m.get("role", "test") role_summary[r] = role_summary.get(r, 0) + 1 logger.info( - f"Marking complete: {len(markers)} embryo(s) " - f"(roles: {role_summary})" + f"Marking complete: {len(markers)} embryo(s) (roles: {role_summary})" ) elif msg_type == "marking_redetect": @@ -234,13 +227,14 @@ async def _handle_ws_message(server, websocket: WebSocket, message: str): # listen for. Once recapture lands, the agent calls # start_marking_session again with the new image + markers. session_id = data.get("session_id") - if session_id and hasattr(server, '_marking_sessions'): + if session_id and hasattr(server, "_marking_sessions"): session = server._marking_sessions.get(session_id) if session is not None: session["redetect_requested"] = True logger.info(f"Marking redetect requested for session {session_id}") try: from gently.core import EventType, get_event_bus + get_event_bus().publish( event_type=EventType.STATUS_CHANGED, data={ diff --git a/gently/ui/web/server.py b/gently/ui/web/server.py index 58cfbd65..12e47714 100644 --- a/gently/ui/web/server.py +++ b/gently/ui/web/server.py @@ -24,7 +24,6 @@ import sys from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional import numpy as np @@ -35,11 +34,12 @@ # Optional imports try: + import uvicorn from fastapi import FastAPI + from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates - from fastapi.middleware.cors import CORSMiddleware - import uvicorn + FASTAPI_AVAILABLE = True except ImportError: FASTAPI_AVAILABLE = False @@ -60,8 +60,7 @@ class _InvalidHttpFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: msg = record.getMessage() if "Invalid HTTP request received" in msg: - logger.debug("uvicorn dropped non-HTTP bytes on viz port " - "(probable TLS/peer mismatch)") + logger.debug("uvicorn dropped non-HTTP bytes on viz port (probable TLS/peer mismatch)") return False return True @@ -76,25 +75,39 @@ def filter(self, record: logging.LogRecord) -> bool: try: from PIL import Image + PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False # Import data models and components -from .models import ( - ClientInfo, Volume3DData, ImageData, EmbryoImageCache, - CALIBRATION_TYPES, VOLUME_TYPES, ANALYSIS_TYPES, VOLUME_3D_TYPES, +from .connection_manager import ConnectionManager # noqa: E402 +from .image_store import ImageStore # noqa: E402 +from .models import ( # noqa: E402 + ANALYSIS_TYPES, + CALIBRATION_TYPES, + VOLUME_3D_TYPES, + VOLUME_TYPES, + ClientInfo, + EmbryoImageCache, + ImageData, + Volume3DData, ) -from .image_store import ImageStore -from .timelapse_tracker import TimelapseStateTracker -from .connection_manager import ConnectionManager +from .timelapse_tracker import TimelapseStateTracker # noqa: E402 # Re-export for backward compatibility __all__ = [ - 'VisualizationServer', 'create_visualization_server', - 'ClientInfo', 'Volume3DData', 'ImageData', 'EmbryoImageCache', - 'CALIBRATION_TYPES', 'VOLUME_TYPES', 'ANALYSIS_TYPES', 'VOLUME_3D_TYPES', - 'ImageStore', + "VisualizationServer", + "create_visualization_server", + "ClientInfo", + "Volume3DData", + "ImageData", + "EmbryoImageCache", + "CALIBRATION_TYPES", + "VOLUME_TYPES", + "ANALYSIS_TYPES", + "VOLUME_3D_TYPES", + "ImageStore", ] @@ -161,7 +174,7 @@ def __init__( self.app = FastAPI( title="Gently Visualization Server", description="Real-time microscopy visualization", - version="2.0.0" + version="2.0.0", ) # Setup templates and static files @@ -179,6 +192,7 @@ def __init__( # Register route groups from .routes import register_all_routes + register_all_routes(self) # Subscribe to events if event bus provided @@ -193,7 +207,7 @@ def set_context_store(self, context_store) -> None: """Set the FileContextStore for campaign/plan data access.""" self.context_store = context_store - def _resolve_volume_path(self, embryo_id: str, timepoint: int) -> Optional[str]: + def _resolve_volume_path(self, embryo_id: str, timepoint: int) -> str | None: """Resolve volume file path from timelapse tracker or FileStore.""" # 1. Try timelapse tracker (in-memory, fastest) if embryo_id in self.timelapse_tracker.volume_paths: @@ -209,7 +223,9 @@ def _resolve_volume_path(self, embryo_id: str, timepoint: int) -> Optional[str]: if self.gently_store and sid: try: vol_path = self.gently_store.get_volume_path( - sid, embryo_id, timepoint, + sid, + embryo_id, + timepoint, ) if vol_path and vol_path.exists(): return str(vol_path) @@ -218,7 +234,7 @@ def _resolve_volume_path(self, embryo_id: str, timepoint: int) -> Optional[str]: return None - def _current_session_id(self) -> Optional[str]: + def _current_session_id(self) -> str | None: """The live agent session (source of truth), falling back to the timelapse tracker. The tracker's session_id goes stale after a resume with no active timelapse, so the live agent session is preferred.""" @@ -229,13 +245,15 @@ def _current_session_id(self) -> Optional[str]: return sid return self.timelapse_tracker.session_id - def _resolve_projection_path(self, embryo_id: str, timepoint: int) -> Optional[Path]: + def _resolve_projection_path(self, embryo_id: str, timepoint: int) -> Path | None: """Resolve projection file path from FileStore (current session).""" sid = self._current_session_id() if self.gently_store and sid: try: proj_path = self.gently_store.get_projection_path( - sid, embryo_id, timepoint, + sid, + embryo_id, + timepoint, ) if proj_path and proj_path.exists(): return proj_path @@ -271,12 +289,14 @@ def rehydrate_session(self, session_id: str) -> int: except Exception: tps = [] for tp in tps: - self.store.add_image(ImageData( - uid=f"volume_{eid}_t{tp:04d}", - data_type="volume_projection", - timestamp=f"{tp:06d}", # monotonic with timepoint for ordering - metadata={"embryo_id": eid, "timepoint": tp}, - )) + self.store.add_image( + ImageData( + uid=f"volume_{eid}_t{tp:04d}", + data_type="volume_projection", + timestamp=f"{tp:06d}", # monotonic with timepoint for ordering + metadata={"embryo_id": eid, "timepoint": tp}, + ) + ) added += 1 # Rehydrate the timelapse tracker's per-embryo perception state from @@ -289,7 +309,11 @@ def rehydrate_session(self, session_id: str) -> int: tracker.detection_reasoning = {} tracker.projection_uids = {} for emb in embryos: - eid = emb.get("embryo_id") if isinstance(emb, dict) else getattr(emb, "embryo_id", None) + eid = ( + emb.get("embryo_id") + if isinstance(emb, dict) + else getattr(emb, "embryo_id", None) + ) if not eid: continue try: @@ -307,22 +331,30 @@ def rehydrate_session(self, session_id: str) -> int: puids[tp] = uid stage = p.get("predicted_stage") last_stage = stage or last_stage - items.append({ - "timepoint": tp, - "stage": stage, - "detected_stage": stage, - "reasoning": p.get("reasoning"), - "confidence": p.get("confidence"), - "projection_uid": uid, - "image_uid": uid, - "detector_name": "perception", - }) + items.append( + { + "timepoint": tp, + "stage": stage, + "detected_stage": stage, + "reasoning": p.get("reasoning"), + "confidence": p.get("confidence"), + "projection_uid": uid, + "image_uid": uid, + "detector_name": "perception", + } + ) tracker.detection_reasoning[eid] = items tracker.projection_uids[eid] = puids - entry = tracker.embryos.setdefault(eid, { - "embryo_id": eid, "timepoints": 0, "is_complete": False, - "detections": {}, "current_stage": None, - }) + entry = tracker.embryos.setdefault( + eid, + { + "embryo_id": eid, + "timepoints": 0, + "is_complete": False, + "detections": {}, + "current_stage": None, + }, + ) entry["timepoints"] = max((it["timepoint"] for it in items), default=0) entry["current_stage"] = last_stage tracker.total_timepoints = sum(len(v) for v in tracker.detection_reasoning.values()) @@ -341,7 +373,11 @@ def _subscribe_to_events(self): async def on_event_async(event): """Async handler for all events - broadcasts to WebSocket clients""" - event_type_str = event.event_type.name if hasattr(event.event_type, 'name') else str(event.event_type) + event_type_str = ( + event.event_type.name + if hasattr(event.event_type, "name") + else str(event.event_type) + ) # Update timelapse state tracker self.timelapse_tracker.handle_event(event_type_str, event.data) @@ -351,15 +387,17 @@ async def on_event_async(event): event_type=event_type_str, data=event.data, source=event.source, - event_id=event.event_id + event_id=event.event_id, ) # For session events, also broadcast updated timelapse_state so clients can sync if event_type_str in ("SESSION_STARTED", "SESSION_RESTORED"): - await self.manager.broadcast({ - "type": "timelapse_state", - "data": self.timelapse_tracker.to_dict() - }) + await self.manager.broadcast( + { + "type": "timelapse_state", + "data": self.timelapse_tracker.to_dict(), + } + ) # Subscribe to ALL events using wildcard with async handler self.event_bus.subscribe_async("*", on_event_async) @@ -377,11 +415,19 @@ def _init_from_event_history(self): # Process events in chronological order (history is newest-first) for event in reversed(history): - event_type_str = event.event_type.name if hasattr(event.event_type, 'name') else str(event.event_type) + event_type_str = ( + event.event_type.name + if hasattr(event.event_type, "name") + else str(event.event_type) + ) self.timelapse_tracker.handle_event(event_type_str, event.data) if self.timelapse_tracker.session_id: - logger.info(f"Initialized timelapse state from history: session={self.timelapse_tracker.session_id}, status={self.timelapse_tracker.status}") + logger.info( + f"Initialized timelapse state from history:" + f" session={self.timelapse_tracker.session_id}," + f" status={self.timelapse_tracker.status}" + ) except Exception as e: logger.warning(f"Failed to initialize from event history: {e}") @@ -390,13 +436,13 @@ def _array_to_image_data( array: np.ndarray, uid: str, data_type: str, - metadata: Optional[Dict] = None + metadata: dict | None = None, ) -> ImageData: """Convert numpy array to ImageData with base64 PNG""" from gently.core.imaging import ( - projection_three_view, - compute_crop_bounds, apply_crop_bounds, + compute_crop_bounds, + projection_three_view, ) # Handle 4D arrays (Views, Z, Y, X) - select View A only @@ -413,7 +459,7 @@ def _array_to_image_data( z_depth, height, width = array.shape # Handle dual-view format (width > 2*height) if width > height * 2: - array = array[:, :, :width // 2] + array = array[:, :, : width // 2] # Auto-crop to embryo region bounds = compute_crop_bounds(array) array = apply_crop_bounds(array, bounds) @@ -433,8 +479,8 @@ def _array_to_image_data( if PIL_AVAILABLE: img = Image.fromarray(array) buffer = io.BytesIO() - img.save(buffer, format='PNG') - base64_png = base64.b64encode(buffer.getvalue()).decode('utf-8') + img.save(buffer, format="PNG") + base64_png = base64.b64encode(buffer.getvalue()).decode("utf-8") return ImageData( uid=uid, @@ -442,7 +488,7 @@ def _array_to_image_data( timestamp=datetime.now().isoformat(), metadata=metadata or {}, base64_png=base64_png, - shape=array.shape + shape=array.shape, ) async def push_image( @@ -450,7 +496,7 @@ async def push_image( array: np.ndarray, uid: str, data_type: str = "image", - metadata: Optional[Dict] = None, + metadata: dict | None = None, ): """ Push an image to connected clients @@ -474,14 +520,16 @@ async def push_image( # Broadcast to clients await self.manager.send_image(image_data) - logger.debug(f"Pushed image {uid} ({data_type}) to {len(self.manager.active_connections)} clients") + logger.debug( + f"Pushed image {uid} ({data_type}) to {len(self.manager.active_connections)} clients" + ) async def start_marking_session( self, image: np.ndarray, initial_stage_position: tuple = (0.0, 0.0), pixel_size_um: float = 0.65, - initial_markers: Optional[list] = None, + initial_markers: list | None = None, default_role: str = "test", ) -> str: """ @@ -517,7 +565,7 @@ async def start_marking_session( """ import uuid - if not hasattr(self, '_marking_sessions'): + if not hasattr(self, "_marking_sessions"): self._marking_sessions = {} session_id = str(uuid.uuid4())[:8] @@ -529,15 +577,17 @@ async def start_marking_session( py = m.get("pixel_y", m.get("pixelY")) if px is None or py is None: continue - normalized.append({ - "number": i + 1, - "pixelX": round(float(px), 1), - "pixelY": round(float(py), 1), - "role": m.get("role", default_role), - "source": m.get("source", "sam"), - "embryo_id": m.get("embryo_id"), - "confidence": m.get("confidence"), - }) + normalized.append( + { + "number": i + 1, + "pixelX": round(float(px), 1), + "pixelY": round(float(py), 1), + "role": m.get("role", default_role), + "source": m.get("source", "sam"), + "embryo_id": m.get("embryo_id"), + "confidence": m.get("confidence"), + } + ) self._marking_sessions[session_id] = { "markers": list(normalized), @@ -550,31 +600,34 @@ async def start_marking_session( # Encode image as base64 PNG from PIL import Image as PILImage + img = image if img.dtype != np.uint8: img = ((img - img.min()) / max(img.max() - img.min(), 1) * 255).astype(np.uint8) pil_img = PILImage.fromarray(img) buf = io.BytesIO() - pil_img.save(buf, format='PNG') - b64 = base64.b64encode(buf.getvalue()).decode('ascii') + pil_img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode("ascii") h, w = image.shape[:2] # Broadcast to all clients - await self.manager.broadcast({ - "type": "marking_image", - "data": { - "session_id": session_id, - "image_b64": b64, - "width": w, - "height": h, - "initial_markers": normalized, - "default_role": default_role, - "stage_x_um": float(initial_stage_position[0]), - "stage_y_um": float(initial_stage_position[1]), - "pixel_size_um": pixel_size_um, + await self.manager.broadcast( + { + "type": "marking_image", + "data": { + "session_id": session_id, + "image_b64": b64, + "width": w, + "height": h, + "initial_markers": normalized, + "default_role": default_role, + "stage_x_um": float(initial_stage_position[0]), + "stage_y_um": float(initial_stage_position[1]), + "pixel_size_um": pixel_size_um, + }, } - }) + ) logger.info( f"Marking session {session_id} started, image {w}x{h}, " @@ -610,9 +663,9 @@ async def wait_for_marking(self, session_id: str, timeout: float = None) -> list markers = session["markers"] initial_pos = session["initial_stage_position"] - pixel_size = session["pixel_size_um"] + session["pixel_size_um"] h, w = session["image_shape"][:2] - center_x, center_y = w / 2, h / 2 + _center_x, _center_y = w / 2, h / 2 # Convert to embryo entries. Carries role + source so callers can # register each embryo with the right experimental classification. @@ -620,18 +673,20 @@ async def wait_for_marking(self, session_id: str, timeout: float = None) -> list embryos = [] for m in markers: px, py = m["pixelX"], m["pixelY"] - embryos.append({ - "embryo_number": m["number"], - "embryo_id": m.get("embryo_id") or f"embryo_{m['number']:03d}", - "pixel_position": (px, py), - "pixel_x": px, - "pixel_y": py, - "initial_stage_position": initial_pos, - "role": m.get("role", default_role), - "source": m.get("source", "manual"), - "confidence": m.get("confidence"), - "marking_timestamp": m.get("timestamp", datetime.now().isoformat()), - }) + embryos.append( + { + "embryo_number": m["number"], + "embryo_id": m.get("embryo_id") or f"embryo_{m['number']:03d}", + "pixel_position": (px, py), + "pixel_x": px, + "pixel_y": py, + "initial_stage_position": initial_pos, + "role": m.get("role", default_role), + "source": m.get("source", "manual"), + "confidence": m.get("confidence"), + "marking_timestamp": m.get("timestamp", datetime.now().isoformat()), + } + ) # Clean up del self._marking_sessions[session_id] @@ -643,7 +698,7 @@ async def push_volume_3d( volume: np.ndarray, masks: np.ndarray, uid: str, - metadata: Optional[Dict] = None, + metadata: dict | None = None, ): """ Push a 3D segmentation volume to connected clients @@ -667,24 +722,24 @@ async def push_volume_3d( volume_data = Volume3DData( uid=uid, - data_type='segmentation_3d', + data_type="segmentation_3d", timestamp=datetime.now().isoformat(), volume=volume, masks=masks, colors=colors, - metadata=metadata or {} + metadata=metadata or {}, ) # Store the 3D volume self.store.add_volume_3d(volume_data) # Broadcast notification to clients (without the heavy data) - await self.manager.broadcast({ - 'type': 'volume_3d', - 'data': volume_data.to_info_dict() - }) + await self.manager.broadcast({"type": "volume_3d", "data": volume_data.to_info_dict()}) - logger.info(f"Pushed 3D volume {uid} ({volume.shape}) to {len(self.manager.active_connections)} clients") + logger.info( + f"Pushed 3D volume {uid} ({volume.shape}) to" + f" {len(self.manager.active_connections)} clients" + ) async def open_volume_in_browser( self, @@ -699,15 +754,21 @@ async def open_volume_in_browser( projections) instead of launching a desktop Qt window that would block the shared agent/web event loop. Returns the number of clients notified. """ - await self.manager.broadcast({ - "type": "open_volume", - "embryo_id": embryo_id, - "timepoint": timepoint, - "view": view, - }) + await self.manager.broadcast( + { + "type": "open_volume", + "embryo_id": embryo_id, + "timepoint": timepoint, + "view": view, + } + ) n = len(self.manager.active_connections) - logger.info("Requested browser open_volume for %s t%s (%d client(s))", - embryo_id, timepoint, n) + logger.info( + "Requested browser open_volume for %s t%s (%d client(s))", + embryo_id, + timepoint, + n, + ) return n async def on_start(self): @@ -721,6 +782,7 @@ async def on_start(self): # off to uvicorn (whose bind error surfaces inside a background # task and produces an unhelpful log line). import socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock.bind((self.host, self.port)) @@ -729,7 +791,7 @@ async def on_start(self): f"Port {self.port} is already in use. " "Is another instance of the agent running? " "Close it first and try again." - ) + ) from None finally: sock.close() @@ -791,10 +853,10 @@ async def on_stop(self): self._server_task = None logger.info("Visualization server stopped") - async def health_check(self) -> Dict: + async def health_check(self) -> dict: """Return health status with connected client count.""" base = await super().health_check() - base['connected_clients'] = len(self.manager.active_connections) + base["connected_clients"] = len(self.manager.active_connections) return base async def run_forever(self): @@ -811,20 +873,20 @@ def signal_handler(*args): loop = asyncio.get_running_loop() signals_installed = False - if hasattr(signal, 'SIGINT'): + if hasattr(signal, "SIGINT"): try: loop.add_signal_handler(signal.SIGINT, signal_handler) signals_installed = True except NotImplementedError: pass - if hasattr(signal, 'SIGTERM'): + if hasattr(signal, "SIGTERM"): try: loop.add_signal_handler(signal.SIGTERM, signal_handler) except NotImplementedError: pass - if sys.platform == 'win32' and not signals_installed: + if sys.platform == "win32" and not signals_installed: signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) @@ -833,21 +895,17 @@ def signal_handler(*args): logger.info(f"Server running at http://{self.host}:{self.port} - Press Ctrl+C to stop") try: - if sys.platform == 'win32': + if sys.platform == "win32": while not stop_event.is_set(): try: - await asyncio.wait_for( - asyncio.shield(self._server_task), - timeout=0.5 - ) + await asyncio.wait_for(asyncio.shield(self._server_task), timeout=0.5) break except asyncio.TimeoutError: continue else: stop_task = asyncio.create_task(stop_event.wait()) done, pending = await asyncio.wait( - [self._server_task, stop_task], - return_when=asyncio.FIRST_COMPLETED + [self._server_task, stop_task], return_when=asyncio.FIRST_COMPLETED ) for task in pending: task.cancel() diff --git a/gently/ui/web/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py index 46569758..91c560e7 100644 --- a/gently/ui/web/strategy_snapshot.py +++ b/gently/ui/web/strategy_snapshot.py @@ -22,7 +22,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any import yaml @@ -37,10 +37,10 @@ # ui_icon names but resolves them to actual unicode glyphs the swimlane SVG # can render directly. _ROLE_ICONS = { - "star": "★", # ★ - "diamond": "◆", # ◆ - "circle": "●", # ● - "triangle": "▲", # ▲ + "star": "★", # ★ + "diamond": "◆", # ◆ + "circle": "●", # ● + "triangle": "▲", # ▲ } # Default per-timepoint exposure when nothing on disk tells us otherwise. @@ -60,9 +60,9 @@ # --------------------------------------------------------------------------- -def _read_yaml(path: Path) -> Optional[dict]: +def _read_yaml(path: Path) -> dict | None: try: - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) or {} except FileNotFoundError: return None @@ -80,7 +80,7 @@ def _pick_timelapse_yaml(session_dir: Path, legacy_session_dir: Path) -> dict: orchestrator that hasn't yet been restarted (still writing to legacy) isn't shadowed by a stale new-path file. """ - candidates: List[Path] = [ + candidates: list[Path] = [ session_dir / "timelapse.yaml", legacy_session_dir / "timelapse.yaml", ] @@ -93,6 +93,7 @@ def _pick_timelapse_yaml(session_dir: Path, legacy_session_dir: Path) -> dict: return {} if len(docs) == 1: return docs[0][1] + # Pick by saved_at if present, falling back to file mtime. def _saved_at_key(item): path, doc = item @@ -105,11 +106,12 @@ def _saved_at_key(item): return path.stat().st_mtime except OSError: return 0.0 + docs.sort(key=_saved_at_key, reverse=True) return docs[0][1] -def _parse_iso(s: Optional[str]) -> Optional[datetime]: +def _parse_iso(s: str | None) -> datetime | None: if not s: return None try: @@ -118,7 +120,7 @@ def _parse_iso(s: Optional[str]) -> Optional[datetime]: return None -def _elapsed_s(t: Optional[datetime], started_at: datetime) -> Optional[float]: +def _elapsed_s(t: datetime | None, started_at: datetime) -> float | None: if t is None: return None return (t - started_at).total_seconds() @@ -132,19 +134,21 @@ def _elapsed_s(t: Optional[datetime], started_at: datetime) -> Optional[float]: @dataclass class _EmbryoAccum: """Mutable accumulator while replaying timeline events for one embryo.""" + eid: str - phases: List[dict] - trigger_events: List[dict] - power_history_488: List[dict] + phases: list[dict] + trigger_events: list[dict] + power_history_488: list[dict] - def open_phase(self, mode: str, start_s: float, cadence_s: Optional[float] = None, - **extra) -> None: + def open_phase( + self, mode: str, start_s: float, cadence_s: float | None = None, **extra + ) -> None: # If the last phase has no end yet, close it at start_s. if self.phases: last = self.phases[-1] if "end" not in last or last["end"] is None: last["end"] = start_s - ph: Dict[str, Any] = {"mode": mode, "start": start_s, "end": None} + ph: dict[str, Any] = {"mode": mode, "start": start_s, "end": None} if cadence_s is not None: ph["cadence_s"] = cadence_s ph.update(extra) @@ -167,7 +171,7 @@ def build_strategy_snapshot( session_id: str, *, horizon_padding_s: float = 1800.0, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Read the session folder and return the strategy dict the frontend wants. Parameters @@ -272,7 +276,9 @@ def build_strategy_snapshot( "now_offset_s": now_offset_s, "horizon_s": horizon_s, "base_interval_s": base_interval_s, - "dose_budget_base_ms": float(dose_budget_base_ms) if dose_budget_base_ms is not None else None, + "dose_budget_base_ms": float(dose_budget_base_ms) + if dose_budget_base_ms is not None + else None, "per_timepoint_ms": per_timepoint_ms, "monitoring_modes": monitoring_modes, "triggers": triggers, @@ -285,7 +291,7 @@ def build_strategy_snapshot( # --------------------------------------------------------------------------- -def _build_monitoring_modes(mode_names: List[str]) -> List[dict]: +def _build_monitoring_modes(mode_names: list[str]) -> list[dict]: """Resolve each active monitoring mode name into a serialized dict. The orchestrator persists only the names in ``timelapse.yaml``; we @@ -300,16 +306,18 @@ def _build_monitoring_modes(mode_names: List[str]) -> List[dict]: logger.debug("Could not import MONITORING_MODES; skipping mode resolution") return [] - out: List[dict] = [] + out: list[dict] = [] for name in mode_names: factory = MONITORING_MODES.get(name) if factory is None: - out.append({ - "name": name, - "description": "", - "applies_to_roles": [], - "params": {}, - }) + out.append( + { + "name": name, + "description": "", + "applies_to_roles": [], + "params": {}, + } + ) continue try: mode = factory() @@ -319,15 +327,16 @@ def _build_monitoring_modes(mode_names: List[str]) -> List[dict]: # Pull declarative knobs (fast_interval, rampdown_*) off the instance. excluded = {"name", "description", "applies_to_roles"} params = { - k: v for k, v in vars(mode).items() - if not k.startswith("_") and k not in excluded + k: v for k, v in vars(mode).items() if not k.startswith("_") and k not in excluded } - out.append({ - "name": mode.name, - "description": mode.description, - "applies_to_roles": list(mode.applies_to_roles), - "params": params, - }) + out.append( + { + "name": mode.name, + "description": mode.description, + "applies_to_roles": list(mode.applies_to_roles), + "params": params, + } + ) return out @@ -338,31 +347,35 @@ def _build_monitoring_modes(mode_names: List[str]) -> List[dict]: def _build_triggers( *, - interval_rules: List[dict], - power_rules: List[dict], - embryo_roles: Dict[str, str], -) -> List[dict]: - triggers: List[dict] = [] + interval_rules: list[dict], + power_rules: list[dict], + embryo_roles: dict[str, str], +) -> list[dict]: + triggers: list[dict] = [] for r in interval_rules: - triggers.append({ - "id": r["name"], - "kind": "interval_rule", - "label": _humanize_rule_name(r["name"]), - "when_text": _interval_when_text(r), - "then_text": _interval_then_text(r), - "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles), - "one_time": bool(r.get("one_time", True)), - }) + triggers.append( + { + "id": r["name"], + "kind": "interval_rule", + "label": _humanize_rule_name(r["name"]), + "when_text": _interval_when_text(r), + "then_text": _interval_then_text(r), + "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles), + "one_time": bool(r.get("one_time", True)), + } + ) for r in power_rules: - triggers.append({ - "id": r["name"], - "kind": "power_rule", - "label": _humanize_rule_name(r["name"]), - "when_text": _power_when_text(r), - "then_text": _power_then_text(r), - "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles), - "one_time": bool(r.get("one_time", False)), - }) + triggers.append( + { + "id": r["name"], + "kind": "power_rule", + "label": _humanize_rule_name(r["name"]), + "when_text": _power_when_text(r), + "then_text": _power_then_text(r), + "applies_to": _resolve_applies_to_roles(r.get("applies_to"), embryo_roles), + "one_time": bool(r.get("one_time", False)), + } + ) return triggers @@ -406,9 +419,9 @@ def _power_then_text(r: dict) -> str: def _resolve_applies_to_roles( - applies_to: Optional[List[str]], - embryo_roles: Dict[str, str], -) -> List[str]: + applies_to: list[str] | None, + embryo_roles: dict[str, str], +) -> list[str]: """``applies_to`` is a list of embryo ids; resolve to a deduplicated list of role names for the chips. ``None`` means "all roles in the timelapse". @@ -430,14 +443,14 @@ def _resolve_applies_to_roles( # --------------------------------------------------------------------------- -def _read_embryo_roles(session_dir: Path) -> Dict[str, str]: +def _read_embryo_roles(session_dir: Path) -> dict[str, str]: """Map embryo_id -> role by scanning ``embryos/*/embryo.yaml``. We read this from the durable per-embryo file rather than timelapse.yaml so the role is correct even when the embryo isn't in the active timelapse (yet). """ - out: Dict[str, str] = {} + out: dict[str, str] = {} embryos_dir = session_dir / "embryos" if not embryos_dir.is_dir(): return out @@ -451,7 +464,7 @@ def _read_embryo_roles(session_dir: Path) -> Dict[str, str]: return out -def _stop_condition_from_serialized(d: Any) -> Tuple[str, str]: +def _stop_condition_from_serialized(d: Any) -> tuple[str, str]: """Read the per-embryo stop_condition dict and return ``(spec, kind)``. ``kind`` is ``"bounded"`` when ANY component of the (possibly composite) @@ -463,10 +476,10 @@ def _stop_condition_from_serialized(d: Any) -> Tuple[str, str]: if not isinstance(d, dict): return "manual", "open_ended" spec = d.get("spec") or "manual" - types: List[str] = [] + types: list[str] = [] if d.get("condition_type"): types.append(d["condition_type"]) - for ad in (d.get("additional") or []): + for ad in d.get("additional") or []: if ad.get("condition_type"): types.append(ad["condition_type"]) auto_stop = any(t != "manual" for t in types) @@ -476,13 +489,13 @@ def _stop_condition_from_serialized(d: Any) -> Tuple[str, str]: def _build_embryos_static( *, session_dir: Path, - tl_embryos: Dict[str, dict], - embryo_roles: Dict[str, str], - dose_budget_base_ms: Optional[float], + tl_embryos: dict[str, dict], + embryo_roles: dict[str, str], + dose_budget_base_ms: float | None, base_interval_s: float, - started_at: Optional[datetime] = None, - now_offset_s: Optional[float] = None, -) -> List[dict]: + started_at: datetime | None = None, + now_offset_s: float | None = None, +) -> list[dict]: """Build the per-embryo static portion of the snapshot. Dynamic fields (phases, trigger_events, power_history_488) are seeded @@ -493,7 +506,7 @@ def _build_embryos_static( except Exception: ROLE_REGISTRY = {} - out: List[dict] = [] + out: list[dict] = [] # Sort embryo ids so the snapshot is deterministic. for eid in sorted(tl_embryos.keys()): ed = tl_embryos[eid] or {} @@ -503,9 +516,7 @@ def _build_embryos_static( icon = _ROLE_ICONS.get(role_def.ui_icon if role_def else "circle", "●") mult = role_def.photodose_budget_multiplier if role_def else 1.0 dose_budget_ms = ( - float(dose_budget_base_ms) * float(mult) - if dose_budget_base_ms is not None - else 0.0 + float(dose_budget_base_ms) * float(mult) if dose_budget_base_ms is not None else 0.0 ) laser_488 = ed.get("laser_power_488_pct") if laser_488 is None: @@ -519,49 +530,47 @@ def _build_embryos_static( stop_spec, stop_kind = _stop_condition_from_serialized(ed.get("stop_condition")) # Seed: one base-cadence phase from t=0 until now (replay will # split it as cadence_changed events come in). - out.append({ - "id": eid, - "role": role, - "color": color, - "icon": icon, - "dose_used_ms": float(ed.get("total_exposure_ms") or 0.0), - "dose_budget_ms": dose_budget_ms, - "tp_acquired": int(ed.get("timepoints_acquired") or 0), - "stop_condition": stop_spec, - "stop_kind": stop_kind, - "laser_488_pct_now": float(laser_488), - "phases": [ - { - "mode": "base", - "start": 0.0, - "end": None, - "cadence_s": initial_cadence, - } - ], - "trigger_events": [], - "power_history_488": [ - {"at": 0.0, "pct": float(laser_488)} - ], - # Filled in by _project_forward. - "projected_cadence_s": initial_cadence, - "projected_end_s": None, - # When the embryo was marked complete/terminated, as seconds - # from session start. Null while still acquiring. The - # frontend uses this to draw a TERMINATED cap and stop the - # projection bar; without it, a finished embryo's row would - # appear to still be acquiring forever. - "terminated_at_s": _terminated_at_offset( - ed, started_at, now_offset_s - ), - }) + out.append( + { + "id": eid, + "role": role, + "color": color, + "icon": icon, + "dose_used_ms": float(ed.get("total_exposure_ms") or 0.0), + "dose_budget_ms": dose_budget_ms, + "tp_acquired": int(ed.get("timepoints_acquired") or 0), + "stop_condition": stop_spec, + "stop_kind": stop_kind, + "laser_488_pct_now": float(laser_488), + "phases": [ + { + "mode": "base", + "start": 0.0, + "end": None, + "cadence_s": initial_cadence, + } + ], + "trigger_events": [], + "power_history_488": [{"at": 0.0, "pct": float(laser_488)}], + # Filled in by _project_forward. + "projected_cadence_s": initial_cadence, + "projected_end_s": None, + # When the embryo was marked complete/terminated, as seconds + # from session start. Null while still acquiring. The + # frontend uses this to draw a TERMINATED cap and stop the + # projection bar; without it, a finished embryo's row would + # appear to still be acquiring forever. + "terminated_at_s": _terminated_at_offset(ed, started_at, now_offset_s), + } + ) return out def _terminated_at_offset( ed: dict, - started_at: Optional[datetime], - now_offset_s: Optional[float], -) -> Optional[float]: + started_at: datetime | None, + now_offset_s: float | None, +) -> float | None: """Map an embryo's ``completed_at`` ISO timestamp into seconds-from- session-start. Returns ``None`` if the embryo isn't complete yet or we don't have the data to compute the offset. @@ -590,14 +599,14 @@ def _terminated_at_offset( def _resolve_timeline_paths( session_dir: Path, legacy_session_dir: Path, -) -> List[Tuple[Path, bool]]: +) -> list[tuple[Path, bool]]: """Return the timeline.jsonl paths to read, with a per-source flag. The flag indicates whether the file is the global legacy timeline (which mixes multiple sessions and must be filtered by session_id) or a per-session file (no filtering needed). """ - paths: List[Tuple[Path, bool]] = [] + paths: list[tuple[Path, bool]] = [] # Per-session (new) location. p = session_dir / "timeline.jsonl" if p.exists(): @@ -620,8 +629,8 @@ def _replay_timeline( session_dir: Path, legacy_session_dir: Path, session_id: str, - embryo_dicts: List[dict], - triggers: List[dict], + embryo_dicts: list[dict], + triggers: list[dict], started_at: datetime, now_offset_s: float, base_interval_s: float, @@ -649,20 +658,19 @@ def _replay_timeline( # We need to know each embryo's current cadence_s as we go (for the # phase records). Seed from each embryo's initial phase. - current_cadence: Dict[str, float] = { - e["id"]: e["phases"][0].get("cadence_s", base_interval_s) - for e in embryo_dicts + current_cadence: dict[str, float] = { + e["id"]: e["phases"][0].get("cadence_s", base_interval_s) for e in embryo_dicts } # Track last trigger_fired per (embryo, rule) so we can cluster # consecutive fires into one event with a count. - last_trigger: Dict[Tuple[str, str], dict] = {} + last_trigger: dict[tuple[str, str], dict] = {} # Collect events from all sources, filtering global file by session_id. - events: List[Tuple[datetime, dict]] = [] + events: list[tuple[datetime, dict]] = [] seen_ids: set = set() for path, is_global in paths: try: - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line or not line.startswith("{"): @@ -711,12 +719,14 @@ def _replay_timeline( pass mode = _phase_mode_from_name(new_phase_name) _close_open_phase(emb, at_s) - emb["phases"].append({ - "mode": mode, - "start": at_s, - "end": None, - "cadence_s": current_cadence.get(embryo_id, base_interval_s), - }) + emb["phases"].append( + { + "mode": mode, + "start": at_s, + "end": None, + "cadence_s": current_cadence.get(embryo_id, base_interval_s), + } + ) elif subtype == "power_changed" and embryo_id in by_id: wavelength = data.get("wavelength") @@ -726,10 +736,12 @@ def _replay_timeline( if new_pct is None: continue emb = by_id[embryo_id] - emb["power_history_488"].append({ - "at": at_s, - "pct": float(new_pct), - }) + emb["power_history_488"].append( + { + "at": at_s, + "pct": float(new_pct), + } + ) emb["laser_488_pct_now"] = float(new_pct) elif subtype == "trigger_fired" and embryo_id in by_id: @@ -739,10 +751,7 @@ def _replay_timeline( emb = by_id[embryo_id] key = (embryo_id, rule_name) prev = last_trigger.get(key) - if ( - prev is not None - and at_s - prev["at"] <= _TRIGGER_CLUSTER_GAP_S - ): + if prev is not None and at_s - prev["at"] <= _TRIGGER_CLUSTER_GAP_S: prev["count"] = prev.get("count", 1) + 1 prev["at"] = at_s # extend cluster to last fire else: @@ -755,13 +764,15 @@ def _replay_timeline( mode = data.get("mode") or "1hz" hz = 1.0 if mode == "1hz" else 20.0 _close_open_phase(emb, at_s) - emb["phases"].append({ - "mode": "burst", - "start": at_s, - "end": None, - "frames": int(data.get("frames") or 0), - "hz": hz, - }) + emb["phases"].append( + { + "mode": "burst", + "start": at_s, + "end": None, + "frames": int(data.get("frames") or 0), + "hz": hz, + } + ) elif subtype == "burst_completed" and embryo_id in by_id: emb = by_id[embryo_id] @@ -795,7 +806,12 @@ def _ensure_tail_power(emb: dict, now_offset_s: float) -> None: extends the steady segment to the right edge.""" hist = emb.get("power_history_488") or [] if not hist: - emb["power_history_488"] = [{"at": now_offset_s, "pct": emb.get("laser_488_pct_now", _DEFAULT_INITIAL_POWER_PCT)}] + emb["power_history_488"] = [ + { + "at": now_offset_s, + "pct": emb.get("laser_488_pct_now", _DEFAULT_INITIAL_POWER_PCT), + } + ] return if hist[-1]["at"] < now_offset_s: hist.append({"at": now_offset_s, "pct": hist[-1]["pct"]}) @@ -820,7 +836,7 @@ def _phase_mode_from_name(name: str) -> str: def _project_forward( *, - embryo_dicts: List[dict], + embryo_dicts: list[dict], now_offset_s: float, per_timepoint_ms: float, ) -> None: @@ -851,7 +867,7 @@ def _project_forward( def _compute_horizon( now_offset_s: float, - embryo_dicts: List[dict], + embryo_dicts: list[dict], padding_s: float, ) -> float: """Pick a horizon that comfortably contains the past + projected future.""" diff --git a/gently/ui/web/timelapse_tracker.py b/gently/ui/web/timelapse_tracker.py index 665ef1b7..f980001d 100644 --- a/gently/ui/web/timelapse_tracker.py +++ b/gently/ui/web/timelapse_tracker.py @@ -6,7 +6,6 @@ """ from datetime import datetime -from typing import Dict, List, Optional class TimelapseStateTracker: @@ -21,15 +20,17 @@ class TimelapseStateTracker: """ def __init__(self): - self.session_id: Optional[str] = None # Unique ID per experiment + self.session_id: str | None = None # Unique ID per experiment self.status = "IDLE" # IDLE, RUNNING, PAUSED, COMPLETED - self.started_at: Optional[str] = None - self.embryos: Dict[str, dict] = {} # embryo_id -> state + self.started_at: str | None = None + self.embryos: dict[str, dict] = {} # embryo_id -> state self.total_timepoints = 0 self.base_interval = 120 - self.detection_reasoning: Dict[str, List[dict]] = {} # embryo_id -> list of detections - self.projection_uids: Dict[str, Dict[int, str]] = {} # embryo_id -> {timepoint -> projection_uid} - self.volume_paths: Dict[str, Dict[int, str]] = {} # embryo_id -> {timepoint -> volume_path} + self.detection_reasoning: dict[str, list[dict]] = {} # embryo_id -> list of detections + self.projection_uids: dict[ + str, dict[int, str] + ] = {} # embryo_id -> {timepoint -> projection_uid} + self.volume_paths: dict[str, dict[int, str]] = {} # embryo_id -> {timepoint -> volume_path} def handle_event(self, event_type: str, data: dict): """Update state based on incoming event""" @@ -136,9 +137,7 @@ def handle_event(self, event_type: str, data: dict): if eid and eid in self.embryos: self.embryos[eid]["is_complete"] = True self.embryos[eid]["completion_reason"] = data.get("completion_reason") - self.embryos[eid].setdefault( - "completed_at", datetime.now().isoformat() - ) + self.embryos[eid].setdefault("completed_at", datetime.now().isoformat()) elif event_type == "DETECTOR_EVALUATED": # All detector/perception evaluations (with reasoning) - populates reasoning panel @@ -160,7 +159,8 @@ def handle_event(self, event_type: str, data: dict): "description": data.get("description"), "timepoint": timepoint, "volume_uid": data.get("volume_uid"), - "projection_uid": data.get("projection_uid") or projection_uid, # Use stored UID as fallback + "projection_uid": data.get("projection_uid") + or projection_uid, # Use stored UID as fallback "timestamp": datetime.now().isoformat(), # Perception-specific fields "stage": data.get("stage"), @@ -198,15 +198,18 @@ def handle_event(self, event_type: str, data: dict): # before any acquisition has happened. eid = data.get("embryo_id") if eid: - emb = self.embryos.setdefault(eid, { - "embryo_id": eid, - "timepoints": 0, - "is_complete": False, - "first_acquired": None, - "last_acquired": None, - "detections": {}, - "current_stage": None, - }) + emb = self.embryos.setdefault( + eid, + { + "embryo_id": eid, + "timepoints": 0, + "is_complete": False, + "first_acquired": None, + "last_acquired": None, + "detections": {}, + "current_stage": None, + }, + ) if data.get("x") is not None: emb["stage_x_um"] = data["x"] if data.get("y") is not None: @@ -227,13 +230,11 @@ def handle_event(self, event_type: str, data: dict): detector_name = data.get("detector_name", "unknown") self.embryos[eid]["detections"][detector_name] = { "detected": True, - "confidence": data.get("confidence") + "confidence": data.get("confidence"), } if detector_name == "hatching": self.embryos[eid]["is_complete"] = True - self.embryos[eid].setdefault( - "completed_at", datetime.now().isoformat() - ) + self.embryos[eid].setdefault("completed_at", datetime.now().isoformat()) elif event_type == "VERIFICATION_STARTED": # Verification round started for embryo @@ -262,8 +263,12 @@ def handle_event(self, event_type: str, data: dict): # Progress update eid = data.get("embryo_id") if eid and eid in self.embryos and "verification" in self.embryos[eid]: - self.embryos[eid]["verification"]["strategies_complete"] = data.get("strategies_complete", 0) - self.embryos[eid]["verification"]["total_strategies"] = data.get("total_strategies", 5) + self.embryos[eid]["verification"]["strategies_complete"] = data.get( + "strategies_complete", 0 + ) + self.embryos[eid]["verification"]["total_strategies"] = data.get( + "total_strategies", 5 + ) elif event_type == "VERIFICATION_COMPLETED": # Final verification result @@ -304,10 +309,16 @@ def handle_event(self, event_type: str, data: dict): if data.get("change") == "role_assigned": eid = data.get("embryo_id") if eid: - emb = self.embryos.setdefault(eid, { - "embryo_id": eid, "timepoints": 0, "is_complete": False, - "detections": {}, "current_stage": None, - }) + emb = self.embryos.setdefault( + eid, + { + "embryo_id": eid, + "timepoints": 0, + "is_complete": False, + "detections": {}, + "current_stage": None, + }, + ) if data.get("new_role"): emb["role"] = data["new_role"] @@ -316,10 +327,16 @@ def handle_event(self, event_type: str, data: dict): elif event_type == "EMBRYO_CADENCE_CHANGED": eid = data.get("embryo_id") if eid: - emb = self.embryos.setdefault(eid, { - "embryo_id": eid, "timepoints": 0, "is_complete": False, - "detections": {}, "current_stage": None, - }) + emb = self.embryos.setdefault( + eid, + { + "embryo_id": eid, + "timepoints": 0, + "is_complete": False, + "detections": {}, + "current_stage": None, + }, + ) if data.get("new_phase") is not None: emb["cadence_phase"] = data["new_phase"] if data.get("new_interval_s") is not None: @@ -331,22 +348,30 @@ def handle_event(self, event_type: str, data: dict): elif event_type == "POWER_RAMP_STEP": eid = data.get("embryo_id") if eid: - emb = self.embryos.setdefault(eid, { - "embryo_id": eid, "timepoints": 0, "is_complete": False, - "detections": {}, "current_stage": None, - }) + emb = self.embryos.setdefault( + eid, + { + "embryo_id": eid, + "timepoints": 0, + "is_complete": False, + "detections": {}, + "current_stage": None, + }, + ) wavelength = data.get("wavelength", 488) if wavelength == 488: emb["laser_power_488_pct"] = data.get("new_pct") - emb.setdefault("power_history", []).append({ - "wavelength": wavelength, - "old_pct": data.get("old_pct"), - "new_pct": data.get("new_pct"), - "direction": data.get("direction"), - "rule": data.get("rule"), - "intensity_level": data.get("intensity_level"), - "timestamp": datetime.now().isoformat(), - }) + emb.setdefault("power_history", []).append( + { + "wavelength": wavelength, + "old_pct": data.get("old_pct"), + "new_pct": data.get("new_pct"), + "direction": data.get("direction"), + "rule": data.get("rule"), + "intensity_level": data.get("intensity_level"), + "timestamp": datetime.now().isoformat(), + } + ) # cap history per embryo if len(emb["power_history"]) > 200: emb["power_history"] = emb["power_history"][-200:] @@ -354,10 +379,16 @@ def handle_event(self, event_type: str, data: dict): elif event_type == "CLAUDE_DETECTOR_RESULT": eid = data.get("embryo_id") if eid: - emb = self.embryos.setdefault(eid, { - "embryo_id": eid, "timepoints": 0, "is_complete": False, - "detections": {}, "current_stage": None, - }) + emb = self.embryos.setdefault( + eid, + { + "embryo_id": eid, + "timepoints": 0, + "is_complete": False, + "detections": {}, + "current_stage": None, + }, + ) findings = data.get("findings") or {} emb["last_intensity_level"] = findings.get("intensity_level") emb["last_structure_quality"] = findings.get("structure_quality") @@ -365,13 +396,24 @@ def handle_event(self, event_type: str, data: dict): if findings.get("has_hatched"): emb["hatched"] = True - elif event_type in ("BURST_QUEUED", "BURST_START", "BURST_FRAME", "BURST_COMPLETE"): + elif event_type in ( + "BURST_QUEUED", + "BURST_START", + "BURST_FRAME", + "BURST_COMPLETE", + ): eid = data.get("embryo_id") if eid: - emb = self.embryos.setdefault(eid, { - "embryo_id": eid, "timepoints": 0, "is_complete": False, - "detections": {}, "current_stage": None, - }) + emb = self.embryos.setdefault( + eid, + { + "embryo_id": eid, + "timepoints": 0, + "is_complete": False, + "detections": {}, + "current_stage": None, + }, + ) emb.setdefault("burst", {}) burst_state = emb["burst"] if event_type == "BURST_QUEUED": @@ -409,7 +451,7 @@ def to_dict(self) -> dict: "embryos": self.embryos, "total_timepoints": self.total_timepoints, "base_interval": self.base_interval, - "detection_reasoning": self.detection_reasoning + "detection_reasoning": self.detection_reasoning, } def reset(self): @@ -436,14 +478,17 @@ def seed_from_experiment(self, experiment) -> int: y = pos.get("y") if isinstance(pos, dict) else None if x is None or y is None: continue - self.handle_event("EMBRYO_DETECTED", { - "embryo_id": eid, - "uid": getattr(emb, "uid", None), - "x": x, - "y": y, - "role": getattr(emb, "role", "test"), - "user_label": getattr(emb, "user_label", None), - "confidence": getattr(emb, "detection_confidence", None), - }) + self.handle_event( + "EMBRYO_DETECTED", + { + "embryo_id": eid, + "uid": getattr(emb, "uid", None), + "x": x, + "y": y, + "role": getattr(emb, "role", "test"), + "user_label": getattr(emb, "user_label", None), + "confidence": getattr(emb, "detection_confidence", None), + }, + ) seeded += 1 return seeded diff --git a/gently/ui/web/volume_helpers.py b/gently/ui/web/volume_helpers.py index e92954ae..2ff479d9 100644 --- a/gently/ui/web/volume_helpers.py +++ b/gently/ui/web/volume_helpers.py @@ -10,11 +10,10 @@ import re from io import BytesIO from pathlib import Path -from typing import Optional import numpy as np -from gently.core.imaging import normalize_to_uint8, image_to_base64 +from gently.core.imaging import image_to_base64, normalize_to_uint8 logger = logging.getLogger(__name__) @@ -22,7 +21,7 @@ VOLUME_UID_PATTERN = re.compile(r"volume_(.+)_t(\d+)$") -def parse_volume_uid(uid: str) -> Optional[tuple]: +def parse_volume_uid(uid: str) -> tuple | None: """Parse a volume UID into (embryo_id, timepoint) or return None.""" if not uid.startswith("volume_"): return None @@ -42,9 +41,9 @@ def load_volume_from_disk(volume_path: str) -> np.ndarray: Cropped 3D numpy array (Z, H, W) """ from gently.core.imaging import ( - load_volume, - compute_crop_bounds, apply_crop_bounds, + compute_crop_bounds, + load_volume, ) path = Path(volume_path) @@ -67,7 +66,7 @@ def array_to_png_bytes(img_array: np.ndarray) -> bytes: img_array = normalize_to_uint8(img_array, method="simple") img = Image.fromarray(img_array) buf = BytesIO() - img.save(buf, format='PNG') + img.save(buf, format="PNG") return buf.getvalue() diff --git a/launch_gently.py b/launch_gently.py index 57dee885..0663c500 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -21,16 +21,15 @@ python launch_gently.py --debug # Debug logging """ +import argparse import asyncio -import json import logging import os -import sys import shutil import subprocess -import argparse -from pathlib import Path +import sys from datetime import datetime +from pathlib import Path import yaml @@ -50,13 +49,13 @@ if not any(flag in sys.argv for flag in ("-h", "--help")): print("Starting gently — loading modules (this can take a few seconds)...", flush=True) -from gently.log_config import configure_logging -from gently.core.log_bridge import configure_log_bridge from gently.app.agent import MicroscopyAgent +from gently.core.file_store import FileStore +from gently.core.log_bridge import configure_log_bridge +from gently.hardware import get_hardware, load_hardware +from gently.log_config import configure_logging from gently.organisms import load_organism -from gently.hardware import load_hardware, get_hardware from gently.settings import settings -from gently.core.file_store import FileStore logger = logging.getLogger(__name__) @@ -88,11 +87,13 @@ def _build_session_items(store: FileStore) -> list: session_id = session.get("session_id", "unknown") embryos = store.list_embryos(session_id) embryo_count = len(embryos) if embryos else 0 - items.append({ - "session_id": session_id, - "embryo_count": embryo_count, - "time": _format_elapsed(session.get("last_active", "")), - }) + items.append( + { + "session_id": session_id, + "embryo_count": embryo_count, + "time": _format_elapsed(session.get("last_active", "")), + } + ) return items @@ -136,7 +137,7 @@ def _print_banner(viz_url, device_connected, offline, storage_dir, log_file, res print(f" Device: {dev}") print(f" Storage: {storage_dir}") print(f" Logs: {log_file}") - print(f" Stop: Ctrl-C") + print(" Stop: Ctrl-C") print(f" {line}") print() @@ -153,7 +154,11 @@ def _open_browser(url: str) -> None: override = os.environ.get("GENTLY_BROWSER", "").strip() # 1) Registered browser names (override first, then Chrome aliases). - for name in ([override] if override else []) + ["chrome", "google-chrome", "chromium"]: + for name in ([override] if override else []) + [ + "chrome", + "google-chrome", + "chromium", + ]: try: webbrowser.get(name).open(url) return @@ -171,8 +176,10 @@ def _open_browser(url: str) -> None: try: if exe and Path(exe).exists(): webbrowser.register( - "gently-browser", None, - webbrowser.BackgroundBrowser(exe), preferred=True, + "gently-browser", + None, + webbrowser.BackgroundBrowser(exe), + preferred=True, ) webbrowser.get("gently-browser").open(url) return @@ -210,13 +217,21 @@ def run_ink_picker(tui_dist: Path, sessions_json: str) -> str | None: # Parse the SESSION: protocol line from stdout for line in (proc.stdout or "").splitlines(): if line.startswith("SESSION:"): - selected = line[len("SESSION:"):].strip() + selected = line[len("SESSION:") :].strip() return selected if selected else None return None -async def main(offline: bool = False, resume_session: str = None, show_sessions: bool = False, pick_session: bool = False, log_level: str = "WARNING", no_browser: bool = False, no_api: bool = False): +async def main( + offline: bool = False, + resume_session: str = None, + show_sessions: bool = False, + pick_session: bool = False, + log_level: str = "WARNING", + no_browser: bool = False, + no_api: bool = False, +): # Set up log file in storage directory # Unified with FileStore: logs live under the same root as data # (settings.storage.base_path reads GENTLY_STORAGE_PATH). Previously this @@ -250,6 +265,7 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: # Create unified store (FileStore) early for session queries from gently.core.gently_manifest import write_manifest + write_manifest(storage_dir) store = FileStore(storage_dir) @@ -261,6 +277,7 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: if os.environ.get("GENTLY_NO_AUTH", "").strip().lower() not in ("1", "true", "yes"): try: from gently.ui.web.accounts import AccountStore, set_account_store + account_store = AccountStore(storage_dir / "auth") set_account_store(account_store) admin_creds = account_store.bootstrap_admin_if_empty() @@ -285,8 +302,10 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: if sessions: session_to_resume = sessions[0].get("session_id") if pick_session: - print(f"Resuming most recent session: {session_to_resume} " - "(interactive session picking is moving into the browser)") + print( + f"Resuming most recent session: {session_to_resume} " + "(interactive session picking is moving into the browser)" + ) else: print("No sessions found - starting fresh") elif resume_session: @@ -305,11 +324,12 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: if not offline: hw = get_hardware() http_url = f"http://{settings.network.device_host}:{settings.network.device_port}" - if hasattr(hw, 'create_client'): + if hasattr(hw, "create_client"): client = hw.create_client(http_url=http_url) else: # Fallback for hardware modules without create_client from gently.app.queue_server_client import QueueServerClient + client = QueueServerClient(http_url=http_url) connected = await client.connect() if not connected: @@ -320,14 +340,15 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: # makes Claude hallucinate XML tool calls as plain text. logger.debug( "Device layer not reachable at %s — microscope tools " - "available but will return errors until connected", http_url, + "available but will return errors until connected", + http_url, ) # Configure device session for zero-copy volume transfer if client and client.is_connected: try: incoming = str(store.incoming_dir) - resp = await client.configure_device_session(incoming) + await client.configure_device_session(incoming) logger.info("Device session configured: volume_dir=%s", incoming) except Exception as e: logger.error("Failed to configure device session (volumes will be slow): %s", e) @@ -336,6 +357,7 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: if client and client.is_connected: try: from gently.harness.microscope import register_microscope_tools + n = register_microscope_tools(client) if n: logger.info("Registered %d microscope tools from device layer", n) @@ -355,6 +377,7 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: cert_path, key_path = None, None try: from gently.mesh.tls import ensure_tls_cert, get_cert_fingerprint + _config_dir = Path(__file__).parent / "config" cert_path, key_path = ensure_tls_cert(_config_dir) except Exception: @@ -364,15 +387,20 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions: # self-signed certs trigger browser "unsafe" warnings for visitors). await agent.start_viz_server(port=settings.network.viz_port) scheme = "http" - viz_url = f"{scheme}://localhost:{settings.network.viz_port}" if agent.viz_server is not None else None + viz_url = ( + f"{scheme}://localhost:{settings.network.viz_port}" + if agent.viz_server is not None + else None + ) # ── Mesh discovery ────────────────────────────────────────────── mesh = None try: + import uuid as _uuid + from gently.mesh import MeshService, register_mesh_routes from gently.mesh.audit import MeshAuditLog from gently.mesh.pairing import PairingManager - import uuid as _uuid # Persistent instance ID instance_id_path = Path(__file__).parent / "config" / "mesh_instance_id" @@ -398,6 +426,7 @@ def _capability_provider(): # GPU detection — try torch first, fall back to nvidia-smi try: import torch + if torch.cuda.is_available(): caps["has_gpu"] = True caps["gpu_name"] = torch.cuda.get_device_name(0) @@ -407,10 +436,15 @@ def _capability_provider(): except ImportError: try: import subprocess as _sp + out = _sp.check_output( - ["nvidia-smi", "--query-gpu=name,memory.total", - "--format=csv,noheader,nounits"], - timeout=5, text=True, + [ + "nvidia-smi", + "--query-gpu=name,memory.total", + "--format=csv,noheader,nounits", + ], + timeout=5, + text=True, ).strip() if out: parts = out.split(",", 1) @@ -430,6 +464,7 @@ def _capability_provider(): def _status_provider(): import gently as _gently + return { "session_id": agent.session_id or "", "acquisition_status": "idle", @@ -442,6 +477,7 @@ def _status_provider(): } import socket as _socket + config_dir = Path(__file__).parent / "config" audit_log = MeshAuditLog(config_dir) pairing_mgr = PairingManager( @@ -474,27 +510,32 @@ def _status_provider(): await mesh.start() except Exception as e: import logging as _log + _log.getLogger(__name__).warning(f"Mesh discovery failed to start: {e}") mesh = None # ── End mesh ──────────────────────────────────────────────────── # Attach the agent bridge to the viz server from gently.harness.bridge import AgentBridge + bridge = AgentBridge(agent) - bridge.set_launch_info({ - "device_connected": client.is_connected if client else False, - "sam_available": client.has_sam if client else False, - "offline": offline or (client is None) or not client.is_connected, - "store_path": str(storage_dir), - "viz_url": viz_url, - "log_path": str(log_file), - "resumed": session_to_resume is not None, - "mesh_service": mesh, - }) + bridge.set_launch_info( + { + "device_connected": client.is_connected if client else False, + "sam_available": client.has_sam if client else False, + "offline": offline or (client is None) or not client.is_connected, + "store_path": str(storage_dir), + "viz_url": viz_url, + "log_path": str(log_file), + "resumed": session_to_resume is not None, + "mesh_service": mesh, + } + ) # Initialize startup wizard (gap-driven onboarding) from gently.harness.memory.file_store import FileContextStore + agent_dir = storage_dir / "agent" context_store = FileContextStore(agent_dir) agent.set_context_store(context_store) @@ -540,6 +581,7 @@ def _status_provider(): # bare Event().wait(), so install signal handlers and poll on a short # interval (which also lets a pending KeyboardInterrupt surface). import signal as _signal + _loop = asyncio.get_running_loop() _stop = asyncio.Event() try: @@ -550,6 +592,7 @@ def _status_provider(): # signal.signal, waking the loop via call_soon_threadsafe. def _sig(*_a): _loop.call_soon_threadsafe(_stop.set) + try: _signal.signal(_signal.SIGINT, _sig) _signal.signal(_signal.SIGTERM, _sig) @@ -565,6 +608,7 @@ def _sig(*_a): # Suppress noisy CancelledError / overlapped IO errors from # uvicorn during shutdown on Windows. import logging as _logging + _logging.getLogger("uvicorn.error").setLevel(_logging.CRITICAL) _logging.getLogger("uvicorn").setLevel(_logging.CRITICAL) # Cleanup: stop mesh service @@ -585,15 +629,29 @@ def cli_main(): """Sync entry point for ``gently`` console script (pyproject.toml).""" parser = argparse.ArgumentParser(description="Launch Microscopy Agent") parser.add_argument("--offline", action="store_true", help="Run without server connections") - parser.add_argument("--no-api", action="store_true", - help="UI-only mode: boot the web UI without any Anthropic API key. " - "Chat, perception, and plan generation are disabled.") + parser.add_argument( + "--no-api", + action="store_true", + help="UI-only mode: boot the web UI without any Anthropic API key. " + "Chat, perception, and plan generation are disabled.", + ) parser.add_argument("--sessions", action="store_true", help="List available sessions and exit") - parser.add_argument("--resume", nargs="?", const="__PICK__", metavar="ID", - help="Resume a session. Without ID: shows picker. With ID: resumes that session.") - parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose (INFO) logging") + parser.add_argument( + "--resume", + nargs="?", + const="__PICK__", + metavar="ID", + help="Resume a session. Without ID: shows picker. With ID: resumes that session.", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose (INFO) logging" + ) parser.add_argument("--debug", action="store_true", help="Enable debug logging (most verbose)") - parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the web UI in a browser") + parser.add_argument( + "--no-browser", + action="store_true", + help="Do not auto-open the web UI in a browser", + ) args = parser.parse_args() # An API key is required unless running in UI-only mode. @@ -613,19 +671,21 @@ def cli_main(): if args.debug: log_level = "DEBUG" - pick_session = (args.resume == "__PICK__") + pick_session = args.resume == "__PICK__" resume_id = args.resume if args.resume and args.resume != "__PICK__" else None try: - asyncio.run(main( - offline=args.offline, - show_sessions=args.sessions, - resume_session=resume_id, - pick_session=pick_session, - log_level=log_level, - no_browser=args.no_browser, - no_api=args.no_api, - )) + asyncio.run( + main( + offline=args.offline, + show_sessions=args.sessions, + resume_session=resume_id, + pick_session=pick_session, + log_level=log_level, + no_browser=args.no_browser, + no_api=args.no_api, + ) + ) except (KeyboardInterrupt, RuntimeError, SystemExit): pass diff --git a/pyproject.toml b/pyproject.toml index 82f2ac3e..003ae9e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,8 @@ device = [ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", + "ruff>=0.4.0", + "pre-commit>=3.7.0", ] [project.scripts] @@ -136,3 +138,10 @@ exclude = ["tests*", "benchmarks*", "scripts*", "examples*"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/scripts/auto_label_examples.py b/scripts/auto_label_examples.py index 3ef2b87a..b12f6626 100644 --- a/scripts/auto_label_examples.py +++ b/scripts/auto_label_examples.py @@ -16,11 +16,8 @@ import base64 import io import json -import os -import random import sys from pathlib import Path -from typing import Dict, List, Optional, Tuple import anthropic import numpy as np @@ -34,7 +31,8 @@ STAGES = ["early", "comma", "pretzel", "3fold", "hatching", "hatched"] # Staging prompt for Claude -STAGE_PROMPT = """You are classifying C. elegans embryo developmental stages from diSPIM microscopy max projections. +STAGE_PROMPT = """You are classifying C. elegans embryo developmental stages from diSPIM +microscopy max projections. The stages in order are: 1. EARLY - Round/oval shape, no visible folding, may see cell divisions @@ -77,7 +75,7 @@ def sample_timelapse( timelapse_dir: Path, embryo_num: int = 1, num_samples: int = 40, -) -> List[Tuple[int, Path]]: +) -> list[tuple[int, Path]]: """ Sample images spread across timelapse to cover all stages. @@ -100,8 +98,8 @@ def sample_timelapse( def get_timepoint(f: Path) -> int: # Pattern: embryo_001_embryo001_t0042_... name = f.stem - for part in name.split('_'): - if part.startswith('t') and part[1:].isdigit(): + for part in name.split("_"): + if part.startswith("t") and part[1:].isdigit(): return int(part[1:]) return 0 @@ -122,26 +120,28 @@ async def classify_image( client: anthropic.Anthropic, image_b64: str, timepoint: int, -) -> Dict: +) -> dict: """Use Claude to classify a single image.""" response = await asyncio.to_thread( client.messages.create, model="claude-haiku-4-5-20251001", # Fast and cheap for bulk labeling max_tokens=200, - messages=[{ - "role": "user", - "content": [ - {"type": "text", "text": STAGE_PROMPT}, - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/jpeg", - "data": image_b64, - } - } - ] - }] + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": STAGE_PROMPT}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": image_b64, + }, + }, + ], + } + ], ) # Parse response @@ -149,23 +149,29 @@ async def classify_image( try: # Find JSON in response import re - json_match = re.search(r'\{[^{}]+\}', text) + + json_match = re.search(r"\{[^{}]+\}", text) if json_match: result = json.loads(json_match.group()) result["timepoint"] = timepoint return result - except: + except Exception: pass - return {"stage": "unknown", "confidence": 0.0, "notes": text[:100], "timepoint": timepoint} + return { + "stage": "unknown", + "confidence": 0.0, + "notes": text[:100], + "timepoint": timepoint, + } async def auto_label_batch( timelapse_dir: Path, embryo_num: int, num_samples: int = 40, - output_file: Optional[Path] = None, -) -> Dict[str, List[Dict]]: + output_file: Path | None = None, +) -> dict[str, list[dict]]: """ Auto-label a batch of images from a timelapse. @@ -178,7 +184,7 @@ async def auto_label_batch( samples = sample_timelapse(timelapse_dir, embryo_num, num_samples) # Process images - results_by_stage: Dict[str, List[Dict]] = {stage: [] for stage in STAGES} + results_by_stage: dict[str, list[dict]] = {stage: [] for stage in STAGES} results_by_stage["unknown"] = [] print(f"Classifying {len(samples)} images with Claude...") @@ -216,15 +222,15 @@ async def auto_label_batch( if count > 0: avg_conf = sum(r["confidence"] for r in results_by_stage[stage]) / count timepoints = [r["timepoint"] for r in results_by_stage[stage]] - print(f" {stage:10s}: {count:3d} images (avg confidence: {avg_conf:.0%}, T={min(timepoints)}-{max(timepoints)})") + print( + f" {stage:10s}: {count:3d} images (avg confidence: {avg_conf:.0%}," + f" T={min(timepoints)}-{max(timepoints)})" + ) # Save results for review output_file = output_file or Path("labeled_results.json") save_data = { - stage: [ - {k: v for k, v in r.items() if k != "image_b64"} - for r in results - ] + stage: [{k: v for k, v in r.items() if k != "image_b64"} for r in results] for stage, results in results_by_stage.items() } save_data["_metadata"] = { @@ -241,7 +247,7 @@ async def auto_label_batch( def populate_examples( - results_by_stage: Dict[str, List[Dict]], + results_by_stage: dict[str, list[dict]], examples_dir: Path, max_per_stage: int = 3, min_confidence: float = 0.7, @@ -284,11 +290,14 @@ def populate_examples( # Save as JPEG img_bytes = base64.b64decode(b64) - out_path = stage_dir / f"example_{i+1:03d}.jpg" + out_path = stage_dir / f"example_{i + 1:03d}.jpg" with open(out_path, "wb") as f: f.write(img_bytes) - print(f" {stage}: Saved {out_path.name} (T={result['timepoint']}, conf={result['confidence']:.0%})") + print( + f" {stage}: Saved {out_path.name}" + f" (T={result['timepoint']}, conf={result['confidence']:.0%})" + ) print("\nDone! Examples populated.") @@ -324,9 +333,9 @@ async def interactive_review(results_file: Path, examples_dir: Path): while True: choice = input("\nChoice: ").strip().lower() - if choice == 'q': + if choice == "q": break - elif choice == 'a': + elif choice == "a": # Reload with image data and populate print("\nReloading images and populating examples...") # We need to reload since we didn't save b64 data @@ -338,7 +347,7 @@ async def interactive_review(results_file: Path, examples_dir: Path): ) populate_examples(results, examples_dir) break - elif choice == 'p': + elif choice == "p": # Just populate from saved file - need to reload images print("Reloading images...") results_with_images = {} @@ -354,12 +363,17 @@ async def interactive_review(results_file: Path, examples_dir: Path): results_with_images[stage].append(item_copy) populate_examples(results_with_images, examples_dir) break - elif choice == 's': - stage = input("Which stage? (early/comma/pretzel/3fold/hatching/hatched): ").strip().lower() + elif choice == "s": + stage = ( + input("Which stage? (early/comma/pretzel/3fold/hatching/hatched): ").strip().lower() + ) if stage in data: print(f"\n{stage} examples:") for item in data[stage]: - print(f" T={item['timepoint']:04d}: {item.get('notes', 'no notes')} (conf={item.get('confidence', 0):.0%})") + print( + f" T={item['timepoint']:04d}: {item.get('notes', 'no notes')}" + f" (conf={item.get('confidence', 0):.0%})" + ) else: print(f"No examples for {stage}") @@ -370,9 +384,16 @@ def main(): parser.add_argument("--embryo", "-e", type=int, default=1, help="Embryo number to sample") parser.add_argument("--samples", "-n", type=int, default=40, help="Number of images to sample") parser.add_argument("--review", "-r", action="store_true", help="Review existing labels") - parser.add_argument("--output", "-o", default="labeled_results.json", help="Output file for labels") + parser.add_argument( + "--output", "-o", default="labeled_results.json", help="Output file for labels" + ) parser.add_argument("--examples-dir", default="gently/examples", help="Examples directory") - parser.add_argument("--auto-populate", "-a", action="store_true", help="Auto-populate without review") + parser.add_argument( + "--auto-populate", + "-a", + action="store_true", + help="Auto-populate without review", + ) args = parser.parse_args() @@ -391,12 +412,14 @@ def main(): print(f"Directory not found: {timelapse_dir}") return - results = asyncio.run(auto_label_batch( - timelapse_dir, - args.embryo, - args.samples, - output_file, - )) + results = asyncio.run( + auto_label_batch( + timelapse_dir, + args.embryo, + args.samples, + output_file, + ) + ) if args.auto_populate: populate_examples(results, examples_dir) diff --git a/scripts/contrast_variants.py b/scripts/contrast_variants.py index 177b0f94..5e307418 100644 --- a/scripts/contrast_variants.py +++ b/scripts/contrast_variants.py @@ -5,6 +5,7 @@ """ from pathlib import Path + import numpy as np import tifffile from PIL import Image @@ -17,7 +18,7 @@ def preprocess(proj: np.ndarray, recipe: str) -> np.ndarray: """Apply one preprocessing variant to a 2D projection.""" arr = proj.astype(np.float32) - if recipe == "current_div16": # current production + if recipe == "current_div16": # current production arr = np.maximum(arr - 100.0, 0.0) return np.clip(arr / 16.0, 0, 255).astype(np.uint8) if recipe == "div8": @@ -29,21 +30,21 @@ def preprocess(proj: np.ndarray, recipe: str) -> np.ndarray: if recipe == "log": arr = np.maximum(arr - 100.0, 1.0) return np.clip(np.log10(arr) * 80.0, 0, 255).astype(np.uint8) - if recipe == "gamma": # divisor 8 + gamma 0.5 + if recipe == "gamma": # divisor 8 + gamma 0.5 arr = np.maximum(arr - 100.0, 0.0) scaled = np.clip(arr / 8.0, 0, 255) / 255.0 return np.clip(np.power(scaled, 0.5) * 255.0, 0, 255).astype(np.uint8) - if recipe == "soft_log": # gentler log: log10(x+1)*60 + if recipe == "soft_log": # gentler log: log10(x+1)*60 arr = np.maximum(arr - 100.0, 0.0) return np.clip(np.log10(arr + 1.0) * 60.0, 0, 255).astype(np.uint8) raise ValueError(recipe) CASES = [ - ("embryo_004", 1), # blank baseline — must stay dark - ("embryo_003", 20), # pre-onset — body should be visible - ("embryo_003", 28), # mid-onset — body + puncta - ("embryo_003", 40), # mature — body + puncta + neurites + ("embryo_004", 1), # blank baseline — must stay dark + ("embryo_003", 20), # pre-onset — body should be visible + ("embryo_003", 28), # mid-onset — body + puncta + ("embryo_003", 40), # mature — body + puncta + neurites ] RECIPES = ["current_div16", "div8", "div4", "log", "gamma", "soft_log"] @@ -68,12 +69,18 @@ def main(): continue vol = tifffile.imread(str(path)) proj = make_projection(vol) - print(f"\n{embryo} t{tp:04d}: shape={proj.shape} min={proj.min()} max={proj.max()} p99={np.percentile(proj,99):.0f}") + print( + f"\n{embryo} t{tp:04d}: shape={proj.shape}" + f" min={proj.min()} max={proj.max()} p99={np.percentile(proj, 99):.0f}" + ) for recipe in RECIPES: out_img = preprocess(proj, recipe) p = OUT / f"{embryo}_t{tp:04d}_{recipe}.png" Image.fromarray(out_img).save(p) - print(f" {recipe:<15} display p50={np.percentile(out_img,50):.0f} p90={np.percentile(out_img,90):.0f} max={out_img.max()}") + print( + f" {recipe:<15} display p50={np.percentile(out_img, 50):.0f}" + f" p90={np.percentile(out_img, 90):.0f} max={out_img.max()}" + ) if __name__ == "__main__": diff --git a/scripts/dry_run_dopaminergic_detector.py b/scripts/dry_run_dopaminergic_detector.py index 482e965c..53d940b4 100644 --- a/scripts/dry_run_dopaminergic_detector.py +++ b/scripts/dry_run_dopaminergic_detector.py @@ -24,20 +24,28 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) -from gently.app.detectors.dopaminergic_signal import DopaminergicSignalDetector +from gently.app.detectors.dopaminergic_signal import DopaminergicSignalDetector # noqa: E402 SESSION = Path(r"D:/Gently3/sessions/20260522_1801_unnamed_ec5ea7ba") # (embryo, timepoint, expected behaviour from human annotation) CASES = [ - ("embryo_004", 1, "FALSE POSITIVE: blank embryo, no puncta. Expect NONE / NONE."), + ("embryo_004", 1, "FALSE POSITIVE: blank embryo, no puncta. Expect NONE / NONE."), ("embryo_003", 20, "Just before onset. Expect NONE or WEAK / NONE."), ("embryo_003", 21, "Onset starting. Expect WEAK / NONE or PARTIAL."), ("embryo_003", 25, "Two bulbs visible. Expect MEDIUM / PARTIAL."), ("embryo_003", 26, "Three bulbs. Expect MEDIUM / PARTIAL."), ("embryo_003", 28, "Neurite extension starting. Expect MEDIUM / PARTIAL or GOOD."), - ("embryo_003", 29, "Neurite extending further. Expect MEDIUM-STRONG / PARTIAL or GOOD."), - ("embryo_003", 40, "Mature pattern, multiple bright bodies + neurites. Expect STRONG / GOOD."), + ( + "embryo_003", + 29, + "Neurite extending further. Expect MEDIUM-STRONG / PARTIAL or GOOD.", + ), + ( + "embryo_003", + 40, + "Mature pattern, multiple bright bodies + neurites. Expect STRONG / GOOD.", + ), ] @@ -70,22 +78,27 @@ async def _run_one(detector, claude, embryo: str, timepoint: int, expected: str) raw = result.raw_response or {} if isinstance(raw, dict): description = raw.get("description", "(no description)") - classifier_raw = raw.get("classifier_raw", "") + raw.get("classifier_raw", "") else: # legacy single-call shape description = "(no description — legacy single-call detector)" - classifier_raw = raw if isinstance(raw, str) else "" + raw if isinstance(raw, str) else "" print("PERCEIVER (Stage 1):") print(description.strip()) - print(f"\nCLASSIFIER (Stage 2):") + print("\nCLASSIFIER (Stage 2):") findings = result.findings or {} - print(json.dumps({ - "intensity_level": findings.get("intensity_level"), - "structure_quality": findings.get("structure_quality"), - "has_hatched": findings.get("has_hatched"), - "reasoning": result.reasoning, - }, indent=2)) + print( + json.dumps( + { + "intensity_level": findings.get("intensity_level"), + "structure_quality": findings.get("structure_quality"), + "has_hatched": findings.get("has_hatched"), + "reasoning": result.reasoning, + }, + indent=2, + ) + ) if result.error: print(f"ERROR: {result.error}") print(f"elapsed: {result.elapsed_ms:.0f}ms") diff --git a/scripts/dump_perceiver_input.py b/scripts/dump_perceiver_input.py index bbf29b58..9c1c1506 100644 --- a/scripts/dump_perceiver_input.py +++ b/scripts/dump_perceiver_input.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) -from gently.app.detectors.dopaminergic_signal import _volume_to_b64 +from gently.app.detectors.dopaminergic_signal import _volume_to_b64 # noqa: E402 SESSION = Path(r"D:/Gently3/sessions/20260522_1801_unnamed_ec5ea7ba") OUT = Path("./perceiver_input") diff --git a/scripts/extract_stage_examples.py b/scripts/extract_stage_examples.py index bacf162a..208e49c9 100644 --- a/scripts/extract_stage_examples.py +++ b/scripts/extract_stage_examples.py @@ -13,13 +13,16 @@ python scripts/extract_stage_examples.py --session 3fb70aca --embryo embryo_1 # Using session ID with custom storage path - python scripts/extract_stage_examples.py --session exp_20251219_185542_f7df153a --storage D:/Gently --embryo embryo_1 + python scripts/extract_stage_examples.py \ + --session exp_20251219_185542_f7df153a --storage D:/Gently --embryo embryo_1 # Using direct source path - python scripts/extract_stage_examples.py --source multi_embryo_volumes/20251122_141240 --embryo embryo_001 + python scripts/extract_stage_examples.py \ + --source multi_embryo_volumes/20251122_141240 --embryo embryo_001 # Extract specific timepoint range - python scripts/extract_stage_examples.py --session 3fb70aca --embryo embryo_1 --start 50 --end 100 + python scripts/extract_stage_examples.py \ + --session 3fb70aca --embryo embryo_1 --start 50 --end 100 """ import argparse @@ -29,14 +32,13 @@ import sys from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Tuple import numpy as np # Add parent directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) -from gently.harness.perception.stages import STAGES, DevelopmentalStage +from gently.harness.perception.stages import STAGES # Lazy imports tifffile = None @@ -49,6 +51,7 @@ def ensure_dependencies(): try: import tifffile as _tifffile + tifffile = _tifffile except ImportError: print("ERROR: tifffile is required. Install with: pip install tifffile") @@ -56,13 +59,14 @@ def ensure_dependencies(): try: from PIL import Image as _Image + PIL_Image = _Image except ImportError: print("ERROR: Pillow is required. Install with: pip install Pillow") sys.exit(1) -def discover_volumes(session_dir: Path, embryo_id: Optional[str] = None) -> Dict[str, List[Path]]: +def discover_volumes(session_dir: Path, embryo_id: str | None = None) -> dict[str, list[Path]]: """ Discover volume files in a session directory. @@ -145,7 +149,7 @@ def create_dual_view_projection(volume: np.ndarray) -> np.ndarray: # Check if width contains dual-view data (views side-by-side in X) if width > height * 2: # Extract View A (left half) - view_a = view_a[:, :, :width // 2] + view_a = view_a[:, :, : width // 2] # TOP projection: max along Z axis (looking down at embryo) top_proj = np.max(view_a, axis=0) # Shape: (Y, X) @@ -227,16 +231,16 @@ def save_example(image: np.ndarray, stage: str, output_dir: Path) -> Path: return filepath -def load_hatching_log(session_dir: Path) -> Optional[Dict]: +def load_hatching_log(session_dir: Path) -> dict | None: """Load hatching_detection_log.json if it exists.""" log_path = session_dir / "hatching_detection_log.json" if log_path.exists(): - with open(log_path, "r") as f: + with open(log_path) as f: return json.load(f) return None -def get_stage_from_log(log: Dict, embryo_id: str, timepoint: int) -> Optional[str]: +def get_stage_from_log(log: dict, embryo_id: str, timepoint: int) -> str | None: """ Extract stage from hatching detection log for a specific timepoint. @@ -263,8 +267,12 @@ def get_stage_from_log(log: Dict, embryo_id: str, timepoint: int) -> Optional[st return None -def save_preview_image(image: np.ndarray, timepoint: int, preview_dir: Path, - suggested_stage: Optional[str] = None) -> Path: +def save_preview_image( + image: np.ndarray, + timepoint: int, + preview_dir: Path, + suggested_stage: str | None = None, +) -> Path: """Save image to preview folder for manual review.""" preview_dir.mkdir(parents=True, exist_ok=True) @@ -280,7 +288,9 @@ def save_preview_image(image: np.ndarray, timepoint: int, preview_dir: Path, return filepath -def interactive_classify(image: np.ndarray, timepoint: int, suggested_stage: Optional[str] = None) -> Optional[str]: +def interactive_classify( + image: np.ndarray, timepoint: int, suggested_stage: str | None = None +) -> str | None: """ Display image and let user classify interactively. @@ -301,29 +311,40 @@ def interactive_classify(image: np.ndarray, timepoint: int, suggested_stage: Opt cv2.putText(display, info, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) # Add stage key legend - legend = "Keys: (e)arly (b)ean (c)omma (1).5fold (2)fold (3)fold (h)atching (d)one(hatched) (s)kip (q)uit" - cv2.putText(display, legend, (10, display.shape[0] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (200, 200, 200), 1) + legend = ( + "Keys: (e)arly (b)ean (c)omma (1).5fold (2)fold (3)fold" + " (h)atching (d)one(hatched) (s)kip (q)uit" + ) + cv2.putText( + display, + legend, + (10, display.shape[0] - 10), + cv2.FONT_HERSHEY_SIMPLEX, + 0.4, + (200, 200, 200), + 1, + ) cv2.imshow("Classify Stage", display) key = cv2.waitKey(0) & 0xFF key_map = { - ord('e'): 'early', - ord('b'): 'bean', - ord('c'): 'comma', - ord('1'): '1.5fold', - ord('2'): '2fold', - ord('3'): '3fold', - ord('h'): 'hatching', - ord('d'): 'hatched', - ord('s'): None, # Skip - ord('q'): 'QUIT', + ord("e"): "early", + ord("b"): "bean", + ord("c"): "comma", + ord("1"): "1.5fold", + ord("2"): "2fold", + ord("3"): "3fold", + ord("h"): "hatching", + ord("d"): "hatched", + ord("s"): None, # Skip + ord("q"): "QUIT", } return key_map.get(key, None) - except Exception as e: + except Exception: # GUI not available, return None to trigger preview mode return "NO_GUI" @@ -333,31 +354,60 @@ def main(): # Source specification (either --session or --source) source_group = parser.add_mutually_exclusive_group(required=True) - source_group.add_argument("--session", type=str, - help="Session ID (e.g., 3fb70aca or exp_20251219_185542_f7df153a)") - source_group.add_argument("--source", type=str, - help="Direct source path (multi_embryo_volumes/SESSION or lightsheet_captures/batch)") - - parser.add_argument("--storage", type=str, default="D:/Gently", - help="Storage path for session data (default: D:/Gently)") - parser.add_argument("--embryo", type=str, default=None, - help="Specific embryo ID to process (default: all)") - parser.add_argument("--output", type=str, default=None, - help="Output directory (default: gently/examples/stages)") - parser.add_argument("--use-log", action="store_true", - help="Use hatching_detection_log.json for auto-labeling") - parser.add_argument("--start", type=int, default=0, - help="Start timepoint") - parser.add_argument("--end", type=int, default=None, - help="End timepoint") - parser.add_argument("--step", type=int, default=10, - help="Sample every N timepoints (default: 10)") - parser.add_argument("--auto", action="store_true", - help="Auto-save without interactive review (requires --use-log)") - parser.add_argument("--preview", action="store_true", - help="Save images to preview folder instead of interactive classification") - parser.add_argument("--list-embryos", action="store_true", - help="Just list available embryos and exit") + source_group.add_argument( + "--session", + type=str, + help="Session ID (e.g., 3fb70aca or exp_20251219_185542_f7df153a)", + ) + source_group.add_argument( + "--source", + type=str, + help="Direct source path (multi_embryo_volumes/SESSION or lightsheet_captures/batch)", + ) + + parser.add_argument( + "--storage", + type=str, + default="D:/Gently", + help="Storage path for session data (default: D:/Gently)", + ) + parser.add_argument( + "--embryo", + type=str, + default=None, + help="Specific embryo ID to process (default: all)", + ) + parser.add_argument( + "--output", + type=str, + default=None, + help="Output directory (default: gently/examples/stages)", + ) + parser.add_argument( + "--use-log", + action="store_true", + help="Use hatching_detection_log.json for auto-labeling", + ) + parser.add_argument("--start", type=int, default=0, help="Start timepoint") + parser.add_argument("--end", type=int, default=None, help="End timepoint") + parser.add_argument( + "--step", type=int, default=10, help="Sample every N timepoints (default: 10)" + ) + parser.add_argument( + "--auto", + action="store_true", + help="Auto-save without interactive review (requires --use-log)", + ) + parser.add_argument( + "--preview", + action="store_true", + help="Save images to preview folder instead of interactive classification", + ) + parser.add_argument( + "--list-embryos", + action="store_true", + help="Just list available embryos and exit", + ) args = parser.parse_args() @@ -405,15 +455,15 @@ def main(): if args.use_log: hatching_log = load_hatching_log(source_dir) if hatching_log: - print(f"\nLoaded hatching detection log") + print("\nLoaded hatching detection log") else: - print(f"\nWARNING: No hatching_detection_log.json found") + print("\nWARNING: No hatching_detection_log.json found") # Process each embryo for embryo_id, volume_paths in embryo_volumes.items(): - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"Processing {embryo_id}: {len(volume_paths)} timepoints") - print(f"{'='*60}") + print(f"{'=' * 60}") # Filter timepoints end_tp = args.end if args.end else len(volume_paths) @@ -450,7 +500,7 @@ def main(): # Interactive classification stage = interactive_classify(projection, timepoint, suggested_stage) - if stage == 'QUIT': + if stage == "QUIT": print("\nQuitting...") break @@ -472,8 +522,9 @@ def main(): # Final cleanup try: import cv2 + cv2.destroyAllWindows() - except: + except Exception: pass print("\nDone!") diff --git a/scripts/gemini_stage_test.py b/scripts/gemini_stage_test.py index bb7b6969..d99d12db 100644 --- a/scripts/gemini_stage_test.py +++ b/scripts/gemini_stage_test.py @@ -9,17 +9,18 @@ Usage: python scripts/gemini_stage_test.py --session 3fb70aca --embryo embryo_1 - python scripts/gemini_stage_test.py --session 3fb70aca --embryo embryo_1 --model gemini-3-pro-preview + python scripts/gemini_stage_test.py --session 3fb70aca --embryo embryo_1 \ + --model gemini-3-pro-preview """ -import os -import sys import argparse import json -import time +import os import re -from pathlib import Path +import sys +import time from datetime import datetime +from pathlib import Path # Add parent directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -38,6 +39,7 @@ def ensure_dependencies(): try: import cv2 as _cv2 + cv2 = _cv2 except ImportError: print("ERROR: opencv-python is required. Install with: pip install opencv-python") @@ -45,6 +47,7 @@ def ensure_dependencies(): try: import tifffile as _tifffile + tifffile = _tifffile except ImportError: print("ERROR: tifffile is required. Install with: pip install tifffile") @@ -52,6 +55,7 @@ def ensure_dependencies(): try: from google import genai as _genai + genai = _genai except ImportError: print("ERROR: google-genai is required. Install with: pip install google-genai") @@ -62,6 +66,7 @@ def ensure_dependencies(): # Video Creation (adapted from video_maker.py) # ============================================================================ + def discover_volumes(session_dir: Path, embryo_id: str = None) -> dict: """Discover volume files in a session directory.""" if not session_dir.exists(): @@ -156,7 +161,7 @@ def normalize(img): # diSPIM format has width roughly 4x height when dual-view if width > height * 2: # Extract View A (left half) - view_a = view_a[:, :, :width // 2] + view_a = view_a[:, :, : width // 2] # TOP projection: max along Z axis (looking down at embryo) top_proj = np.max(view_a, axis=0) # Shape: (Y, X) @@ -191,7 +196,9 @@ def normalize(img): return normalize(view_a) -def normalize_for_video(image: np.ndarray, percentile_low: float = 1, percentile_high: float = 99.5) -> np.ndarray: +def normalize_for_video( + image: np.ndarray, percentile_low: float = 1, percentile_high: float = 99.5 +) -> np.ndarray: """Normalize image to 8-bit for video encoding.""" p_low = np.percentile(image, percentile_low) p_high = np.percentile(image, percentile_high) @@ -206,8 +213,13 @@ def normalize_for_video(image: np.ndarray, percentile_low: float = 1, percentile return image -def add_text_overlay(image: np.ndarray, text: str, position: str = "top-left", - font_scale: float = 0.6, color=(255, 255, 255)) -> np.ndarray: +def add_text_overlay( + image: np.ndarray, + text: str, + position: str = "top-left", + font_scale: float = 0.6, + color=(255, 255, 255), +) -> np.ndarray: """Add text overlay to image.""" if image.ndim == 2: image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) @@ -233,7 +245,7 @@ def add_text_overlay(image: np.ndarray, text: str, position: str = "top-left", (x - 2, y - text_height - 2), (x + text_width + 2, y + baseline + 2), (0, 0, 0), - -1 + -1, ) cv2.putText(image, text, (x, y), font, font_scale, color, thickness) @@ -241,8 +253,13 @@ def add_text_overlay(image: np.ndarray, text: str, position: str = "top-left", return image -def create_embryo_video(session_id: str, embryo_id: str, storage_path: Path, - fps: int = 10, output_path: Path = None) -> dict: +def create_embryo_video( + session_id: str, + embryo_id: str, + storage_path: Path, + fps: int = 10, + output_path: Path = None, +) -> dict: """ Create timelapse video from embryo images. @@ -306,19 +323,29 @@ def create_embryo_video(session_id: str, embryo_id: str, storage_path: Path, cv2.putText(frame, "TOP", (10, height - 10), font, 0.5, (200, 200, 200), 1) # SIDE label - estimate separator position based on aspect ratio side_start = int(width * 0.75) # Approximate - cv2.putText(frame, "SIDE", (side_start, height - 10), font, 0.5, (200, 200, 200), 1) + cv2.putText( + frame, + "SIDE", + (side_start, height - 10), + font, + 0.5, + (200, 200, 200), + 1, + ) # Initialize writer if writer is None: first_shape = frame.shape height, width = frame.shape[:2] - codecs = [('mp4v', '.mp4'), ('avc1', '.mp4'), ('XVID', '.avi')] + codecs = [("mp4v", ".mp4"), ("avc1", ".mp4"), ("XVID", ".avi")] for codec, ext in codecs: fourcc = cv2.VideoWriter_fourcc(*codec) test_path = output_path.with_suffix(ext) - writer = cv2.VideoWriter(str(test_path), fourcc, fps, (width, height), isColor=True) + writer = cv2.VideoWriter( + str(test_path), fourcc, fps, (width, height), isColor=True + ) if writer.isOpened(): output_path = test_path print(f" Using codec: {codec}") @@ -352,7 +379,7 @@ def create_embryo_video(session_id: str, embryo_id: str, storage_path: Path, "output_path": str(output_path), "frame_count": frame_count, "duration_seconds": duration, - "fps": fps + "fps": fps, } except Exception as e: @@ -365,6 +392,7 @@ def create_embryo_video(session_id: str, embryo_id: str, storage_path: Path, # Gemini API Integration # ============================================================================ + def build_stage_classification_prompt(frame_count: int, fps: int, duration_seconds: float) -> str: """Build the classification prompt with video-specific information.""" return f"""You are analyzing a time-lapse microscopy video of a C. elegans embryo developing. @@ -434,7 +462,8 @@ def build_stage_classification_prompt(frame_count: int, fps: int, duration_secon Watch the ENTIRE video and identify each developmental stage. For each stage you observe: 1. Report the FRAME NUMBER (0 to {frame_count - 1}) when that stage BEGINS -2. Identify the stage name (use lowercase: early, bean, comma, 1.5fold, 2fold, 3fold, hatching, hatched) +2. Identify the stage name (use lowercase: early, bean, comma, 1.5fold, 2fold, 3fold, + hatching, hatched) 3. Describe the specific morphological features you observe that indicate this stage 4. Provide your confidence level (HIGH, MEDIUM, or LOW) @@ -464,8 +493,14 @@ def build_stage_classification_prompt(frame_count: int, fps: int, duration_secon Be precise with frame numbers. Watch the ENTIRE video to capture all stage transitions.""" -def analyze_with_gemini(video_path: str, model: str = "gemini-3-pro-preview", api_key: str = None, - frame_count: int = 100, fps: int = 10, duration_seconds: float = 10.0) -> dict: +def analyze_with_gemini( + video_path: str, + model: str = "gemini-3-pro-preview", + api_key: str = None, + frame_count: int = 100, + fps: int = 10, + duration_seconds: float = 10.0, +) -> dict: """ Upload video to Gemini and analyze for developmental stages. @@ -479,7 +514,9 @@ def analyze_with_gemini(video_path: str, model: str = "gemini-3-pro-preview", ap if not api_key: api_key = os.environ.get("GOOGLE_API_KEY") if not api_key: - return {"error": "No API key found. Set GEMINI_API_KEY environment variable or pass --api-key"} + return { + "error": "No API key found. Set GEMINI_API_KEY environment variable or pass --api-key" + } print(f"\nInitializing Gemini client with model: {model}") @@ -521,7 +558,7 @@ def analyze_with_gemini(video_path: str, model: str = "gemini-3-pro-preview", ap if video_file.state.name != "ACTIVE": return {"error": f"Video processing failed. State: {video_file.state.name}"} - print(f" Video ready for analysis") + print(" Video ready for analysis") # Analyze with Gemini print(f"\nSending to Gemini {model} for analysis...") @@ -530,7 +567,10 @@ def analyze_with_gemini(video_path: str, model: str = "gemini-3-pro-preview", ap try: response = client.models.generate_content( model=model, - contents=[video_file, build_stage_classification_prompt(frame_count, fps, duration_seconds)] + contents=[ + video_file, + build_stage_classification_prompt(frame_count, fps, duration_seconds), + ], ) elapsed = time.time() - start_time @@ -540,7 +580,7 @@ def analyze_with_gemini(video_path: str, model: str = "gemini-3-pro-preview", ap "success": True, "response_text": response.text, "model": model, - "elapsed_seconds": elapsed + "elapsed_seconds": elapsed, } except Exception as e: @@ -555,17 +595,20 @@ def parse_stage_response(response_text: str) -> dict: """ # Try to extract JSON from response # Handle cases where response has markdown code blocks - json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', response_text) + json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", response_text) if json_match: json_str = json_match.group(1) else: # Try to find raw JSON - json_match = re.search(r'\{[\s\S]*\}', response_text) + json_match = re.search(r"\{[\s\S]*\}", response_text) if json_match: json_str = json_match.group(0) else: - return {"error": "Could not find JSON in response", "raw_response": response_text} + return { + "error": "Could not find JSON in response", + "raw_response": response_text, + } try: data = json.loads(json_str) @@ -588,7 +631,10 @@ def timestamp_to_seconds(timestamp: str) -> float: # Annotated Video Creation # ============================================================================ -def create_annotated_video(source_video: str, stage_data: dict, output_path: str, fps: int = 10) -> dict: + +def create_annotated_video( + source_video: str, stage_data: dict, output_path: str, fps: int = 10 +) -> dict: """ Create annotated video with stage labels overlaid. @@ -626,19 +672,21 @@ def create_annotated_video(source_video: str, stage_data: dict, output_path: str end_seconds = timestamp_to_seconds(next_stage["timestamp"]) end_frame = int(end_seconds * fps) else: - end_frame = float('inf') + end_frame = float("inf") else: - end_frame = float('inf') - - stage_timeline.append({ - "start_frame": start_frame, - "end_frame": end_frame, - "stage": stage_info["stage"], - "confidence": stage_info.get("confidence", "?"), - "features": stage_info.get("features", "") - }) + end_frame = float("inf") + + stage_timeline.append( + { + "start_frame": start_frame, + "end_frame": end_frame, + "stage": stage_info["stage"], + "confidence": stage_info.get("confidence", "?"), + "features": stage_info.get("features", ""), + } + ) - print(f"\nCreating annotated video...") + print("\nCreating annotated video...") print(f" Source: {source_video}") print(f" Output: {output_path}") print(f" Stages: {len(stage_timeline)}") @@ -667,7 +715,7 @@ def create_annotated_video(source_video: str, stage_data: dict, output_path: str output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) - fourcc = cv2.VideoWriter_fourcc(*'mp4v') + fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter(str(output_path), fourcc, source_fps, (width, height), isColor=True) if not writer.isOpened(): @@ -720,7 +768,7 @@ def create_annotated_video(source_video: str, stage_data: dict, output_path: str "success": True, "output_path": str(output_path), "frame_count": frame_idx, - "stages_annotated": len(stage_timeline) + "stages_annotated": len(stage_timeline), } @@ -728,23 +776,44 @@ def create_annotated_video(source_video: str, stage_data: dict, output_path: str # Main # ============================================================================ + def main(): parser = argparse.ArgumentParser( - description="Test Gemini video understanding for C. elegans developmental stage classification" + description=( + "Test Gemini video understanding for C. elegans developmental stage classification" + ) ) parser.add_argument("--session", default="3fb70aca", help="Session ID") parser.add_argument("--embryo", default="embryo_1", help="Embryo ID") - parser.add_argument("--model", default="gemini-2.5-flash", - choices=["gemini-2.5-pro", "gemini-2.5-flash", "gemini-3-pro-preview", "gemini-3-flash-preview"], - help="Gemini model to use") + parser.add_argument( + "--model", + default="gemini-2.5-flash", + choices=[ + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-3-pro-preview", + "gemini-3-flash-preview", + ], + help="Gemini model to use", + ) parser.add_argument("--storage", default="D:/Gently", help="Storage path") parser.add_argument("--fps", type=int, default=10, help="Video frames per second") - parser.add_argument("--skip-video-creation", action="store_true", - help="Skip video creation if video already exists") - parser.add_argument("--video-only", action="store_true", - help="Only create video, skip Gemini analysis") - parser.add_argument("--api-key", type=str, default=None, - help="Gemini API key (or set GEMINI_API_KEY env var)") + parser.add_argument( + "--skip-video-creation", + action="store_true", + help="Skip video creation if video already exists", + ) + parser.add_argument( + "--video-only", + action="store_true", + help="Only create video, skip Gemini analysis", + ) + parser.add_argument( + "--api-key", + type=str, + default=None, + help="Gemini API key (or set GEMINI_API_KEY env var)", + ) args = parser.parse_args() @@ -768,7 +837,7 @@ def main(): # Video info for Gemini prompt video_frame_count = 100 # default - video_duration = 10.0 # default + video_duration = 10.0 # default if args.skip_video_creation and video_path.exists(): print(f"\nUsing existing video: {video_path}") @@ -779,7 +848,7 @@ def main(): cap.release() print(f" Frames: {video_frame_count}, Duration: {video_duration:.1f}s") else: - print(f"\n[Step 1] Creating timelapse video...") + print("\n[Step 1] Creating timelapse video...") video_result = create_embryo_video(args.session, args.embryo, storage_path, args.fps) if "error" in video_result: @@ -789,7 +858,7 @@ def main(): video_path = Path(video_result["output_path"]) video_frame_count = video_result["frame_count"] video_duration = video_result["duration_seconds"] - print(f"\nVideo created successfully!") + print("\nVideo created successfully!") print(f" Path: {video_path}") print(f" Frames: {video_frame_count}") print(f" Duration: {video_duration:.1f}s") @@ -799,10 +868,14 @@ def main(): return 0 # Step 2: Analyze with Gemini - print(f"\n[Step 2] Analyzing with Gemini...") + print("\n[Step 2] Analyzing with Gemini...") result = analyze_with_gemini( - str(video_path), args.model, args.api_key, - frame_count=video_frame_count, fps=args.fps, duration_seconds=video_duration + str(video_path), + args.model, + args.api_key, + frame_count=video_frame_count, + fps=args.fps, + duration_seconds=video_duration, ) if "error" in result: @@ -818,18 +891,22 @@ def main(): # Save raw response response_file = video_dir / f"{args.embryo}_gemini_response.json" with open(response_file, "w") as f: - json.dump({ - "model": args.model, - "session": args.session, - "embryo": args.embryo, - "response": result["response_text"], - "elapsed_seconds": result["elapsed_seconds"], - "timestamp": datetime.now().isoformat() - }, f, indent=2) + json.dump( + { + "model": args.model, + "session": args.session, + "embryo": args.embryo, + "response": result["response_text"], + "elapsed_seconds": result["elapsed_seconds"], + "timestamp": datetime.now().isoformat(), + }, + f, + indent=2, + ) print(f"\nResponse saved to: {response_file}") # Step 3: Parse response - print(f"\n[Step 3] Parsing response...") + print("\n[Step 3] Parsing response...") parsed = parse_stage_response(result["response_text"]) if "error" in parsed: @@ -846,7 +923,7 @@ def main(): if "frame" in stage: loc = f"Frame {stage['frame']}" else: - loc = stage.get('timestamp', '?') + loc = stage.get("timestamp", "?") print(f" {loc} - {stage['stage']} ({stage.get('confidence', '?')})") print(f"\nFinal stage: {stage_data.get('final_stage', 'unknown')}") @@ -859,15 +936,10 @@ def main(): print(f"\nParsed stages saved to: {parsed_file}") # Step 4: Create annotated video - print(f"\n[Step 4] Creating annotated video...") + print("\n[Step 4] Creating annotated video...") annotated_path = video_dir / f"{args.embryo}_timelapse_gemini_annotated.mp4" - result = create_annotated_video( - str(video_path), - stage_data, - str(annotated_path), - args.fps - ) + result = create_annotated_video(str(video_path), stage_data, str(annotated_path), args.fps) if "error" in result: print(f"ERROR: {result['error']}") diff --git a/scripts/generate_examples.py b/scripts/generate_examples.py index e8531005..587212ad 100644 --- a/scripts/generate_examples.py +++ b/scripts/generate_examples.py @@ -15,13 +15,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from scripts.projection_explorer import ( - ensure_dependencies, discover_volumes, + ensure_dependencies, load_volume, normalize_image, ) - # Ground truth for embryo 2 EMBRYO_2_TRANSITIONS = { "early": 0, @@ -35,12 +34,12 @@ # Timepoints to sample for each stage (showing progression within stage) # Pick timepoints that span the stage duration STAGE_TIMEPOINTS = { - "early": [5, 12, 20, 28], # T0-32: 4 timepoints - "bean": [34, 36, 38], # T33-38: 3 timepoints (short stage) - "comma": [40, 42, 44], # T39-44: 3 timepoints (short stage) - "1.5fold": [47, 52, 57], # T45-59: 3 timepoints - "2fold": [62, 68, 75], # T60-79: 3 timepoints - "pretzel": [82, 88, 95], # T80+: 3 timepoints + "early": [5, 12, 20, 28], # T0-32: 4 timepoints + "bean": [34, 36, 38], # T33-38: 3 timepoints (short stage) + "comma": [40, 42, 44], # T39-44: 3 timepoints (short stage) + "1.5fold": [47, 52, 57], # T45-59: 3 timepoints + "2fold": [62, 68, 75], # T60-79: 3 timepoints + "pretzel": [82, 88, 95], # T80+: 3 timepoints } @@ -56,12 +55,14 @@ def crop_to_content(img, padding=10): gray = img.astype(np.float32) # Estimate background as the median of edge pixels - edge_pixels = np.concatenate([ - gray[0, :], # top row - gray[-1, :], # bottom row - gray[:, 0], # left column - gray[:, -1], # right column - ]) + edge_pixels = np.concatenate( + [ + gray[0, :], # top row + gray[-1, :], # bottom row + gray[:, 0], # left column + gray[:, -1], # right column + ] + ) background = np.median(edge_pixels) # Threshold: significantly above background (background + 25% of dynamic range) @@ -76,6 +77,7 @@ def crop_to_content(img, padding=10): # Use morphological operations to clean up noise from scipy import ndimage as ndi + # Close small gaps, then open to remove small noise content_mask = ndi.binary_closing(content_mask, iterations=2) content_mask = ndi.binary_opening(content_mask, iterations=2) @@ -147,7 +149,7 @@ def create_stage_montage(volumes_list, timepoints, stage_name): from PIL import Image as PILImage scaled_sides = [] - for i, (top, side) in enumerate(zip(top_views, side_views)): + for _i, (top, side) in enumerate(zip(top_views, side_views, strict=False)): target_width = top.shape[1] # Match TOP width # Scale height to be visible (3x original Z depth) target_height = max(top.shape[0] // 3, side.shape[0] * 3) @@ -174,7 +176,7 @@ def pad_center(img, target_h, target_w): x_off = (target_w - w) // 2 if img.ndim == 3: img = img[:, :, 0] if img.shape[2] > 1 else img.squeeze() - result[y_off:y_off+h, x_off:x_off+w] = img + result[y_off : y_off + h, x_off : x_off + w] = img return result padded_tops = [pad_center(t, max_top_h, cell_w) for t in top_views] @@ -182,7 +184,7 @@ def pad_center(img, target_h, target_w): # Create separator sep_w = 2 - n_cols = len(padded_tops) + len(padded_tops) # Build rows with separators def build_row(views): @@ -211,13 +213,13 @@ def build_row(views): try: font = ImageFont.truetype("arial.ttf", 12) small_font = ImageFont.truetype("arial.ttf", 10) - except: + except Exception: font = ImageFont.load_default() small_font = font # Add timepoint labels at bottom label_h = 16 - labeled_img = Image.new('L', (img.width, img.height + label_h), 0) + labeled_img = Image.new("L", (img.width, img.height + label_h), 0) labeled_img.paste(img, (0, 0)) draw = ImageDraw.Draw(labeled_img) @@ -231,12 +233,17 @@ def build_row(views): # Get text size bbox = draw.textbbox((0, 0), label, font=small_font) text_w = bbox[2] - bbox[0] - draw.text((cell_center - text_w // 2, img.height + 2), label, fill=200, font=small_font) + draw.text( + (cell_center - text_w // 2, img.height + 2), + label, + fill=200, + font=small_font, + ) x_pos += cell_w # Add row labels on left final_w = labeled_img.width + 35 - final_img = Image.new('L', (final_w, labeled_img.height), 0) + final_img = Image.new("L", (final_w, labeled_img.height), 0) final_img.paste(labeled_img, (35, 0)) draw = ImageDraw.Draw(final_img) @@ -291,9 +298,10 @@ def main(): output_dir = Path("gently/examples/stages") output_dir.mkdir(parents=True, exist_ok=True) - from PIL import Image import json + from PIL import Image + # Generate montage and save volumes for each stage for stage, timepoints in STAGE_TIMEPOINTS.items(): print(f"\n=== {stage.upper()} ===") @@ -308,7 +316,7 @@ def main(): Image.fromarray(montage).save(output_path, quality=95) print(f" Saved: {output_path} ({montage.shape[1]}x{montage.shape[0]})") else: - print(f" ERROR: Failed to create montage") + print(" ERROR: Failed to create montage") # Save volumes for all timepoints in this stage volumes_dir = stage_dir / "volumes" @@ -324,11 +332,13 @@ def main(): # Save as compressed numpy with timepoint in filename volume_out = volumes_dir / f"T{tp:03d}.npz" np.savez_compressed(volume_out, volume=volume) - saved_volumes.append({ - "timepoint": tp, - "filename": f"T{tp:03d}.npz", - "shape": list(volume.shape), - }) + saved_volumes.append( + { + "timepoint": tp, + "filename": f"T{tp:03d}.npz", + "shape": list(volume.shape), + } + ) print(f" Saved volume: T{tp} ({volume.shape})") # Save metadata diff --git a/scripts/launch_viz_server.py b/scripts/launch_viz_server.py index aabb661a..b10eea91 100644 --- a/scripts/launch_viz_server.py +++ b/scripts/launch_viz_server.py @@ -22,16 +22,16 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from benchmarks.perception.ground_truth import GroundTruth +from benchmarks.perception.testset import _discover_volumes from gently.core.event_bus import EventBus, EventType from gently.core.imaging import ( + apply_crop_bounds, + compute_crop_bounds, load_volume, projection_three_view, - compute_crop_bounds, - apply_crop_bounds, ) from gently.settings import settings -from benchmarks.perception.testset import _discover_volumes -from benchmarks.perception.ground_truth import GroundTruth # Benchmark data paths REPO_ROOT = Path(__file__).resolve().parents[1] @@ -44,7 +44,9 @@ def _find_main_worktree() -> Path: try: out = subprocess.check_output( ["git", "worktree", "list", "--porcelain"], - cwd=str(REPO_ROOT), text=True, stderr=subprocess.DEVNULL, + cwd=str(REPO_ROOT), + text=True, + stderr=subprocess.DEVNULL, ) for line in out.splitlines(): if line.startswith("worktree "): @@ -134,7 +136,11 @@ async def run( event_bus.publish( EventType.ACQUISITION_STARTED, - {"session_id": session_id, "embryo_ids": sorted(all_vols.keys()), "total_timepoints": max_tp}, + { + "session_id": session_id, + "embryo_ids": sorted(all_vols.keys()), + "total_timepoints": max_tp, + }, source="demo", ) @@ -176,7 +182,11 @@ async def run( ) pushed += 1 - print(f"\r [{pushed}/{total}] {eid} t={t} [{stage or '?'}]", end="", flush=True) + print( + f"\r [{pushed}/{total}] {eid} t={t} [{stage or '?'}]", + end="", + flush=True, + ) if actual_delay: await asyncio.sleep(actual_delay) @@ -197,9 +207,13 @@ async def run( def main(): parser = argparse.ArgumentParser(description="Launch viz server with benchmark data") parser.add_argument("--port", type=int, default=settings.network.viz_port) - parser.add_argument("--delay", type=float, default=1.0, help="Seconds between pushes (default 1)") + parser.add_argument( + "--delay", type=float, default=1.0, help="Seconds between pushes (default 1)" + ) parser.add_argument("--fast", action="store_true", help="Push all data immediately, then serve") - parser.add_argument("--timepoints", type=int, default=0, help="Max timepoints per embryo (0=all)") + parser.add_argument( + "--timepoints", type=int, default=0, help="Max timepoints per embryo (0=all)" + ) parser.add_argument("--embryos", nargs="+", default=None, help="Specific embryo IDs") parser.add_argument("--volumes", type=str, default=None, help="Path to volumes dir") parser.add_argument("--ground-truth", type=str, default=None, help="Path to ground truth JSON") @@ -218,15 +232,17 @@ def main(): gt_path = Path(args.ground_truth) if args.ground_truth else _resolve_data_path(_GT_REL) try: - asyncio.run(run( - port=args.port, - delay=args.delay, - max_timepoints=args.timepoints, - embryo_ids=args.embryos, - fast=args.fast, - volumes_dir=volumes_dir, - gt_path=gt_path, - )) + asyncio.run( + run( + port=args.port, + delay=args.delay, + max_timepoints=args.timepoints, + embryo_ids=args.embryos, + fast=args.fast, + volumes_dir=volumes_dir, + gt_path=gt_path, + ) + ) except KeyboardInterrupt: pass diff --git a/scripts/projection_explorer.py b/scripts/projection_explorer.py index 88700941..88319905 100644 --- a/scripts/projection_explorer.py +++ b/scripts/projection_explorer.py @@ -29,13 +29,12 @@ import base64 import io import json +import socketserver import sys from datetime import datetime -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from typing import Dict, List, Optional, Tuple, Callable from urllib.parse import parse_qs, urlparse -import socketserver import numpy as np from scipy import ndimage @@ -52,6 +51,7 @@ def ensure_dependencies(): try: import tifffile as _tifffile + tifffile = _tifffile except ImportError: print("ERROR: tifffile required. Install: pip install tifffile") @@ -59,15 +59,17 @@ def ensure_dependencies(): try: from PIL import Image as _Image + PIL_Image = _Image except ImportError: print("ERROR: Pillow required. Install: pip install Pillow") sys.exit(1) try: - import matplotlib.pyplot as _plt import matplotlib - matplotlib.use('Agg') # Non-interactive backend + import matplotlib.pyplot as _plt + + matplotlib.use("Agg") # Non-interactive backend plt = _plt except ImportError: print("WARNING: matplotlib not available, some features disabled") @@ -78,7 +80,8 @@ def ensure_dependencies(): # Volume Loading # ============================================================================= -def discover_volumes(session_dir: Path, embryo_id: Optional[str] = None) -> Dict[str, List[Path]]: + +def discover_volumes(session_dir: Path, embryo_id: str | None = None) -> dict[str, list[Path]]: """Discover volume files in a session directory.""" if not session_dir.exists(): return {} @@ -110,7 +113,7 @@ def discover_volumes(session_dir: Path, embryo_id: Optional[str] = None) -> Dict return result -def load_volume(path: Path, crop_bounds: Optional[Tuple[int, int, int, int]] = None) -> np.ndarray: +def load_volume(path: Path, crop_bounds: tuple[int, int, int, int] | None = None) -> np.ndarray: """Load a volume from TIFF file, extract View A, and optionally apply crop bounds.""" vol = tifffile.imread(str(path)) vol = np.squeeze(vol) @@ -119,7 +122,7 @@ def load_volume(path: Path, crop_bounds: Optional[Tuple[int, int, int, int]] = N z_depth, height, width = vol.shape # Extract View A (left half) if dual-view format if width > height * 2: - vol = vol[:, :, :width // 2] + vol = vol[:, :, : width // 2] # Apply crop bounds if provided if crop_bounds is not None: @@ -132,6 +135,7 @@ def load_volume(path: Path, crop_bounds: Optional[Tuple[int, int, int, int]] = N # Image Utilities # ============================================================================= + def normalize_image(img: np.ndarray, p_low: float = 1, p_high: float = 99) -> np.ndarray: """Normalize image to 0-255 uint8 using percentile scaling.""" img = img.astype(np.float32) @@ -147,8 +151,8 @@ def normalize_image(img: np.ndarray, p_low: float = 1, p_high: float = 99) -> np def image_to_base64(img: np.ndarray, format: str = "JPEG", quality: int = 90) -> str: """Convert numpy array to base64-encoded image.""" pil_img = PIL_Image.fromarray(img) - if pil_img.mode not in ('RGB', 'RGBA'): - pil_img = pil_img.convert('RGB') + if pil_img.mode not in ("RGB", "RGBA"): + pil_img = pil_img.convert("RGB") buffer = io.BytesIO() pil_img.save(buffer, format=format, quality=quality) @@ -168,7 +172,10 @@ def resize_to_height(img: np.ndarray, target_height: int) -> np.ndarray: # Auto-crop utilities # ============================================================================= -def find_content_bbox(img: np.ndarray, threshold_percentile: float = 10, padding: int = 20) -> Tuple[int, int, int, int]: + +def find_content_bbox( + img: np.ndarray, threshold_percentile: float = 10, padding: int = 20 +) -> tuple[int, int, int, int]: """ Find bounding box of content (non-background) in image. @@ -209,7 +216,9 @@ def auto_crop(img: np.ndarray, threshold_percentile: float = 10, padding: int = return img[y_min:y_max, x_min:x_max] -def compute_crop_bounds(volume: np.ndarray, padding: int = 20, sigma_mult: float = 3.5) -> Tuple[int, int, int, int]: +def compute_crop_bounds( + volume: np.ndarray, padding: int = 20, sigma_mult: float = 3.5 +) -> tuple[int, int, int, int]: """ Compute crop bounds for 3D volume using center-of-mass of bright pixels. @@ -245,7 +254,7 @@ def compute_crop_bounds(volume: np.ndarray, padding: int = 20, sigma_mult: float return (y_min, y_max, x_min, x_max) -def apply_crop_bounds(volume: np.ndarray, bounds: Tuple[int, int, int, int]) -> np.ndarray: +def apply_crop_bounds(volume: np.ndarray, bounds: tuple[int, int, int, int]) -> np.ndarray: """Apply pre-computed crop bounds to a volume.""" y_min, y_max, x_min, x_max = bounds # Clamp to actual volume dimensions @@ -260,6 +269,7 @@ def apply_crop_bounds(volume: np.ndarray, bounds: Tuple[int, int, int, int]) -> # Projection Methods # ============================================================================= + def find_outer_boundary(img: np.ndarray, percentile: float = 50) -> np.ndarray: """ Find outer boundary of embryo by thresholding and extracting mask edge. @@ -271,17 +281,27 @@ def find_outer_boundary(img: np.ndarray, percentile: float = 50) -> np.ndarray: mask = img > thresh # Vectorized erosion: pixel is 1 only if all neighbors are 1 - padded = np.pad(mask, 1, mode='constant', constant_values=True) - eroded = (padded[:-2, :-2] & padded[:-2, 1:-1] & padded[:-2, 2:] & - padded[1:-1, :-2] & padded[1:-1, 1:-1] & padded[1:-1, 2:] & - padded[2:, :-2] & padded[2:, 1:-1] & padded[2:, 2:]) + padded = np.pad(mask, 1, mode="constant", constant_values=True) + eroded = ( + padded[:-2, :-2] + & padded[:-2, 1:-1] + & padded[:-2, 2:] + & padded[1:-1, :-2] + & padded[1:-1, 1:-1] + & padded[1:-1, 2:] + & padded[2:, :-2] + & padded[2:, 1:-1] + & padded[2:, 2:] + ) # Boundary = mask AND NOT eroded boundary = (mask & ~eroded).astype(np.uint8) * 255 return boundary -def overlay_edges(img: np.ndarray, edges: np.ndarray, color: Tuple[int, int, int] = (255, 200, 0)) -> np.ndarray: +def overlay_edges( + img: np.ndarray, edges: np.ndarray, color: tuple[int, int, int] = (255, 200, 0) +) -> np.ndarray: """Overlay edge contours on image in specified color.""" if img.ndim == 2: rgb = np.stack([img, img, img], axis=-1) @@ -294,7 +314,7 @@ def overlay_edges(img: np.ndarray, edges: np.ndarray, color: Tuple[int, int, int return rgb -def projection_dual_view(volume: np.ndarray) -> Tuple[np.ndarray, str]: +def projection_dual_view(volume: np.ndarray) -> tuple[np.ndarray, str]: """ Dual-view projection: TOP above, SIDE below, sharing X axis. Includes embryo boundary overlay using Canny edge detection. @@ -341,7 +361,7 @@ def projection_dual_view(volume: np.ndarray) -> Tuple[np.ndarray, str]: return combined, "Dual-view MIP with boundary: TOP (XY) above, SIDE (XZ) below" -def projection_depth_colored(volume: np.ndarray, colormap: str = 'turbo') -> Tuple[np.ndarray, str]: +def projection_depth_colored(volume: np.ndarray, colormap: str = "turbo") -> tuple[np.ndarray, str]: """ Depth-colored max intensity projection with side view. @@ -407,7 +427,7 @@ def projection_depth_colored(volume: np.ndarray, colormap: str = 'turbo') -> Tup return combined, f"Z-depth colored MIP ({colormap}): TOP + SIDE views" -def projection_multi_slice(volume: np.ndarray, n_slices: int = 6) -> Tuple[np.ndarray, str]: +def projection_multi_slice(volume: np.ndarray, n_slices: int = 6) -> tuple[np.ndarray, str]: """ Montage of N representative z-slices. @@ -439,7 +459,7 @@ def projection_multi_slice(volume: np.ndarray, n_slices: int = 6) -> Tuple[np.nd # Build montage rows = [] for r in range(n_rows): - row_slices = slices[r * n_cols:(r + 1) * n_cols] + row_slices = slices[r * n_cols : (r + 1) * n_cols] # Add thin separator between slices sep = np.ones((height, 2), dtype=np.uint8) * 64 row_with_sep = [] @@ -458,7 +478,7 @@ def projection_multi_slice(volume: np.ndarray, n_slices: int = 6) -> Tuple[np.nd return montage, f"Multi-slice montage ({n_slices} slices: {z_labels})" -def projection_subvolume(volume: np.ndarray) -> Tuple[np.ndarray, str]: +def projection_subvolume(volume: np.ndarray) -> tuple[np.ndarray, str]: """ Sub-volume projections: top third, middle third, bottom third. @@ -481,18 +501,33 @@ def projection_subvolume(volume: np.ndarray) -> Tuple[np.ndarray, str]: bot_vol = volume[z2:] # Project each - top_proj = normalize_image(np.max(top_vol, axis=0)) if top_vol.size > 0 else np.zeros((height, width), dtype=np.uint8) - mid_proj = normalize_image(np.max(mid_vol, axis=0)) if mid_vol.size > 0 else np.zeros((height, width), dtype=np.uint8) - bot_proj = normalize_image(np.max(bot_vol, axis=0)) if bot_vol.size > 0 else np.zeros((height, width), dtype=np.uint8) + top_proj = ( + normalize_image(np.max(top_vol, axis=0)) + if top_vol.size > 0 + else np.zeros((height, width), dtype=np.uint8) + ) + mid_proj = ( + normalize_image(np.max(mid_vol, axis=0)) + if mid_vol.size > 0 + else np.zeros((height, width), dtype=np.uint8) + ) + bot_proj = ( + normalize_image(np.max(bot_vol, axis=0)) + if bot_vol.size > 0 + else np.zeros((height, width), dtype=np.uint8) + ) # Combine horizontally with labels sep = np.ones((height, 4), dtype=np.uint8) * 128 combined = np.concatenate([top_proj, sep, mid_proj, sep, bot_proj], axis=1) - return combined, f"Sub-volume MIPs: TOP (z=0-{z1}) | MID (z={z1}-{z2}) | BOT (z={z2}-{z_depth})" + return ( + combined, + f"Sub-volume MIPs: TOP (z=0-{z1}) | MID (z={z1}-{z2}) | BOT (z={z2}-{z_depth})", + ) -def projection_three_view(volume: np.ndarray) -> Tuple[np.ndarray, str]: +def projection_three_view(volume: np.ndarray) -> tuple[np.ndarray, str]: """ Three orthogonal views with proper axis alignment: @@ -562,8 +597,9 @@ def projection_three_view(volume: np.ndarray) -> Tuple[np.ndarray, str]: return combined, "Three-view: [XY|YZ] top, [XZ] bottom - axes aligned" -def render_volume_rotated_v2(volume: np.ndarray, angle_y: float, angle_x: float = -0.5, - threshold: float = 0.12) -> np.ndarray: +def render_volume_rotated_v2( + volume: np.ndarray, angle_y: float, angle_x: float = -0.5, threshold: float = 0.12 +) -> np.ndarray: """ Render volume by actually rotating it in 3D, then projecting. Uses scipy.ndimage.rotate for true 3D rotation. @@ -581,11 +617,19 @@ def render_volume_rotated_v2(volume: np.ndarray, angle_y: float, angle_x: float # Rotate around Y axis (rotation in XZ plane, axis 1 is Y) # axes=(2, 0) means rotate in the X-Z plane - rotated = rotate(vol, angle_y_deg, axes=(2, 0), reshape=False, order=1, mode='constant', cval=0) + rotated = rotate(vol, angle_y_deg, axes=(2, 0), reshape=False, order=1, mode="constant", cval=0) # Rotate around X axis (rotation in YZ plane) # axes=(1, 0) means rotate in the Y-Z plane - rotated = rotate(rotated, angle_x_deg, axes=(1, 0), reshape=False, order=1, mode='constant', cval=0) + rotated = rotate( + rotated, + angle_x_deg, + axes=(1, 0), + reshape=False, + order=1, + mode="constant", + cval=0, + ) # Now do alpha-blended projection along Z (front to back would be back to front after rotation) z_depth, height, width = rotated.shape @@ -609,9 +653,14 @@ def render_volume_rotated_v2(volume: np.ndarray, angle_y: float, angle_x: float return result -def render_volume_rotated(volume: np.ndarray, angle_y: float, angle_x: float = -0.5, - threshold: float = 0.12, num_slices: int = 48, - perspective: float = 0.4) -> np.ndarray: +def render_volume_rotated( + volume: np.ndarray, + angle_y: float, + angle_x: float = -0.5, + threshold: float = 0.12, + num_slices: int = 48, + perspective: float = 0.4, +) -> np.ndarray: """ Render volume from a rotated viewpoint with parallax and perspective. @@ -680,23 +729,25 @@ def render_volume_rotated(volume: np.ndarray, angle_y: float, angle_x: float = - perspective_scale = 1.0 + (depth_after_rotation * perspective * 1.5 / z_scale) perspective_scale = np.clip(perspective_scale, 0.5, 1.5) - slice_data_list.append({ - 'z_idx': z_idx, - 'shift_x': shift_x, - 'shift_y': shift_y, - 'depth': depth_after_rotation, - 'scale': perspective_scale, - }) + slice_data_list.append( + { + "z_idx": z_idx, + "shift_x": shift_x, + "shift_y": shift_y, + "depth": depth_after_rotation, + "scale": perspective_scale, + } + ) # Sort by depth (back to front for proper alpha compositing) - slice_data_list.sort(key=lambda s: s['depth']) + slice_data_list.sort(key=lambda s: s["depth"]) # Composite slices for slice_info in slice_data_list: - z_idx = slice_info['z_idx'] - shift_x = slice_info['shift_x'] - shift_y = slice_info['shift_y'] - scale = slice_info['scale'] + z_idx = slice_info["z_idx"] + shift_x = slice_info["shift_x"] + shift_y = slice_info["shift_y"] + scale = slice_info["scale"] # Get slice slice_img = vol[z_idx, :, :] @@ -746,8 +797,8 @@ def render_volume_rotated(volume: np.ndarray, angle_y: float, angle_x: float = - # Alpha composite for c in range(3): result[dst_y_start:dst_y_end, dst_x_start:dst_x_end, c] = ( - src_slice * src_alpha + - result[dst_y_start:dst_y_end, dst_x_start:dst_x_end, c] * (1 - src_alpha) + src_slice * src_alpha + + result[dst_y_start:dst_y_end, dst_x_start:dst_x_end, c] * (1 - src_alpha) ) # Crop to content (remove empty margins) @@ -771,7 +822,7 @@ def render_volume_rotated(volume: np.ndarray, angle_y: float, angle_x: float = - return result -def projection_spin_3d(volume: np.ndarray) -> Tuple[np.ndarray, str]: +def projection_spin_3d(volume: np.ndarray) -> tuple[np.ndarray, str]: """ Multiple 3D perspective views from different angles. @@ -793,7 +844,6 @@ def projection_spin_3d(volume: np.ndarray) -> Tuple[np.ndarray, str]: # Different rotation angles around Y axis (horizontal spin) # 6 views: front, 60°, 120°, 180° (back), 240°, 300° angles_y = [-0.05, 0.5, 1.0, 1.57, 2.1, 2.6] - labels = ["front", "60°", "120°", "back", "240°", "300°"] views = [] for angle_y in angles_y: @@ -802,7 +852,7 @@ def projection_spin_3d(volume: np.ndarray) -> Tuple[np.ndarray, str]: angle_y=angle_y, angle_x=base_tilt, threshold=py_threshold, - perspective=0.5 + perspective=0.5, ) views.append(view) @@ -818,10 +868,10 @@ def projection_spin_3d(volume: np.ndarray) -> Tuple[np.ndarray, str]: pad_w = (target_w - w) // 2 if v.ndim == 3: p = np.zeros((target_h, target_w, 3), dtype=v.dtype) - p[pad_h:pad_h+h, pad_w:pad_w+w] = v + p[pad_h : pad_h + h, pad_w : pad_w + w] = v else: p = np.zeros((target_h, target_w), dtype=v.dtype) - p[pad_h:pad_h+h, pad_w:pad_w+w] = v + p[pad_h : pad_h + h, pad_w : pad_w + w] = v padded.append(p) # Create 2x3 grid @@ -834,12 +884,12 @@ def projection_spin_3d(volume: np.ndarray) -> Tuple[np.ndarray, str]: # Registry of available projection methods PROJECTION_METHODS = { - 'dual_view': projection_dual_view, - 'depth_colored': projection_depth_colored, - 'multi_slice': projection_multi_slice, - 'subvolume': projection_subvolume, - 'three_view': projection_three_view, - 'spin_3d': projection_spin_3d, + "dual_view": projection_dual_view, + "depth_colored": projection_depth_colored, + "multi_slice": projection_multi_slice, + "subvolume": projection_subvolume, + "three_view": projection_three_view, + "spin_3d": projection_spin_3d, } @@ -847,6 +897,7 @@ def projection_spin_3d(volume: np.ndarray) -> Tuple[np.ndarray, str]: # Session Manager # ============================================================================= + class SessionManager: """Manages volume data for a session with aggressive caching.""" @@ -864,11 +915,11 @@ def __init__(self, session_path: Path): self.current_embryo = sorted(self.embryo_volumes.keys())[0] @property - def embryo_list(self) -> List[str]: + def embryo_list(self) -> list[str]: return sorted(self.embryo_volumes.keys()) @property - def current_volumes(self) -> List[Path]: + def current_volumes(self) -> list[Path]: if self.current_embryo: return self.embryo_volumes.get(self.current_embryo, []) return [] @@ -882,7 +933,7 @@ def switch_embryo(self, embryo_id: str): self.current_embryo = embryo_id self.current_idx = 0 - def _get_crop_bounds(self) -> Optional[Tuple[int, int, int, int]]: + def _get_crop_bounds(self) -> tuple[int, int, int, int] | None: """Get or compute crop bounds for current embryo (computed from first volume).""" if self.current_embryo is None: return None @@ -899,16 +950,19 @@ def _get_crop_bounds(self) -> Optional[Tuple[int, int, int, int]]: if vol.ndim == 3: z_depth, height, width = vol.shape if width > height * 2: - vol = vol[:, :, :width // 2] + vol = vol[:, :, : width // 2] bounds = compute_crop_bounds(vol, padding=20) self._crop_bounds[self.current_embryo] = bounds print(f"[ROI] Computed crop bounds for {self.current_embryo}: {bounds}") - print(f"[ROI] Original volume shape: {vol.shape}, cropped region: {bounds[1]-bounds[0]}x{bounds[3]-bounds[2]}") + print( + f"[ROI] Original volume shape: {vol.shape}, cropped region:" + f" {bounds[1] - bounds[0]}x{bounds[3] - bounds[2]}" + ) return self._crop_bounds[self.current_embryo] - def get_volume(self, idx: int) -> Optional[np.ndarray]: + def get_volume(self, idx: int) -> np.ndarray | None: """Load volume at index, with caching and stable crop bounds.""" volumes = self.current_volumes if not volumes or idx < 0 or idx >= len(volumes): @@ -923,7 +977,7 @@ def get_volume(self, idx: int) -> Optional[np.ndarray]: for k in self._volume_cache: if k[0] != self.current_embryo or abs(k[1] - idx) > 10: keys_to_remove.append(k) - for k in keys_to_remove[:len(self._volume_cache) - self._cache_size // 2]: + for k in keys_to_remove[: len(self._volume_cache) - self._cache_size // 2]: del self._volume_cache[k] # Get stable crop bounds for this embryo @@ -934,7 +988,7 @@ def get_volume(self, idx: int) -> Optional[np.ndarray]: return self._volume_cache[cache_key] - def get_projection(self, idx: int, method: str) -> Optional[Tuple[np.ndarray, str]]: + def get_projection(self, idx: int, method: str) -> tuple[np.ndarray, str] | None: """Get cached projection or compute it.""" cache_key = (self.current_embryo, idx, method) @@ -958,10 +1012,10 @@ def get_projection(self, idx: int, method: str) -> Optional[Tuple[np.ndarray, st return self._projection_cache[cache_key] - def get_current_volume(self) -> Optional[np.ndarray]: + def get_current_volume(self) -> np.ndarray | None: return self.get_volume(self.current_idx) - def get_current_path(self) -> Optional[Path]: + def get_current_path(self) -> Path | None: volumes = self.current_volumes if volumes and 0 <= self.current_idx < len(volumes): return volumes[self.current_idx] @@ -978,11 +1032,12 @@ def preload_adjacent(self, idx: int, radius: int = 2): # Web UI Server # ============================================================================= + class ExplorerHandler(BaseHTTPRequestHandler): """HTTP handler for projection explorer.""" session_manager: SessionManager = None - current_methods: List[str] = ['dual_view', 'depth_colored'] + current_methods: list[str] = ["dual_view", "depth_colored"] current_z_slice: int = 0 # For 3D volume viewer def log_message(self, format, *args): @@ -1001,7 +1056,9 @@ def do_GET(self): elif parsed.path.startswith("/api/goto"): params = parse_qs(parsed.query) idx = int(params.get("idx", [0])[0]) - self.session_manager.current_idx = max(0, min(idx, self.session_manager.total_timepoints - 1)) + self.session_manager.current_idx = max( + 0, min(idx, self.session_manager.total_timepoints - 1) + ) self.send_projections() elif parsed.path.startswith("/api/embryo"): params = parse_qs(parsed.query) @@ -1013,7 +1070,9 @@ def do_GET(self): methods = params.get("m", []) if methods: # Update class variable, not instance variable - ExplorerHandler.current_methods = [m for m in methods if m in PROJECTION_METHODS] + ExplorerHandler.current_methods = [ + m for m in methods if m in PROJECTION_METHODS + ] self.send_projections() elif parsed.path.startswith("/api/zslice"): params = parse_qs(parsed.query) @@ -1041,17 +1100,19 @@ def send_status(self): volume = sm.get_current_volume() z_depth = volume.shape[0] if volume is not None and volume.ndim == 3 else 0 crop_bounds = sm._crop_bounds.get(sm.current_embryo) if sm.current_embryo else None - self.send_json({ - "session": sm.session_path.name, - "embryos": sm.embryo_list, - "current_embryo": sm.current_embryo, - "total": sm.total_timepoints, - "idx": sm.current_idx, - "methods": list(PROJECTION_METHODS.keys()), - "active_methods": ExplorerHandler.current_methods, - "z_depth": z_depth, - "crop_bounds": crop_bounds, - }) + self.send_json( + { + "session": sm.session_path.name, + "embryos": sm.embryo_list, + "current_embryo": sm.current_embryo, + "total": sm.total_timepoints, + "idx": sm.current_idx, + "methods": list(PROJECTION_METHODS.keys()), + "active_methods": ExplorerHandler.current_methods, + "z_depth": z_depth, + "crop_bounds": crop_bounds, + } + ) def send_zslice(self, z: int): sm = self.session_manager @@ -1071,11 +1132,13 @@ def send_zslice(self, z: int): # Convert to RGB slice_rgb = np.stack([slice_norm, slice_norm, slice_norm], axis=-1) - self.send_json({ - "z": z, - "z_depth": z_depth, - "data": image_to_base64(slice_rgb), - }) + self.send_json( + { + "z": z, + "z_depth": z_depth, + "data": image_to_base64(slice_rgb), + } + ) def send_volume(self): """Send full volume as base64 for 3D rendering.""" @@ -1101,10 +1164,12 @@ def send_volume(self): vol_bytes = vol_uint8.tobytes() vol_b64 = base64.b64encode(vol_bytes).decode() - self.send_json({ - "shape": [z, h, w], - "data": vol_b64, - }) + self.send_json( + { + "shape": [z, h, w], + "data": vol_b64, + } + ) def send_projections(self): sm = self.session_manager @@ -1119,11 +1184,13 @@ def send_projections(self): result = sm.get_projection(idx, method_name) if result: img, desc = result - projections.append({ - "method": method_name, - "description": desc, - "data": image_to_base64(img), - }) + projections.append( + { + "method": method_name, + "description": desc, + "data": image_to_base64(img), + } + ) if not projections: self.send_json({"error": "No volume loaded"}) @@ -1133,18 +1200,21 @@ def send_projections(self): volume = sm.get_current_volume() volume_shape = list(volume.shape) if volume is not None else [] - self.send_json({ - "idx": idx, - "total": sm.total_timepoints, - "filename": path.name if path else "", - "embryo": sm.current_embryo, - "embryo_list": sm.embryo_list, - "projections": projections, - "volume_shape": volume_shape, - }) + self.send_json( + { + "idx": idx, + "total": sm.total_timepoints, + "filename": path.name if path else "", + "embryo": sm.current_embryo, + "embryo_list": sm.embryo_list, + "projections": projections, + "volume_shape": volume_shape, + } + ) # Preload adjacent volumes in background import threading + threading.Thread(target=sm.preload_adjacent, args=(idx,), daemon=True).start() def send_html(self): @@ -1316,10 +1386,13 @@ def send_html(self):
@@ -1335,10 +1408,17 @@ def send_html(self):
3D Volume
Drag: X/Y | Shift+Drag: Z | Scroll: zoom | - Threshold: - 0.30 - | angle_y: 0.00, angle_x: 0.00, angle_z: 0.00 - + Threshold: + 0.30 + | angle_y: 0.00, angle_x: 0.00, angle_z: 0.00 +
@@ -1358,13 +1438,19 @@ def send_html(self): // Ground truth stage transitions (from biologist annotations) const GROUND_TRUTH = { - "embryo_1": {"early": 0, "bean": 43, "comma": 49, "1.5fold": 55, "2fold": 70, "pretzel": 90}, - "embryo_2": {"early": 0, "bean": 33, "comma": 39, "1.5fold": 45, "2fold": 60, "pretzel": 80}, - "embryo_3": {"early": 0, "bean": 27, "comma": 33, "1.5fold": 39, "2fold": 50, "pretzel": 69}, - "embryo_4": {"early": 0, "bean": 54, "comma": 60, "1.5fold": 69, "2fold": 77, "pretzel": 97} + "embryo_1": {"early": 0, "bean": 43, "comma": 49, + "1.5fold": 55, "2fold": 70, "pretzel": 90}, + "embryo_2": {"early": 0, "bean": 33, "comma": 39, + "1.5fold": 45, "2fold": 60, "pretzel": 80}, + "embryo_3": {"early": 0, "bean": 27, "comma": 33, + "1.5fold": 39, "2fold": 50, "pretzel": 69}, + "embryo_4": {"early": 0, "bean": 54, "comma": 60, + "1.5fold": 69, "2fold": 77, "pretzel": 97} }; - const STAGE_ORDER = ["early", "bean", "comma", "1.5fold", "2fold", "pretzel", "hatching", "hatched"]; + const STAGE_ORDER = [ + "early", "bean", "comma", "1.5fold", "2fold", "pretzel", "hatching", "hatched" + ]; const STAGE_COLORS = { "early": "#6e7681", "bean": "#8b5cf6", @@ -1450,7 +1536,8 @@ def send_html(self): toggles.innerHTML = ''; data.methods.forEach((m, i) => { const btn = document.createElement('button'); - btn.className = 'method-toggle' + (data.active_methods.includes(m) ? ' active' : ''); + btn.className = 'method-toggle' + + (data.active_methods.includes(m) ? ' active' : ''); btn.textContent = (i + 1) + ': ' + m; btn.onclick = () => toggleMethod(m); toggles.appendChild(btn); @@ -1495,7 +1582,10 @@ def send_html(self):
${proj.method}
${proj.description} - +
@@ -1654,7 +1744,9 @@ def send_html(self): const zIndex = Math.floor(i * zd / numSlices); const zPos = (i / numSlices - 0.5) * zScale; const tex = createSliceTex(zIndex, threshold); - const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide, depthWrite: false }); + const mat = new THREE.MeshBasicMaterial({ + map: tex, transparent: true, side: THREE.DoubleSide, depthWrite: false + }); const geo = new THREE.PlaneGeometry(1, 1 / aspect); const mesh = new THREE.Mesh(geo, mat); mesh.position.z = zPos; @@ -1751,7 +1843,8 @@ def send_html(self): document.getElementById('thresh3d').addEventListener('input', (e) => { const threshVal = parseInt(e.target.value); buildSlices3d(32, threshVal); - document.getElementById('thresh-display').textContent = (threshVal / 100).toFixed(2); + document.getElementById('thresh-display').textContent = + (threshVal / 100).toFixed(2); }); renderer3d.domElement.addEventListener('mousedown', (e) => { @@ -1784,7 +1877,8 @@ def send_html(self): }); renderer3d.domElement.addEventListener('wheel', (e) => { e.preventDefault(); - camera3d.position.z = Math.max(0.5, Math.min(5, camera3d.position.z + e.deltaY * 0.002)); + camera3d.position.z = + Math.max(0.5, Math.min(5, camera3d.position.z + e.deltaY * 0.002)); savedZoom = camera3d.position.z; }); @@ -1978,7 +2072,9 @@ def send_3d_viewer(self): const [zd, h, w] = volumeShape; scene = new THREE.Scene(); - camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100); + camera = new THREE.PerspectiveCamera( + 50, window.innerWidth / window.innerHeight, 0.1, 100 + ); camera.position.z = 2.5; renderer = new THREE.WebGLRenderer({ antialias: true }); @@ -2059,6 +2155,7 @@ class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer): # Main # ============================================================================= + def main(): parser = argparse.ArgumentParser(description="Projection Explorer") parser.add_argument("--session", required=True, help="Session ID") @@ -2117,8 +2214,8 @@ def main(): # Save image pil_img = PIL_Image.fromarray(img) - if pil_img.mode != 'RGB': - pil_img = pil_img.convert('RGB') + if pil_img.mode != "RGB": + pil_img = pil_img.convert("RGB") filename = f"t{idx:04d}_{method_name}.jpg" pil_img.save(output_dir / filename, quality=90) @@ -2138,6 +2235,7 @@ def main(): if not args.no_browser: import webbrowser + webbrowser.open(url) try: diff --git a/scripts/recreate_session_videos.py b/scripts/recreate_session_videos.py index e725f56d..aa53a48e 100644 --- a/scripts/recreate_session_videos.py +++ b/scripts/recreate_session_videos.py @@ -28,9 +28,9 @@ import logging import sys from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Any import cv2 import numpy as np @@ -42,22 +42,22 @@ # --------------------------------------------------------------------------- # Presentation palette — dark, projector-friendly. All BGR (cv2 native). # --------------------------------------------------------------------------- -COL_BG = (22, 17, 14) # #0E1116 — main background -COL_PANEL = (38, 31, 26) # #1A1F26 — card surface -COL_PANEL_HI = (64, 52, 45) # #2D3440 — borders -COL_TEXT = (235, 233, 229) # off-white primary -COL_TEXT_DIM = (175, 162, 156) # secondary -COL_ACCENT = (250, 165, 96) # sky blue (BGR) -COL_DIVIDER = (80, 70, 60) +COL_BG = (22, 17, 14) # #0E1116 — main background +COL_PANEL = (38, 31, 26) # #1A1F26 — card surface +COL_PANEL_HI = (64, 52, 45) # #2D3440 — borders +COL_TEXT = (235, 233, 229) # off-white primary +COL_TEXT_DIM = (175, 162, 156) # secondary +COL_ACCENT = (250, 165, 96) # sky blue (BGR) +COL_DIVIDER = (80, 70, 60) # Cadence regimes — interval (seconds) → (label, BGR color, regime key) -CADENCE_5MIN = ("5 MIN", (137, 211, 52), "5min") # emerald (#34D399) -CADENCE_1MIN = ("1 MIN", (36, 191, 251), "1min") # amber (#FBBF24) -CADENCE_BURST = ("BURST", (113, 113, 248), "burst") # red (#F87171) -CADENCE_OTHER = ("---", (160, 160, 160), "other") +CADENCE_5MIN = ("5 MIN", (137, 211, 52), "5min") # emerald (#34D399) +CADENCE_1MIN = ("1 MIN", (36, 191, 251), "1min") # amber (#FBBF24) +CADENCE_BURST = ("BURST", (113, 113, 248), "burst") # red (#F87171) +CADENCE_OTHER = ("---", (160, 160, 160), "other") -def cadence_for_interval(interval_s: float) -> Tuple[str, Tuple[int, int, int], str]: +def cadence_for_interval(interval_s: float) -> tuple[str, tuple[int, int, int], str]: if interval_s <= 5.0: return CADENCE_BURST if interval_s <= 90.0: @@ -75,17 +75,17 @@ class FrameRecord: timepoint: int jpg_path: Path acquired_at: datetime - role: Optional[str] = None - predicted_stage: Optional[str] = None + role: str | None = None + predicted_stage: str | None = None interval_to_next_s: float = 0.0 # filled later cadence_label: str = "---" - cadence_color: Tuple[int, int, int] = (160, 160, 160) + cadence_color: tuple[int, int, int] = (160, 160, 160) @dataclass class EmbryoData: embryo_id: str - frames: List[FrameRecord] = field(default_factory=list) + frames: list[FrameRecord] = field(default_factory=list) @dataclass @@ -93,11 +93,11 @@ class BurstData: embryo_id: str request_id: str burst_dir: Path - manifest: Dict[str, Any] = field(default_factory=dict) - frames: List[FrameRecord] = field(default_factory=list) + manifest: dict[str, Any] = field(default_factory=dict) + frames: list[FrameRecord] = field(default_factory=list) -def _read_meta(meta_path: Path) -> Dict: +def _read_meta(meta_path: Path) -> dict: try: with meta_path.open("r", encoding="utf-8") as f: return yaml.safe_load(f) or {} @@ -106,9 +106,9 @@ def _read_meta(meta_path: Path) -> Dict: return {} -def _load_predictions(predictions_path: Path) -> Dict[int, str]: +def _load_predictions(predictions_path: Path) -> dict[int, str]: """Map timepoint -> predicted_stage from predictions.jsonl (most-recent wins).""" - out: Dict[int, str] = {} + out: dict[int, str] = {} if not predictions_path.exists(): return out with predictions_path.open("r", encoding="utf-8") as f: @@ -127,7 +127,7 @@ def _load_predictions(predictions_path: Path) -> Dict[int, str]: return out -def load_embryo(embryo_dir: Path, *, use_perception: bool = True) -> Optional[EmbryoData]: +def load_embryo(embryo_dir: Path, *, use_perception: bool = True) -> EmbryoData | None: projections_dir = embryo_dir / "projections" volumes_dir = embryo_dir / "volumes" if not projections_dir.is_dir(): @@ -135,7 +135,7 @@ def load_embryo(embryo_dir: Path, *, use_perception: bool = True) -> Optional[Em predictions = _load_predictions(embryo_dir / "predictions.jsonl") if use_perception else {} - frames: List[FrameRecord] = [] + frames: list[FrameRecord] = [] for jpg in sorted(projections_dir.glob("t*.jpg")): stem = jpg.stem # e.g. "t0003" try: @@ -151,13 +151,15 @@ def load_embryo(embryo_dir: Path, *, use_perception: bool = True) -> Optional[Em else: acquired_at = datetime.fromisoformat(str(acquired_raw)) - frames.append(FrameRecord( - timepoint=tp, - jpg_path=jpg, - acquired_at=acquired_at, - role=meta.get("metadata", {}).get("role"), - predicted_stage=predictions.get(tp), - )) + frames.append( + FrameRecord( + timepoint=tp, + jpg_path=jpg, + acquired_at=acquired_at, + role=meta.get("metadata", {}).get("role"), + predicted_stage=predictions.get(tp), + ) + ) if not frames: return None @@ -185,7 +187,7 @@ def _looks_like_full_session(path: Path) -> bool: return path.is_dir() and (path / "session.yaml").exists() and (path / "embryos").is_dir() -def load_burst(burst_dir: Path) -> Optional[BurstData]: +def load_burst(burst_dir: Path) -> BurstData | None: """Load one burst (request_id subfolder) from disk. Layout written by BurstAcquisition.run: @@ -201,7 +203,7 @@ def load_burst(burst_dir: Path) -> Optional[BurstData]: if not proj_dir.is_dir(): return None - manifest: Dict[str, Any] = {} + manifest: dict[str, Any] = {} manifest_path = burst_dir / "burst.yaml" if manifest_path.exists(): try: @@ -213,7 +215,7 @@ def load_burst(burst_dir: Path) -> Optional[BurstData]: embryo_id = manifest.get("embryo_id") or burst_dir.parent.parent.name request_id = manifest.get("request_id") or burst_dir.name - frames: List[FrameRecord] = [] + frames: list[FrameRecord] = [] for jpg in sorted(proj_dir.glob("t*.jpg")): stem = jpg.stem try: @@ -226,13 +228,15 @@ def load_burst(burst_dir: Path) -> Optional[BurstData]: acquired_at = datetime.fromisoformat(str(acquired_raw)) else: acquired_at = datetime.fromtimestamp(jpg.stat().st_mtime) - frames.append(FrameRecord( - timepoint=idx, - jpg_path=jpg, - acquired_at=acquired_at, - role="burst", - predicted_stage=None, - )) + frames.append( + FrameRecord( + timepoint=idx, + jpg_path=jpg, + acquired_at=acquired_at, + role="burst", + predicted_stage=None, + ) + ) if not frames: return None @@ -251,13 +255,18 @@ def load_burst(burst_dir: Path) -> Optional[BurstData]: fr.cadence_label = label fr.cadence_color = color - return BurstData(embryo_id=embryo_id, request_id=request_id, - burst_dir=burst_dir, manifest=manifest, frames=frames) + return BurstData( + embryo_id=embryo_id, + request_id=request_id, + burst_dir=burst_dir, + manifest=manifest, + frames=frames, + ) -def discover_bursts(session_dir: Path) -> List[BurstData]: +def discover_bursts(session_dir: Path) -> list[BurstData]: """Find every burst across all embryos under this session.""" - out: List[BurstData] = [] + out: list[BurstData] = [] for embryo_dir in sorted((session_dir / "embryos").glob("embryo_*")): bursts_root = embryo_dir / "bursts" if not bursts_root.is_dir(): @@ -271,13 +280,13 @@ def discover_bursts(session_dir: Path) -> List[BurstData]: return out -def find_session(storage: Path, requested: Optional[str]) -> Path: +def find_session(storage: Path, requested: str | None) -> Path: sessions_root = storage / "sessions" if not sessions_root.is_dir(): raise FileNotFoundError(f"Sessions root not found: {sessions_root}") # _index.yaml is the source of truth for short-id -> folder-name mapping. - index: Dict[str, str] = {} + index: dict[str, str] = {} index_path = sessions_root / "_index.yaml" if index_path.exists(): try: @@ -314,16 +323,16 @@ def find_session(storage: Path, requested: Optional[str]) -> Path: # --------------------------------------------------------------------------- # Frame rendering # --------------------------------------------------------------------------- -HEADER_H = 80 # px above the projection (per-embryo header) -FOOTER_H = 28 # px below the projection (cadence bar + spacing) -GRID_TITLE_H = 110 # px at top of grid -GRID_GUTTER = 14 # px between cells -GRID_MARGIN = 18 # px around grid edge +HEADER_H = 80 # px above the projection (per-embryo header) +FOOTER_H = 28 # px below the projection (cadence bar + spacing) +GRID_TITLE_H = 110 # px at top of grid +GRID_GUTTER = 14 # px between cells +GRID_MARGIN = 18 # px around grid edge # --- TrueType font loading (Segoe UI on Windows, fall back to Inter / DejaVu) --- _FONT_CANDIDATES = [ - "C:/Windows/Fonts/segoeuib.ttf", # Segoe UI Bold + "C:/Windows/Fonts/segoeuib.ttf", # Segoe UI Bold "C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/Inter-Regular.ttf", "C:/Windows/Fonts/calibri.ttf", @@ -347,7 +356,7 @@ def _load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: return ImageFont.load_default() -_font_cache: Dict[Tuple[int, bool], ImageFont.FreeTypeFont] = {} +_font_cache: dict[tuple[int, bool], ImageFont.FreeTypeFont] = {} def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: @@ -357,25 +366,36 @@ def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: return _font_cache[key] -def _bgr_to_rgb(c: Tuple[int, int, int]) -> Tuple[int, int, int]: +def _bgr_to_rgb(c: tuple[int, int, int]) -> tuple[int, int, int]: return (c[2], c[1], c[0]) -def _draw_text(draw: ImageDraw.ImageDraw, xy: Tuple[int, int], text: str, - *, size: int = 16, bold: bool = False, - color: Tuple[int, int, int] = COL_TEXT, - anchor: str = "la") -> None: +def _draw_text( + draw: ImageDraw.ImageDraw, + xy: tuple[int, int], + text: str, + *, + size: int = 16, + bold: bool = False, + color: tuple[int, int, int] = COL_TEXT, + anchor: str = "la", +) -> None: """Draw RGB text via PIL. ``color`` is BGR (project convention).""" - draw.text(xy, text, font=font(size, bold=bold), - fill=_bgr_to_rgb(color), anchor=anchor) + draw.text(xy, text, font=font(size, bold=bold), fill=_bgr_to_rgb(color), anchor=anchor) -def _draw_rounded(draw: ImageDraw.ImageDraw, box: Tuple[int, int, int, int], - radius: int, *, fill=None, outline=None, width: int = 1) -> None: +def _draw_rounded( + draw: ImageDraw.ImageDraw, + box: tuple[int, int, int, int], + radius: int, + *, + fill=None, + outline=None, + width: int = 1, +) -> None: fill_rgb = _bgr_to_rgb(fill) if fill else None outline_rgb = _bgr_to_rgb(outline) if outline else None - draw.rounded_rectangle(box, radius=radius, - fill=fill_rgb, outline=outline_rgb, width=width) + draw.rounded_rectangle(box, radius=radius, fill=fill_rgb, outline=outline_rgb, width=width) def _format_elapsed(seconds: float) -> str: @@ -390,7 +410,7 @@ def _format_elapsed(seconds: float) -> str: VIEW_CROP: str = "a" # "a" | "b" | "both" -def _load_projection_bgr(jpg_path: Path) -> Optional[np.ndarray]: +def _load_projection_bgr(jpg_path: Path) -> np.ndarray | None: img = cv2.imread(str(jpg_path), cv2.IMREAD_GRAYSCALE) if img is None: return None @@ -402,7 +422,7 @@ def _load_projection_bgr(jpg_path: Path) -> Optional[np.ndarray]: return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) -def _proj_to_pil(proj: np.ndarray, target_size: Tuple[int, int]) -> Image.Image: +def _proj_to_pil(proj: np.ndarray, target_size: tuple[int, int]) -> Image.Image: """Resize a BGR ndarray projection and return as PIL RGB Image.""" resized = cv2.resize(proj, target_size, interpolation=cv2.INTER_AREA) return Image.fromarray(cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)) @@ -416,10 +436,10 @@ def _decorate_embryo_frame( elapsed_s: float, embryo_id: str, cadence_label: str, - cadence_color: Tuple[int, int, int], - stage: Optional[str], + cadence_color: tuple[int, int, int], + stage: str | None, stage_changed: bool, - session_label: Optional[str] = None, + session_label: str | None = None, ) -> np.ndarray: """Compose header + projection (with subtle card frame) + cadence bar.""" margin = 28 @@ -431,28 +451,53 @@ def _decorate_embryo_frame( # --- Header panel --- header_box = (margin, margin, width - margin, margin + HEADER_H - 14) - _draw_rounded(draw, header_box, radius=10, - fill=COL_PANEL, outline=COL_PANEL_HI, width=1) + _draw_rounded(draw, header_box, radius=10, fill=COL_PANEL, outline=COL_PANEL_HI, width=1) # Big timecode (left) - _draw_text(draw, (margin + 18, margin + 12), - _format_elapsed(elapsed_s), size=28, bold=True, color=COL_ACCENT) - _draw_text(draw, (margin + 18, margin + 44), - embryo_id.replace("_", " ").upper(), size=13, - color=COL_TEXT_DIM, bold=True) + _draw_text( + draw, + (margin + 18, margin + 12), + _format_elapsed(elapsed_s), + size=28, + bold=True, + color=COL_ACCENT, + ) + _draw_text( + draw, + (margin + 18, margin + 44), + embryo_id.replace("_", " ").upper(), + size=13, + color=COL_TEXT_DIM, + bold=True, + ) if session_label: - _draw_text(draw, (margin + 160, margin + 47), - session_label, size=12, color=COL_TEXT_DIM) + _draw_text( + draw, + (margin + 160, margin + 47), + session_label, + size=12, + color=COL_TEXT_DIM, + ) # Cadence pill (right top) pill_w, pill_h = 110, 30 pill_x1 = width - margin - 18 - pill_w pill_y1 = margin + 12 - _draw_rounded(draw, (pill_x1, pill_y1, pill_x1 + pill_w, pill_y1 + pill_h), - radius=8, fill=cadence_color) - _draw_text(draw, (pill_x1 + pill_w // 2, pill_y1 + pill_h // 2), - cadence_label, size=14, bold=True, - color=(20, 20, 20), anchor="mm") + _draw_rounded( + draw, + (pill_x1, pill_y1, pill_x1 + pill_w, pill_y1 + pill_h), + radius=8, + fill=cadence_color, + ) + _draw_text( + draw, + (pill_x1 + pill_w // 2, pill_y1 + pill_h // 2), + cadence_label, + size=14, + bold=True, + color=(20, 20, 20), + anchor="mm", + ) # Stage chip (right bottom of header) if stage: @@ -462,24 +507,39 @@ def _decorate_embryo_frame( chip_y1 = pill_y1 + pill_h + 6 chip_fill = COL_ACCENT if stage_changed else COL_PANEL_HI chip_text_col = (20, 20, 20) if stage_changed else COL_TEXT - _draw_rounded(draw, (chip_x1, chip_y1, chip_x1 + chip_w, chip_y1 + 22), - radius=6, fill=chip_fill) - _draw_text(draw, (chip_x1 + chip_w // 2, chip_y1 + 11), - stage_txt, size=12, bold=True, color=chip_text_col, anchor="mm") + _draw_rounded( + draw, + (chip_x1, chip_y1, chip_x1 + chip_w, chip_y1 + 22), + radius=6, + fill=chip_fill, + ) + _draw_text( + draw, + (chip_x1 + chip_w // 2, chip_y1 + 11), + stage_txt, + size=12, + bold=True, + color=chip_text_col, + anchor="mm", + ) # --- Projection card --- proj_x = margin proj_y = margin + HEADER_H pil_proj = _proj_to_pil(proj, (proj_w, proj_h)) # Subtle outer border around the projection - _draw_rounded(draw, (proj_x - 2, proj_y - 2, proj_x + proj_w + 2, proj_y + proj_h + 2), - radius=6, outline=COL_PANEL_HI, width=2) + _draw_rounded( + draw, + (proj_x - 2, proj_y - 2, proj_x + proj_w + 2, proj_y + proj_h + 2), + radius=6, + outline=COL_PANEL_HI, + width=2, + ) canvas.paste(pil_proj, (proj_x, proj_y)) # --- Footer cadence bar --- bar_y = proj_y + proj_h + 10 - _draw_rounded(draw, (proj_x, bar_y, proj_x + proj_w, bar_y + 6), - radius=3, fill=cadence_color) + _draw_rounded(draw, (proj_x, bar_y, proj_x + proj_w, bar_y + 6), radius=3, fill=cadence_color) return cv2.cvtColor(np.array(canvas), cv2.COLOR_RGB2BGR) @@ -490,7 +550,7 @@ def _decorate_embryo_frame( @dataclass class VideoParams: fps: int = 30 - speedup: float = 600.0 # real_seconds rendered into 1 video second + speedup: float = 600.0 # real_seconds rendered into 1 video second min_frame_dt_s: float = 0.06 # don't flash burst frames max_frame_dt_s: float = 0.40 # don't drag slow frames @@ -500,7 +560,7 @@ def _video_dt_for_real_interval(interval_s: float, vp: VideoParams) -> float: return max(vp.min_frame_dt_s, min(vp.max_frame_dt_s, raw)) -def _open_writer(path: Path, size: Tuple[int, int], fps: int): +def _open_writer(path: Path, size: tuple[int, int], fps: int): """Try mp4v then avc1 then fall back to AVI codecs (matches video_maker.py).""" codecs = ( ("mp4v", ".mp4"), @@ -522,15 +582,20 @@ def _open_writer(path: Path, size: Tuple[int, int], fps: int): # --------------------------------------------------------------------------- # Per-embryo video # --------------------------------------------------------------------------- -def _embryo_proj_dims() -> Tuple[int, int]: +def _embryo_proj_dims() -> tuple[int, int]: """2x-scale the cropped projection for crisp text proportion on slides.""" if VIEW_CROP in ("a", "b"): - return 1024, 512 # 2x of native 512x256 - return 1024, 256 # native widescreen for both-views + return 1024, 512 # 2x of native 512x256 + return 1024, 256 # native widescreen for both-views -def render_embryo_video(emb: EmbryoData, out_path: Path, vp: VideoParams, - *, session_label: Optional[str] = None) -> Optional[Path]: +def render_embryo_video( + emb: EmbryoData, + out_path: Path, + vp: VideoParams, + *, + session_label: str | None = None, +) -> Path | None: proj_w, proj_h = _embryo_proj_dims() margin = 28 canvas_w = proj_w + 2 * margin @@ -542,14 +607,18 @@ def render_embryo_video(emb: EmbryoData, out_path: Path, vp: VideoParams, return None t0 = emb.frames[0].acquired_at - prev_stage: Optional[str] = None - last_proj: Optional[np.ndarray] = None + prev_stage: str | None = None + last_proj: np.ndarray | None = None written = 0 for fr in emb.frames: proj = _load_projection_bgr(fr.jpg_path) if proj is None: - logger.warning("[%s] could not read %s — reusing previous", emb.embryo_id, fr.jpg_path.name) + logger.warning( + "[%s] could not read %s — reusing previous", + emb.embryo_id, + fr.jpg_path.name, + ) if last_proj is None: continue proj = last_proj @@ -559,12 +628,22 @@ def render_embryo_video(emb: EmbryoData, out_path: Path, vp: VideoParams, stage_changed = bool(fr.predicted_stage and fr.predicted_stage != prev_stage) elapsed_s = (fr.acquired_at - t0).total_seconds() - def render(_changed: bool) -> np.ndarray: + def render( + _changed: bool, + _proj=proj, + _elapsed=elapsed_s, + _fr=fr, + ) -> np.ndarray: return _decorate_embryo_frame( - proj, proj_w=proj_w, proj_h=proj_h, - elapsed_s=elapsed_s, embryo_id=emb.embryo_id, - cadence_label=fr.cadence_label, cadence_color=fr.cadence_color, - stage=fr.predicted_stage, stage_changed=_changed, + _proj, + proj_w=proj_w, + proj_h=proj_h, + elapsed_s=_elapsed, + embryo_id=emb.embryo_id, + cadence_label=_fr.cadence_label, + cadence_color=_fr.cadence_color, + stage=_fr.predicted_stage, + stage_changed=_changed, session_label=session_label, ) @@ -581,16 +660,27 @@ def render(_changed: bool) -> np.ndarray: prev_stage = fr.predicted_stage writer.release() - logger.info("[%s] wrote %s (%d video frames, %d input frames, codec=%s)", - emb.embryo_id, real_out, written, len(emb.frames), codec) + logger.info( + "[%s] wrote %s (%d video frames, %d input frames, codec=%s)", + emb.embryo_id, + real_out, + written, + len(emb.frames), + codec, + ) return real_out # --------------------------------------------------------------------------- # Burst-only videos # --------------------------------------------------------------------------- -def render_burst_video(burst: BurstData, out_path: Path, vp: VideoParams, - *, session_label: Optional[str] = None) -> Optional[Path]: +def render_burst_video( + burst: BurstData, + out_path: Path, + vp: VideoParams, + *, + session_label: str | None = None, +) -> Path | None: """Render one MP4 for a single burst (request_id) — slower default speedup so the embryo actually looks alive.""" # Reuse the embryo-video composition by wrapping as EmbryoData. The header @@ -605,27 +695,40 @@ def render_burst_video(burst: BurstData, out_path: Path, vp: VideoParams, # --------------------------------------------------------------------------- # Grid video (real-time synchronized) # --------------------------------------------------------------------------- -GRID_COLS_BY_N = {1: 1, 2: 2, 3: 3, 4: 2, 5: 3, 6: 3, 7: 4, 8: 4, 9: 3, 10: 5, 11: 4, 12: 4} - - -def _grid_layout(n: int) -> Tuple[int, int]: +GRID_COLS_BY_N = { + 1: 1, + 2: 2, + 3: 3, + 4: 2, + 5: 3, + 6: 3, + 7: 4, + 8: 4, + 9: 3, + 10: 5, + 11: 4, + 12: 4, +} + + +def _grid_layout(n: int) -> tuple[int, int]: cols = GRID_COLS_BY_N.get(n, max(1, int(np.ceil(np.sqrt(n))))) rows = int(np.ceil(n / cols)) return rows, cols -def _grid_cell_dims() -> Tuple[int, int]: +def _grid_cell_dims() -> tuple[int, int]: """Pick cell projection size so view-A-only and both-views both look right.""" if VIEW_CROP in ("a", "b"): - return 540, 270 # 2:1 — matches cropped single view - return 600, 150 # 4:1 — matches both-views native aspect + return 540, 270 # 2:1 — matches cropped single view + return 600, 150 # 4:1 — matches both-views native aspect CELL_HEADER_H = 38 CELL_FOOTER_H = 6 -def _build_global_timeline(embryos: List[EmbryoData]) -> List[datetime]: +def _build_global_timeline(embryos: list[EmbryoData]) -> list[datetime]: times = set() for emb in embryos: for fr in emb.frames: @@ -633,7 +736,7 @@ def _build_global_timeline(embryos: List[EmbryoData]) -> List[datetime]: return sorted(times) -def _most_recent_index(frames: List[FrameRecord], t: datetime) -> int: +def _most_recent_index(frames: list[FrameRecord], t: datetime) -> int: # Linear scan is fine for ~60 frames per embryo. idx = -1 for i, fr in enumerate(frames): @@ -644,63 +747,110 @@ def _most_recent_index(frames: List[FrameRecord], t: datetime) -> int: return idx -def _draw_cell(canvas: Image.Image, top_left: Tuple[int, int], *, - cell_w: int, cell_h: int, - proj: Optional[np.ndarray], - embryo_id: str, - cadence_color: Tuple[int, int, int], - cadence_label: str, - stage: Optional[str]) -> None: +def _draw_cell( + canvas: Image.Image, + top_left: tuple[int, int], + *, + cell_w: int, + cell_h: int, + proj: np.ndarray | None, + embryo_id: str, + cadence_color: tuple[int, int, int], + cadence_label: str, + stage: str | None, +) -> None: """Paint a single cell onto the grid canvas at top_left.""" x, y = top_left total_h = CELL_HEADER_H + cell_h + CELL_FOOTER_H draw = ImageDraw.Draw(canvas) # Card background + outer border - _draw_rounded(draw, (x, y, x + cell_w, y + total_h), radius=12, - fill=COL_PANEL, outline=COL_PANEL_HI, width=1) + _draw_rounded( + draw, + (x, y, x + cell_w, y + total_h), + radius=12, + fill=COL_PANEL, + outline=COL_PANEL_HI, + width=1, + ) # Header band - _draw_text(draw, (x + 14, y + CELL_HEADER_H // 2), - embryo_id.replace("_", " ").upper(), size=14, bold=True, - color=COL_TEXT, anchor="lm") + _draw_text( + draw, + (x + 14, y + CELL_HEADER_H // 2), + embryo_id.replace("_", " ").upper(), + size=14, + bold=True, + color=COL_TEXT, + anchor="lm", + ) if stage: - _draw_text(draw, (x + cell_w // 2, y + CELL_HEADER_H // 2), - stage.upper(), size=12, bold=True, - color=COL_ACCENT, anchor="mm") + _draw_text( + draw, + (x + cell_w // 2, y + CELL_HEADER_H // 2), + stage.upper(), + size=12, + bold=True, + color=COL_ACCENT, + anchor="mm", + ) # Cadence pill (right) pill_w, pill_h = 78, 22 pill_x1 = x + cell_w - 14 - pill_w pill_y1 = y + (CELL_HEADER_H - pill_h) // 2 - _draw_rounded(draw, (pill_x1, pill_y1, pill_x1 + pill_w, pill_y1 + pill_h), - radius=6, fill=cadence_color) - _draw_text(draw, (pill_x1 + pill_w // 2, pill_y1 + pill_h // 2), - cadence_label, size=11, bold=True, - color=(20, 20, 20), anchor="mm") + _draw_rounded( + draw, + (pill_x1, pill_y1, pill_x1 + pill_w, pill_y1 + pill_h), + radius=6, + fill=cadence_color, + ) + _draw_text( + draw, + (pill_x1 + pill_w // 2, pill_y1 + pill_h // 2), + cadence_label, + size=11, + bold=True, + color=(20, 20, 20), + anchor="mm", + ) # Projection area proj_y = y + CELL_HEADER_H if proj is None: # "Waiting" placeholder - _draw_rounded(draw, (x + 6, proj_y, x + cell_w - 6, proj_y + cell_h), - radius=6, fill=(48, 40, 34)) - _draw_text(draw, (x + cell_w // 2, proj_y + cell_h // 2), - "WAITING", size=14, bold=True, - color=COL_TEXT_DIM, anchor="mm") + _draw_rounded( + draw, + (x + 6, proj_y, x + cell_w - 6, proj_y + cell_h), + radius=6, + fill=(48, 40, 34), + ) + _draw_text( + draw, + (x + cell_w // 2, proj_y + cell_h // 2), + "WAITING", + size=14, + bold=True, + color=COL_TEXT_DIM, + anchor="mm", + ) else: pil_proj = _proj_to_pil(proj, (cell_w, cell_h)) canvas.paste(pil_proj, (x, proj_y)) # Bottom cadence accent bar_y = proj_y + cell_h - _draw_rounded(draw, (x + 6, bar_y, x + cell_w - 6, bar_y + 4), - radius=2, fill=cadence_color) + _draw_rounded(draw, (x + 6, bar_y, x + cell_w - 6, bar_y + 4), radius=2, fill=cadence_color) -def render_grid_video(embryos: List[EmbryoData], out_path: Path, vp: VideoParams, - *, session_label: Optional[str] = None) -> Optional[Path]: +def render_grid_video( + embryos: list[EmbryoData], + out_path: Path, + vp: VideoParams, + *, + session_label: str | None = None, +) -> Path | None: if not embryos: return None timeline = _build_global_timeline(embryos) @@ -722,8 +872,8 @@ def render_grid_video(embryos: List[EmbryoData], out_path: Path, vp: VideoParams logger.error("Grid: no working codec — skipped") return None - cached_proj: Dict[str, Optional[np.ndarray]] = {emb.embryo_id: None for emb in embryos} - cached_record_idx: Dict[str, int] = {emb.embryo_id: -1 for emb in embryos} + cached_proj: dict[str, np.ndarray | None] = {emb.embryo_id: None for emb in embryos} + cached_record_idx: dict[str, int] = {emb.embryo_id: -1 for emb in embryos} t0 = timeline[0] written = 0 @@ -744,19 +894,39 @@ def render_grid_video(embryos: List[EmbryoData], out_path: Path, vp: VideoParams draw = ImageDraw.Draw(canvas) # --- Title bar --- - title_box = (GRID_MARGIN, GRID_MARGIN, - canvas_w - GRID_MARGIN, GRID_MARGIN + GRID_TITLE_H - 16) - _draw_rounded(draw, title_box, radius=12, - fill=COL_PANEL, outline=COL_PANEL_HI, width=1) + title_box = ( + GRID_MARGIN, + GRID_MARGIN, + canvas_w - GRID_MARGIN, + GRID_MARGIN + GRID_TITLE_H - 16, + ) + _draw_rounded(draw, title_box, radius=12, fill=COL_PANEL, outline=COL_PANEL_HI, width=1) elapsed_s = (t - t0).total_seconds() - _draw_text(draw, (GRID_MARGIN + 22, GRID_MARGIN + 18), - _format_elapsed(elapsed_s), size=36, bold=True, color=COL_ACCENT) - _draw_text(draw, (GRID_MARGIN + 22, GRID_MARGIN + 58), - "elapsed", size=12, color=COL_TEXT_DIM, bold=True) + _draw_text( + draw, + (GRID_MARGIN + 22, GRID_MARGIN + 18), + _format_elapsed(elapsed_s), + size=36, + bold=True, + color=COL_ACCENT, + ) + _draw_text( + draw, + (GRID_MARGIN + 22, GRID_MARGIN + 58), + "elapsed", + size=12, + color=COL_TEXT_DIM, + bold=True, + ) if session_label: - _draw_text(draw, (GRID_MARGIN + 200, GRID_MARGIN + 64), - session_label, size=12, color=COL_TEXT_DIM) + _draw_text( + draw, + (GRID_MARGIN + 200, GRID_MARGIN + 64), + session_label, + size=12, + color=COL_TEXT_DIM, + ) # Active cadence pill (right) active = None @@ -778,12 +948,21 @@ def render_grid_video(embryos: List[EmbryoData], out_path: Path, vp: VideoParams pill_w, pill_h = 200, 44 pill_x1 = canvas_w - GRID_MARGIN - 22 - pill_w pill_y1 = GRID_MARGIN + (GRID_TITLE_H - 16 - pill_h) // 2 - _draw_rounded(draw, (pill_x1, pill_y1, - pill_x1 + pill_w, pill_y1 + pill_h), - radius=10, fill=color) - _draw_text(draw, (pill_x1 + pill_w // 2, pill_y1 + pill_h // 2), - f"CADENCE {label}", size=16, bold=True, - color=(20, 20, 20), anchor="mm") + _draw_rounded( + draw, + (pill_x1, pill_y1, pill_x1 + pill_w, pill_y1 + pill_h), + radius=10, + fill=color, + ) + _draw_text( + draw, + (pill_x1 + pill_w // 2, pill_y1 + pill_h // 2), + f"CADENCE {label}", + size=16, + bold=True, + color=(20, 20, 20), + anchor="mm", + ) # --- Cells --- for idx, emb in enumerate(embryos): @@ -794,18 +973,30 @@ def render_grid_video(embryos: List[EmbryoData], out_path: Path, vp: VideoParams i = cached_record_idx[emb.embryo_id] if i < 0: - _draw_cell(canvas, (x, y), cell_w=cell_w, cell_h=cell_h, - proj=None, embryo_id=emb.embryo_id, - cadence_color=COL_PANEL_HI, cadence_label="--", - stage=None) + _draw_cell( + canvas, + (x, y), + cell_w=cell_w, + cell_h=cell_h, + proj=None, + embryo_id=emb.embryo_id, + cadence_color=COL_PANEL_HI, + cadence_label="--", + stage=None, + ) else: fr = emb.frames[i] - _draw_cell(canvas, (x, y), cell_w=cell_w, cell_h=cell_h, - proj=cached_proj[emb.embryo_id], - embryo_id=emb.embryo_id, - cadence_color=fr.cadence_color, - cadence_label=fr.cadence_label, - stage=fr.predicted_stage) + _draw_cell( + canvas, + (x, y), + cell_w=cell_w, + cell_h=cell_h, + proj=cached_proj[emb.embryo_id], + embryo_id=emb.embryo_id, + cadence_color=fr.cadence_color, + cadence_label=fr.cadence_label, + stage=fr.predicted_stage, + ) frame_bgr = cv2.cvtColor(np.array(canvas), cv2.COLOR_RGB2BGR) @@ -820,43 +1011,78 @@ def render_grid_video(embryos: List[EmbryoData], out_path: Path, vp: VideoParams written += 1 writer.release() - logger.info("Grid: wrote %s (%d video frames, %d timeline ticks, codec=%s)", - real_out, written, len(timeline), codec) + logger.info( + "Grid: wrote %s (%d video frames, %d timeline ticks, codec=%s)", + real_out, + written, + len(timeline), + codec, + ) return real_out # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- -def main(argv: Optional[List[str]] = None) -> int: +def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--session", default=None, - help="Session folder name or partial id (default: latest)") - parser.add_argument("--storage", default="D:/Gently3", - help="Gently storage root (default: D:/Gently3)") - parser.add_argument("--out", default=None, - help="Output directory (default: /_recap)") - parser.add_argument("--speedup", type=float, default=600.0, - help="real_seconds rendered into one video second (default: 600)") + parser.add_argument( + "--session", + default=None, + help="Session folder name or partial id (default: latest)", + ) + parser.add_argument( + "--storage", + default="D:/Gently3", + help="Gently storage root (default: D:/Gently3)", + ) + parser.add_argument("--out", default=None, help="Output directory (default: /_recap)") + parser.add_argument( + "--speedup", + type=float, + default=600.0, + help="real_seconds rendered into one video second (default: 600)", + ) parser.add_argument("--fps", type=int, default=30, help="Playback FPS (default: 30)") - parser.add_argument("--min-frame-dt", type=float, default=0.06, - help="Min seconds per input frame (default: 0.06)") - parser.add_argument("--max-frame-dt", type=float, default=0.40, - help="Max seconds per input frame (default: 0.40)") - parser.add_argument("--no-perception", action="store_true", - help="Skip predicted-stage overlays") - parser.add_argument("--no-grid", action="store_true", - help="Skip the synchronized grid video") - parser.add_argument("--no-per-embryo", action="store_true", - help="Skip per-embryo videos") - parser.add_argument("--bursts", action="store_true", - help="Also render one MP4 per burst (one per request_id)") - parser.add_argument("--bursts-only", action="store_true", - help="Render burst MP4s only — skip timelapse + grid") - parser.add_argument("--burst-speedup", type=float, default=10.0, - help="Speedup for burst videos only (default: 10 — near-real-time)") - parser.add_argument("--view", choices=("a", "b", "both"), default="a", - help="Which diSPIM view to render (default: a)") + parser.add_argument( + "--min-frame-dt", + type=float, + default=0.06, + help="Min seconds per input frame (default: 0.06)", + ) + parser.add_argument( + "--max-frame-dt", + type=float, + default=0.40, + help="Max seconds per input frame (default: 0.40)", + ) + parser.add_argument( + "--no-perception", action="store_true", help="Skip predicted-stage overlays" + ) + parser.add_argument("--no-grid", action="store_true", help="Skip the synchronized grid video") + parser.add_argument("--no-per-embryo", action="store_true", help="Skip per-embryo videos") + parser.add_argument( + "--bursts", + action="store_true", + help="Also render one MP4 per burst (one per request_id)", + ) + parser.add_argument( + "--bursts-only", + action="store_true", + help="Render burst MP4s only — skip timelapse + grid", + ) + parser.add_argument( + "--burst-speedup", + type=float, + default=10.0, + help="Speedup for burst videos only (default: 10 — near-real-time)", + ) + parser.add_argument( + "--view", + choices=("a", "b", "both"), + default="a", + help="Which diSPIM view to render (default: a)", + ) parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args(argv) @@ -884,15 +1110,19 @@ def main(argv: Optional[List[str]] = None) -> int: logger.error("No embryos found under %s/embryos", session_dir) return 1 - embryos: List[EmbryoData] = [] + embryos: list[EmbryoData] = [] for ed in embryo_dirs: emb = load_embryo(ed, use_perception=not args.no_perception) if emb is None: logger.warning("Skipping %s (no projections)", ed.name) continue - logger.info("Loaded %s: %d frames (%s → %s)", emb.embryo_id, len(emb.frames), - emb.frames[0].acquired_at.isoformat(timespec="seconds"), - emb.frames[-1].acquired_at.isoformat(timespec="seconds")) + logger.info( + "Loaded %s: %d frames (%s → %s)", + emb.embryo_id, + len(emb.frames), + emb.frames[0].acquired_at.isoformat(timespec="seconds"), + emb.frames[-1].acquired_at.isoformat(timespec="seconds"), + ) embryos.append(emb) if not embryos: @@ -909,12 +1139,12 @@ def main(argv: Optional[List[str]] = None) -> int: render_timelapse = not args.bursts_only if render_timelapse and not args.no_per_embryo: for emb in embryos: - render_embryo_video(emb, out_dir / f"{emb.embryo_id}.mp4", vp, - session_label=session_label) + render_embryo_video( + emb, out_dir / f"{emb.embryo_id}.mp4", vp, session_label=session_label + ) if render_timelapse and not args.no_grid: - render_grid_video(embryos, out_dir / "grid.mp4", vp, - session_label=session_label) + render_grid_video(embryos, out_dir / "grid.mp4", vp, session_label=session_label) if args.bursts or args.bursts_only: bursts = discover_bursts(session_dir) @@ -929,13 +1159,20 @@ def main(argv: Optional[List[str]] = None) -> int: min_frame_dt_s=vp.min_frame_dt_s, max_frame_dt_s=max(vp.max_frame_dt_s, 0.6), ) - logger.info("Rendering %d burst(s) at %.0fx speedup", - len(bursts), args.burst_speedup) + logger.info( + "Rendering %d burst(s) at %.0fx speedup", + len(bursts), + args.burst_speedup, + ) for burst in bursts: # request_id already contains the embryo_id; uuid8 suffix keeps it unique. out_name = f"{burst.request_id}.mp4" - render_burst_video(burst, burst_out_dir / out_name, burst_vp, - session_label=session_label) + render_burst_video( + burst, + burst_out_dir / out_name, + burst_vp, + session_label=session_label, + ) logger.info("Done. Videos written to %s", out_dir) return 0 diff --git a/scripts/regenerate_examples_three_view.py b/scripts/regenerate_examples_three_view.py index aa653ee2..bacda92a 100644 --- a/scripts/regenerate_examples_three_view.py +++ b/scripts/regenerate_examples_three_view.py @@ -11,18 +11,19 @@ python scripts/regenerate_examples_three_view.py """ +# Add parent to path for imports +import sys from pathlib import Path + import numpy as np from PIL import Image -# Add parent to path for imports -import sys sys.path.insert(0, str(Path(__file__).parent.parent)) from gently.core.imaging import ( - projection_three_view, - compute_crop_bounds, apply_crop_bounds, + compute_crop_bounds, + projection_three_view, ) STAGES_PATH = Path(__file__).parent.parent / "gently" / "examples" / "stages" @@ -67,7 +68,7 @@ def regenerate_stage(stage: str) -> bool: if volume.ndim == 3: z_depth, height, width = volume.shape if width > height * 2: - volume = volume[:, :, :width // 2] + volume = volume[:, :, : width // 2] print(f" Extracted View A: {volume.shape}") # Auto-crop to embryo region @@ -81,8 +82,8 @@ def regenerate_stage(stage: str) -> bool: # Save as JPEG pil_img = Image.fromarray(three_view_img) - if pil_img.mode != 'RGB': - pil_img = pil_img.convert('RGB') + if pil_img.mode != "RGB": + pil_img = pil_img.convert("RGB") output_path = stage_dir / "three_view.jpg" pil_img.save(output_path, quality=90) diff --git a/scripts/regenerate_projections.py b/scripts/regenerate_projections.py index 7b6241ff..1e41edf4 100644 --- a/scripts/regenerate_projections.py +++ b/scripts/regenerate_projections.py @@ -22,8 +22,8 @@ if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) -from gently.settings import settings -from gently.core.imaging import load_volume, generate_jpeg_projection +from gently.core.imaging import generate_jpeg_projection, load_volume # noqa: E402 +from gently.settings import settings # noqa: E402 def _regen_folder(folder: Path, only_embryo: str | None = None) -> int: @@ -83,7 +83,8 @@ def regenerate_all() -> int: print(f"No sessions dir at {sessions}") return 0 folders = sorted( - p for p in sessions.iterdir() + p + for p in sessions.iterdir() if p.is_dir() and any((p / "embryos").glob("*/volumes/t*.tif")) ) print(f"Regenerating projections for {len(folders)} session(s) with volumes...") diff --git a/scripts/replay_session.py b/scripts/replay_session.py index f8ffe208..ec2b02c4 100644 --- a/scripts/replay_session.py +++ b/scripts/replay_session.py @@ -76,7 +76,9 @@ def main(argv=None) -> int: help="Print event-type histogram, don't replay", ) parser.add_argument( - "-v", "--verbose", action="store_true", + "-v", + "--verbose", + action="store_true", help="Verbose logging", ) args = parser.parse_args(argv) @@ -88,7 +90,7 @@ def main(argv=None) -> int: from gently.core.event_bus import EventBus from gently.core.file_store import FileStore - from gently.eval import EventReplay, DecisionLog, ShadowRunner, NoOpCandidate + from gently.eval import DecisionLog, EventReplay, NoOpCandidate, ShadowRunner root = args.root or os.environ.get("GENTLY_STORAGE_PATH", "D:/Gently3") store = FileStore(root=Path(root)) @@ -135,7 +137,9 @@ def main(argv=None) -> int: try: emitted = rep.replay( - bus, real_time=args.real_time, time_scale=args.time_scale, + bus, + real_time=args.real_time, + time_scale=args.time_scale, ) print(f"Replayed {emitted} events from session {session['session_id']}") finally: diff --git a/scripts/stage_annotator.py b/scripts/stage_annotator.py index 07ce6dbd..6171d603 100644 --- a/scripts/stage_annotator.py +++ b/scripts/stage_annotator.py @@ -10,24 +10,24 @@ """ import argparse +import base64 +import io import json import logging import os import signal +import socketserver import subprocess import sys -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse -import base64 -import io -import socketserver import numpy as np import tifffile from PIL import Image -logging.basicConfig(level=logging.INFO, format='%(message)s') +logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger(__name__) # Configuration @@ -67,7 +67,7 @@ def load_and_project(tif_path: Path) -> np.ndarray: # Check if width is roughly 4x height (dual-view format) if width > height * 2: # Extract View A (left half) - shape (Z, Y, X/2) - view_a = vol[:, :, :width // 2] + view_a = vol[:, :, : width // 2] else: view_a = vol @@ -122,8 +122,8 @@ def image_to_base64(img: np.ndarray) -> str: pil_img = Image.fromarray(img) # Convert to RGB for JPEG (grayscale works but RGB is more compatible) - if pil_img.mode != 'RGB': - pil_img = pil_img.convert('RGB') + if pil_img.mode != "RGB": + pil_img = pil_img.convert("RGB") buffer = io.BytesIO() pil_img.save(buffer, format="JPEG", quality=90) @@ -162,7 +162,7 @@ class AppState: class AnnotatorHandler(BaseHTTPRequestHandler): """HTTP handler for annotation interface.""" - protocol_version = 'HTTP/1.1' + protocol_version = "HTTP/1.1" def log_message(self, format, *args): pass # Suppress HTTP logging @@ -205,12 +205,12 @@ def do_POST(self): if parsed.path == "/api/save_markers": # Read POST body - content_length = int(self.headers.get('Content-Length', 0)) + content_length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(content_length) data = json.loads(body.decode()) # Get labels from client - labels = data.get('labels', {}) + labels = data.get("labels", {}) count = self.save_with_labels(labels) self.send_json({"ok": True, "saved": count}) else: @@ -317,7 +317,8 @@ def send_html(self):
+ + +
@@ -807,6 +810,7 @@

Properties

+ From 0daebda8dd22d55d19c72a7ea2881603e699c5b1 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:35:08 +0530 Subject: [PATCH 172/314] fix(temperature): idempotent graph init + theme setpoint color + label baseline --- gently/ui/web/static/css/main.css | 8 +++++++- gently/ui/web/static/js/temperature-graph.js | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index cf878140..0b115fe6 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -23,6 +23,9 @@ --accent-pink: #f472b6; --accent-cyan: #22d3ee; + /* Temperature graph colors */ + --temp-setpoint-color: #f59e0b; + /* Gradients for modern feel */ --gradient-primary: linear-gradient(135deg, #60a5fa 0%, #c084fc 100%); --gradient-success: linear-gradient(135deg, #4ade80 0%, #22d3ee 100%); @@ -63,6 +66,9 @@ --accent-pink: #ec4899; --accent-cyan: #06b6d4; + /* Temperature graph colors */ + --temp-setpoint-color: #f59e0b; + /* Gradients */ --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #a855f7 100%); --gradient-success: linear-gradient(135deg, #22c55e 0%, #06b6d4 100%); @@ -9752,7 +9758,7 @@ body.modal-open { } /* Dashed line: setpoint (stepped) */ .temp-setpoint { - stroke: #f59e0b; + stroke: var(--temp-setpoint-color); stroke-width: 1.5; stroke-dasharray: 5 3; stroke-linejoin: round; diff --git a/gently/ui/web/static/js/temperature-graph.js b/gently/ui/web/static/js/temperature-graph.js index 4679c970..86129902 100644 --- a/gently/ui/web/static/js/temperature-graph.js +++ b/gently/ui/web/static/js/temperature-graph.js @@ -21,6 +21,7 @@ const TemperatureGraph = (() => { let _session = "current"; function init(container, sessionId) { + ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); _root = container; _session = sessionId || "current"; _samples = []; @@ -102,7 +103,7 @@ const TemperatureGraph = (() => { const lbl = document.createElementNS(SVGNS, "text"); lbl.setAttribute("x", pad.left - 3); - lbl.setAttribute("y", y + 3.5); + lbl.setAttribute("y", y); lbl.setAttribute("class", "temp-grid-label"); lbl.textContent = v.toFixed(1); gridG.appendChild(lbl); From 1f6c7fa63d0ab1fe0f39feabf2b72e31e7ad2371 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:35:08 +0530 Subject: [PATCH 173/314] docs(temperature): correct EventType auto() example in plan --- docs/superpowers/plans/2026-06-27-temperature-interface.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-06-27-temperature-interface.md b/docs/superpowers/plans/2026-06-27-temperature-interface.md index ab02b895..cfdaa563 100644 --- a/docs/superpowers/plans/2026-06-27-temperature-interface.md +++ b/docs/superpowers/plans/2026-06-27-temperature-interface.md @@ -134,10 +134,10 @@ Expected: FAIL — `AttributeError: TEMPERATURE_UPDATE` - [ ] **Step 3: Write minimal implementation** -In `gently/core/event_bus.py`, add the member alongside the other `EventType` values (mirror how `DEVICE_STATE_UPDATE` at `:86` is declared — match the existing value style, e.g. `TEMPERATURE_UPDATE = "TEMPERATURE_UPDATE"`): +In `gently/core/event_bus.py`, add the member alongside the other `EventType` values, **matching the existing `auto()` style** (`DEVICE_STATE_UPDATE = auto()` at `:86`). The wire protocol serializes `event.event_type.name` (`Event.to_dict` at event_bus.py:232; server.py:377/419), so the browser receives the string `"TEMPERATURE_UPDATE"` regardless — which is what the frontend (Task 7) subscribes to. The test must assert `.name == "TEMPERATURE_UPDATE"` (NOT `.value`, which is an `auto()` int): ```python - TEMPERATURE_UPDATE = "TEMPERATURE_UPDATE" + TEMPERATURE_UPDATE = auto() # high-volume telemetry from the temperature controller ``` And add it to the high-volume set so it is not retained in history (next to `DEVICE_STATE_UPDATE` in `_NO_HISTORY_TYPES` near `:187`): From 60e63dd2380b3f11a597e46ea6f9825c6f6c10a6 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sat, 27 Jun 2026 22:46:49 +0530 Subject: [PATCH 174/314] fix(temperature): events-tab exclusion, burst-stamp test, invalidate stale latest, tolerant log read --- gently/app/temperature_sampler.py | 3 ++ gently/core/file_store.py | 20 +++++++- gently/ui/web/static/js/websocket.js | 3 +- tests/test_temperature_sampler.py | 34 +++++++++++++ tests/test_temperature_stamp.py | 72 ++++++++++++++++++++++++++-- tests/test_temperature_store.py | 24 ++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py index 5ccec94d..e762534c 100644 --- a/gently/app/temperature_sampler.py +++ b/gently/app/temperature_sampler.py @@ -70,12 +70,15 @@ async def _run(self) -> None: async def _tick(self, bus) -> None: session_id = self._session_id_getter() if not session_id: + self.latest = None return resp = await self._microscope.get_temperature() if not resp or not resp.get("success", True): + self.latest = None return water = resp.get("temperature_c") if water is None: + self.latest = None return sample = { "t": _now_iso(), diff --git a/gently/core/file_store.py b/gently/core/file_store.py index ae668232..e7fb2174 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -450,11 +450,27 @@ 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. + """ sd = self._session_dir(session_id) if sd is None: return [] - rows = _read_jsonl(sd / "temperature.jsonl") + path = sd / "temperature.jsonl" + if not path.exists(): + return [] + rows = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + pass # truncated or corrupt line — skip if since is not None: rows = [r for r in rows if str(r.get("t", "")) >= since] return rows diff --git a/gently/ui/web/static/js/websocket.js b/gently/ui/web/static/js/websocket.js index 069724a2..bcbef78f 100644 --- a/gently/ui/web/static/js/websocket.js +++ b/gently/ui/web/static/js/websocket.js @@ -103,7 +103,8 @@ function handleMessage(msg) { // per event and would lag every other handler), but still emit to the // 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 !== 'BOTTOM_CAMERA_FRAME' && + msg.event_type !== 'TEMPERATURE_UPDATE') { handleFullEvent({ event_type: msg.event_type, data: msg.data, diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py index 3e208069..4c79bf76 100644 --- a/tests/test_temperature_sampler.py +++ b/tests/test_temperature_sampler.py @@ -71,3 +71,37 @@ def test_temperature_stamp_shapes(): 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"}) + bus = EventBus() + + # Successful first tick. + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + assert s.latest is not None and s.latest["water_c"] == 28.4 + + # Second tick with no active session — latest must be cleared. + s._session_id_getter = lambda: None + await s._tick(bus) + assert s.latest is None + + +async def test_stale_latest_cleared_on_poll_failure(file_store): + """After a successful tick, a failing poll resets latest to None.""" + sid = file_store.create_session(str(uuid.uuid4()), name="s") + bus = EventBus() + + # Successful first tick. + 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 + + # Swap to a scope that returns falsy (empty dict) — simulates device dropout. + s._microscope = FakeScope({}) + await s._tick(bus) + assert s.latest is None diff --git a/tests/test_temperature_stamp.py b/tests/test_temperature_stamp.py index 57dfb603..4784880b 100644 --- a/tests/test_temperature_stamp.py +++ b/tests/test_temperature_stamp.py @@ -1,9 +1,11 @@ -"""Tests for Task 6: acquisition temperature stamp. +"""Tests for Task 6 & Task 7: acquisition temperature stamp. -Two concerns: +Concerns: 1. temperature_stamp(None) returns None (pure helper, no I/O). 2. Volume metadata round-trip: a stamp injected via put_volume(metadata={"temperature": stamp}) lands correctly in the sidecar YAML and is readable via get_volume_meta. +3. Burst stamp: _persist_burst_to_disk injects temperature into both burst.yaml and + each frame's .meta.yaml when a temperature_provider is supplied. Confirmed from file_store.py: - create_session(session_id, name=None, ...) — session_id is the first positional arg. @@ -12,15 +14,17 @@ under the "metadata" key in the sidecar YAML. - get_volume_meta(session_id, embryo_id, timepoint) — added by Task 6; reads sidecar YAML. -Note: tifffile is not installed in the test environment; it is mocked so put_volume -writes only the sidecar YAML (the contract under test) without the TIFF. +Note: tifffile is not installed in the test environment; it is mocked so put_volume / +_persist_burst_to_disk write only the YAML files (the contracts under test). """ import sys import uuid +from datetime import datetime, timezone from unittest.mock import MagicMock, patch import numpy as np +import yaml from gently.app.temperature_sampler import temperature_stamp @@ -52,3 +56,63 @@ def test_volume_metadata_carries_temperature(file_store): meta = file_store.get_volume_meta(sid, emb, 0) assert meta["metadata"]["temperature"]["water_c"] == 28.4 + + +def test_burst_stamp_writes_temperature(file_store): + """_persist_burst_to_disk stamps temperature into burst.yaml and frame .meta.yaml.""" + from gently.app.orchestration.exclusive import _persist_burst_to_disk + + sid = file_store.create_session(str(uuid.uuid4()), name="burst-stamp-test") + embryo_id = "embryo_burst" + file_store.register_embryo(sid, embryo_id, position_x=0.0, position_y=0.0) + + # Minimal orchestrator stand-in: only _store and _session_id are needed. + orch = MagicMock() + orch._store = file_store + orch._session_id = sid + + # Minimal embryo stand-in. + embryo = MagicMock() + embryo.stage_position = {"x": 0.0, "y": 0.0} + embryo.num_slices = 2 + embryo.exposure_ms = 50.0 + + 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}): + burst_dir = _persist_burst_to_disk( + orchestrator=orch, + embryo=embryo, + embryo_id=embryo_id, + request_id="req-burst-001", + mode="1hz", + frames_requested=1, + frames_data=frames_data, + loop_start=datetime(2026, 6, 27, 10, 0, 0, tzinfo=timezone.utc), + duration_s=1.0, + sustained_hz=1.0, + galvo_amplitude=100.0, + galvo_center=0.0, + piezo_amplitude=50.0, + piezo_center=0.0, + laser_power_488_pct=10.0, + temperature_provider=lambda: { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + }, + ) + + assert burst_dir is not None, "_persist_burst_to_disk returned None — check store/session setup" + + # burst.yaml must have a top-level temperature with water_c == 28.4. + manifest = yaml.safe_load((burst_dir / "burst.yaml").read_text()) + assert manifest["temperature"]["water_c"] == 28.4 + + # Frame meta.yaml must have metadata.temperature.water_c == 28.4. + frame_meta = yaml.safe_load((burst_dir / "t0001.meta.yaml").read_text()) + assert frame_meta["metadata"]["temperature"]["water_c"] == 28.4 diff --git a/tests/test_temperature_store.py b/tests/test_temperature_store.py index b51da792..9e5d5fbe 100644 --- a/tests/test_temperature_store.py +++ b/tests/test_temperature_store.py @@ -32,3 +32,27 @@ def test_read_since_filters(file_store): def test_read_unknown_session_is_empty(file_store): """Test that reading from a non-existent session returns empty list.""" assert file_store.read_temperature_log("does-not-exist") == [] + + +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"}) + + # 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" + with open(log_path, "a", encoding="utf-8") as f: + f.write('{"t": "2026-06-27T10:00:03+00:00", "water_c":\n') + + # read_temperature_log must return only the two valid rows without raising. + rows = file_store.read_temperature_log(sid) + assert len(rows) == 2 + assert [r["water_c"] for r in rows] == [28.1, 28.2] From a79a9575ea7a880b7551075f281583902805eb51 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 06:40:22 +0530 Subject: [PATCH 175/314] fix(temperature): root-level temp graph colors for portability The water trace, gridlines, and tick labels used .devices-container-map-scoped --map-* vars, so the graph rendered blank outside that container. Promote them to :root-level --temp-* vars (mirroring --temp-setpoint-color) so the component is reusable when manual mode (sub-project B) re-mounts it. Same colors in-app. --- gently/ui/web/static/css/main.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 0b115fe6..36124bd9 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -23,8 +23,12 @@ --accent-pink: #f472b6; --accent-cyan: #22d3ee; - /* Temperature graph colors */ + /* Temperature graph colors (root-level so the graph is portable outside + .devices-container-map — e.g. when reused in manual mode) */ --temp-setpoint-color: #f59e0b; + --temp-water-color: #22d3ee; + --temp-grid-line-color: rgba(212, 221, 232, 0.20); + --temp-grid-label-color: #6a778a; /* Gradients for modern feel */ --gradient-primary: linear-gradient(135deg, #60a5fa 0%, #c084fc 100%); @@ -66,8 +70,11 @@ --accent-pink: #ec4899; --accent-cyan: #06b6d4; - /* Temperature graph colors */ + /* Temperature graph colors (root-level; see dark block) */ --temp-setpoint-color: #f59e0b; + --temp-water-color: #0e7490; + --temp-grid-line-color: rgba(29, 43, 58, 0.22); + --temp-grid-label-color: #6b7280; /* Gradients */ --gradient-primary: linear-gradient(135deg, #3b82f6 0%, #a855f7 100%); @@ -9751,7 +9758,7 @@ body.modal-open { } /* Solid line: water temperature */ .temp-water { - stroke: var(--map-accent); + stroke: var(--temp-water-color); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; @@ -9767,12 +9774,12 @@ body.modal-open { } /* Y-axis reference gridlines */ .temp-grid-line { - stroke: var(--map-ink-faint); + stroke: var(--temp-grid-line-color); stroke-width: 0.75; } /* Y-axis tick labels */ .temp-grid-label { - fill: var(--map-ink-mute); + fill: var(--temp-grid-label-color); font-size: 8px; font-family: ui-monospace, monospace; text-anchor: end; From 339cc7ce5e0ec2a80659dda19f1a447cec4c7dec Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:06:17 +0530 Subject: [PATCH 176/314] docs(manual-mode): design spec for SPIM single-slice live view (B1) --- ...2026-06-28-manual-mode-live-view-design.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md 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..0ac1365a --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-manual-mode-live-view-design.md @@ -0,0 +1,190 @@ +# 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. From 2041c3aebda084d98c28e64b2c14811e02ea2903 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:24:13 +0530 Subject: [PATCH 177/314] docs(manual-mode): B1 plan (7 tasks) + ASIdiSPIM two-camera model appendix --- .../plans/2026-06-28-manual-mode-live-view.md | 728 ++++++++++++++++++ ...2026-06-28-manual-mode-live-view-design.md | 28 + 2 files changed, 756 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-manual-mode-live-view.md 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 index 0ac1365a..3d846a36 100644 --- 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 @@ -188,3 +188,31 @@ 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. From ec917e06c2cd9063bf9f3292ef32b708be408b1a Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:27:24 +0530 Subject: [PATCH 178/314] feat(manual-mode): add LIGHTSHEET_FRAME event type + frontend exclusion --- gently/core/event_bus.py | 2 ++ gently/ui/web/static/js/websocket.js | 3 ++- tests/test_lightsheet_event.py | 22 ++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/test_lightsheet_event.py 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/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/tests/test_lightsheet_event.py b/tests/test_lightsheet_event.py new file mode 100644 index 00000000..20271403 --- /dev/null +++ b/tests/test_lightsheet_event.py @@ -0,0 +1,22 @@ +"""Tests for LIGHTSHEET_FRAME event type""" + +from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES + + +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"}] From 0ec20c2c7ebe623702ac09244f7df7c4131ca5ab Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:37:27 +0530 Subject: [PATCH 179/314] feat(manual-mode): device-layer lightsheet live streamer (continuous sequence acquisition) --- gently/hardware/dispim/device_layer.py | 210 ++++++++++++++++++++++++- tests/test_lightsheet_streamer.py | 87 ++++++++++ 2 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 tests/test_lightsheet_streamer.py diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 55e8225d..c1331904 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -168,6 +168,16 @@ 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 + # Plans that hold MMCore for long performance-critical work. # Anything in this set runs with state polling paused. self._heavy_plans = frozenset( @@ -845,7 +855,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 +866,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 +881,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 +908,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 +972,187 @@ 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).""" + 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 + 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 = {} + + 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 # ========================================================================= @@ -2806,6 +3006,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/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py new file mode 100644 index 00000000..78b79c4b --- /dev/null +++ b/tests/test_lightsheet_streamer.py @@ -0,0 +1,87 @@ +# 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 +_bs.RunEngine = MagicMock(name="RunEngine") + +import asyncio, numpy as np, pytest +from gently.hardware.dispim.device_layer import DeviceLayerServer + + +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(), + } + 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 From 2c2cc9514af6d14786b773725dd5336bccd7ec7d Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:44:49 +0530 Subject: [PATCH 180/314] feat(manual-mode): client stream_lightsheet + set_lightsheet_live_params --- gently/hardware/dispim/client.py | 50 ++++++++++++++++++++++++++++++++ tests/test_lightsheet_client.py | 25 ++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/test_lightsheet_client.py diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 56a9a33c..3c1aced2 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -954,6 +954,56 @@ 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/tests/test_lightsheet_client.py b/tests/test_lightsheet_client.py new file mode 100644 index 00000000..75c0d392 --- /dev/null +++ b/tests/test_lightsheet_client.py @@ -0,0 +1,25 @@ +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) From 8b86000fdeb6cb7470e9138f24736e8cb1b0882f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:48:37 +0530 Subject: [PATCH 181/314] feat(manual-mode): LightSheetStreamMonitor bridge + agent wiring --- gently/app/agent.py | 18 ++++++ gently/app/lightsheet_monitor.py | 98 ++++++++++++++++++++++++++++++++ tests/test_lightsheet_monitor.py | 32 +++++++++++ 3 files changed, 148 insertions(+) create mode 100644 gently/app/lightsheet_monitor.py create mode 100644 tests/test_lightsheet_monitor.py diff --git a/gently/app/agent.py b/gently/app/agent.py index afe2e649..02ec700a 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 ===== @@ -859,6 +861,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 +879,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/tests/test_lightsheet_monitor.py b/tests/test_lightsheet_monitor.py new file mode 100644 index 00000000..f5cdf797 --- /dev/null +++ b/tests/test_lightsheet_monitor.py @@ -0,0 +1,32 @@ +""" +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.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) + + +@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 From d641fa401e1d06d60d4e453aa134d1a1dcef3f91 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 12:53:12 +0530 Subject: [PATCH 182/314] feat(manual-mode): require_control proxy routes for lightsheet live + illumination + acquire --- gently/ui/web/routes/data.py | 175 +++++++++++++++++++++++++ tests/test_lightsheet_routes.py | 218 ++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 tests/test_lightsheet_routes.py diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index a609526a..4d9f5be8 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -412,6 +412,181 @@ 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(): + """Force the 488 nm laser off. + + Confirmed signature: set_laser_power(wavelength: int, pct: float). + Calls set_laser_power(488, 0) to drive the 488 nm line to 0 %. + """ + 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) + except Exception as exc: + logger.exception("Laser off command failed") + 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 + """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") + 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}.""" + 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: + 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}.""" + 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: + 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/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py new file mode 100644 index 00000000..ed68552f --- /dev/null +++ b/tests/test_lightsheet_routes.py @@ -0,0 +1,218 @@ +"""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 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): + """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 — confirmed signature: set_laser_power(wavelength: int, pct: float) +# --------------------------------------------------------------------------- + +def test_laser_off_calls_set_laser_power_488_0(): + """laser/off must call set_laser_power(488, 0) to force 488 nm line off.""" + client = MagicMock() + client.set_laser_power = AsyncMock(return_value={"success": True}) + r = _app(client=client).post("/api/devices/laser/off") + assert r.status_code == 200 + client.set_laser_power.assert_awaited_once_with(488, 0) + + +# --------------------------------------------------------------------------- +# 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(): + 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 + ) + + +# --------------------------------------------------------------------------- +# acquire/volume +# --------------------------------------------------------------------------- + +def test_acquire_volume_forwards(): + 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) + + +# --------------------------------------------------------------------------- +# 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 From 84a659dc53421d5f88cf1b6a3796fd41ee0d6708 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:00:34 +0530 Subject: [PATCH 183/314] fix(manual-mode): laser/off uses Laser ALL OFF config + expose laser presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace set_laser_power(488, 0) with set_laser_config("ALL OFF") in the laser/off proxy route so the PLogic gate turns off every laser line, not just the 488 nm setpoint (spec §2.7 brightfield safety). Add device-layer handlers handle_set_laser_config / handle_get_laser_configs (POST /api/laser/config, GET /api/laser/configs) calling DiSPIMLightSource.set() / ._get_available_configs() on the laser_control device (group_name="Laser"). Wire matching client methods set_laser_config / get_laser_configs. Add unguarded GET /api/devices/laser/configs proxy route. --- gently/hardware/dispim/client.py | 23 +++++++++ gently/hardware/dispim/device_layer.py | 66 ++++++++++++++++++++++++++ gently/ui/web/routes/data.py | 26 ++++++++-- tests/test_lightsheet_client.py | 37 +++++++++++++++ tests/test_lightsheet_routes.py | 45 ++++++++++++++++-- 5 files changed, 188 insertions(+), 9 deletions(-) diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index 3c1aced2..1437a8a8 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") diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index c1331904..6dc97dbb 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -1995,6 +1995,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: @@ -2987,6 +3051,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) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 4d9f5be8..625fb449 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -507,20 +507,38 @@ async def led_set(payload: dict = Body(...)): # noqa: B008 @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) async def laser_off(): - """Force the 488 nm laser off. + """Gate ALL laser lines off via the Laser config group "ALL OFF" preset. - Confirmed signature: set_laser_power(wavelength: int, pct: float). - Calls set_laser_power(488, 0) to drive the 488 nm line to 0 %. + 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_power(488, 0) + 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}.""" diff --git a/tests/test_lightsheet_client.py b/tests/test_lightsheet_client.py index 75c0d392..cf4a9538 100644 --- a/tests/test_lightsheet_client.py +++ b/tests/test_lightsheet_client.py @@ -23,3 +23,40 @@ def test_stream_lightsheet_is_async_generator(): 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_routes.py b/tests/test_lightsheet_routes.py index ed68552f..4d3cecd9 100644 --- a/tests/test_lightsheet_routes.py +++ b/tests/test_lightsheet_routes.py @@ -127,16 +127,51 @@ def test_led_set_forwards(): # --------------------------------------------------------------------------- -# laser/off — confirmed signature: set_laser_power(wavelength: int, pct: float) +# laser/off — spec §2.7: must use Laser ALL OFF config, not power setpoint # --------------------------------------------------------------------------- -def test_laser_off_calls_set_laser_power_488_0(): - """laser/off must call set_laser_power(488, 0) to force 488 nm line off.""" +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_power = AsyncMock(return_value={"success": True}) + 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_power.assert_awaited_once_with(488, 0) + 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 # --------------------------------------------------------------------------- From 481d8bbd5770b1f147f88068c8184bad5bcb7bc1 Mon Sep 17 00:00:00 2001 From: subindevs <36504048+subindevs@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:40:47 +0100 Subject: [PATCH 184/314] mypy: clear final cluster + remove the last overrides (#49) Clears the remaining mypy errors in the last exempted modules (scripts, dataset, benchmarks/perception, diagnostics, core/store) and removes the entire [[tool.mypy.overrides]] block from pyproject.toml. `mypy .` is now clean across all 297 source files with no per-module exemptions. Notable fixes among the final 11 errors: - explorer_server: key session_embryo_counts by (session_id, embryo_id) tuple instead of str - launch_viz_server: pass the Path half of the (timestamp, Path) tuple returned by _discover_volumes to project_volume - projection_explorer: declare ExplorerHandler.session_manager as a required class var (assigned before serving) instead of `= None` - metrics: skip None ground-truth-stage keys in tool_use_by_stage - auto_label_examples: annotate save_data as dict[str, Any] Deletes three dead files that imported removed/never-existent symbols and could not run: - examples/example_dispim_workflows.py (AutofocusConfig/CalibrationConfig and dispim_* plans removed in an earlier refactor) - tests/test_text_tool_call_extraction.py (_extract_text_tool_calls never existed in committed history) - tests/validate_recursive_autofocus.py (recursive_focus_single_round never existed) Co-Authored-By: Claude Opus 4.8 --- benchmarks/perception/live_viewer.py | 2 +- benchmarks/perception/metrics.py | 8 +- benchmarks/perception/testset.py | 7 +- diagnostics/benchmark_gentlystore_fps.py | 2 +- diagnostics/benchmark_volume_fps.py | 4 +- diagnostics/plot_benchmark_results.py | 6 +- diagnostics/switchbot_webgui.py | 5 +- examples/example_dispim_workflows.py | 512 ----------------------- gently/core/store.py | 24 +- gently/dataset/aggregator.py | 34 +- gently/dataset/embryo_dataset.py | 14 +- gently/dataset/explorer_server.py | 12 +- pyproject.toml | 30 -- scripts/auto_label_examples.py | 5 +- scripts/extract_stage_examples.py | 7 +- scripts/gemini_stage_test.py | 17 +- scripts/launch_viz_server.py | 3 +- scripts/projection_explorer.py | 17 +- scripts/replay_session.py | 1 + scripts/stage_annotator.py | 8 +- tests/test_campaign_coordination.py | 2 +- tests/test_text_tool_call_extraction.py | 76 ---- tests/validate_recursive_autofocus.py | 201 --------- 23 files changed, 110 insertions(+), 887 deletions(-) delete mode 100644 examples/example_dispim_workflows.py delete mode 100644 tests/test_text_tool_call_extraction.py delete mode 100644 tests/validate_recursive_autofocus.py diff --git a/benchmarks/perception/live_viewer.py b/benchmarks/perception/live_viewer.py index 7b0f7af7..0a311786 100644 --- a/benchmarks/perception/live_viewer.py +++ b/benchmarks/perception/live_viewer.py @@ -46,7 +46,7 @@ "verification_active": False, } is_paused: bool = False -pause_event: asyncio.Event = None # Will be initialized on startup +pause_event: asyncio.Event | None = None # Will be initialized on startup async def broadcast(message: dict): diff --git a/benchmarks/perception/metrics.py b/benchmarks/perception/metrics.py index f0578ea1..97d5c0f9 100644 --- a/benchmarks/perception/metrics.py +++ b/benchmarks/perception/metrics.py @@ -124,8 +124,8 @@ def compute_metrics(report: "BenchmarkReport") -> PerceptionMetrics: metrics.adjacent_accuracy = adjacent_correct / len(all_preds) # Per-stage accuracy - stage_correct = defaultdict(int) - stage_total = defaultdict(int) + stage_correct: dict = defaultdict(int) + stage_total: dict = defaultdict(int) for p in all_preds: gt = p.ground_truth_stage @@ -138,7 +138,7 @@ def compute_metrics(report: "BenchmarkReport") -> PerceptionMetrics: metrics.stage_accuracy[stage] = stage_correct[stage] / stage_total[stage] # Confusion matrix - confusion = defaultdict(lambda: defaultdict(int)) + confusion: dict = defaultdict(lambda: defaultdict(int)) for p in all_preds: confusion[p.ground_truth_stage][p.predicted_stage] += 1 @@ -201,6 +201,8 @@ def compute_metrics(report: "BenchmarkReport") -> PerceptionMetrics: stage_tool_calls[p.ground_truth_stage].append(p.tool_calls) for stage, calls in stage_tool_calls.items(): + if stage is None: + continue metrics.tool_use_by_stage[stage] = sum(calls) / len(calls) # Accuracy with vs without tools diff --git a/benchmarks/perception/testset.py b/benchmarks/perception/testset.py index 075eccdb..c3514ffe 100644 --- a/benchmarks/perception/testset.py +++ b/benchmarks/perception/testset.py @@ -10,14 +10,15 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path +from typing import Any import numpy as np from .ground_truth import GroundTruth # Lazy imports -tifffile = None -PIL_Image = None +tifffile: Any = None +PIL_Image: Any = None def _ensure_dependencies(): @@ -64,7 +65,7 @@ def _discover_volumes( ) # Deduplicate (flat + recursive may overlap) tif_files = list({f.resolve(): f for f in tif_files}.values()) - embryo_volumes = {} + embryo_volumes: dict = {} for f in tif_files: parts = f.stem.split("_") diff --git a/diagnostics/benchmark_gentlystore_fps.py b/diagnostics/benchmark_gentlystore_fps.py index 3e8188a7..2621e2bb 100644 --- a/diagnostics/benchmark_gentlystore_fps.py +++ b/diagnostics/benchmark_gentlystore_fps.py @@ -487,7 +487,7 @@ def print_overhead_analysis(results: list[BenchmarkResult]): """Print overhead analysis comparing FileStore to raw TIFF.""" from collections import defaultdict - groups = defaultdict(dict) + groups: dict = defaultdict(dict) for r in results: groups[r.num_slices][r.approach] = r diff --git a/diagnostics/benchmark_volume_fps.py b/diagnostics/benchmark_volume_fps.py index 381e3697..9c070c88 100644 --- a/diagnostics/benchmark_volume_fps.py +++ b/diagnostics/benchmark_volume_fps.py @@ -281,7 +281,7 @@ def acquire_volume_raw( t0 = time.perf_counter() core.setProperty(GALVO_DEVICE, "SPIMState", "Running") - images = [] if save_dir else None + images: list | None = [] if save_dir else None count = 0 timeout_s = max(num_slices * 0.05 * 2, 10.0) # generous timeout t_start = time.time() @@ -1010,7 +1010,7 @@ def print_summary(results: list[BenchmarkResult]): """Print overhead analysis comparing ophyd and ophyd_burst vs raw.""" from collections import defaultdict - groups = defaultdict(dict) + groups: dict = defaultdict(dict) for r in results: groups[(r.num_slices, r.exposure_ms)][r.approach] = r diff --git a/diagnostics/plot_benchmark_results.py b/diagnostics/plot_benchmark_results.py index c3b5cdf1..d4a8a16f 100644 --- a/diagnostics/plot_benchmark_results.py +++ b/diagnostics/plot_benchmark_results.py @@ -98,7 +98,7 @@ def plot_throughput(data: dict, output_dir: Path): approaches.append(r["approach"]) # Build matrix - vps = defaultdict(dict) + vps: dict = defaultdict(dict) for r in summary: if r["vol_per_sec"] is not None: vps[r["slices"]][r["approach"]] = r["vol_per_sec"] @@ -191,7 +191,7 @@ def plot_overhead(data: dict, output_dir: Path): "reconfig_wfd": "Reconfig (waitForDevice)", } - overhead = defaultdict(dict) + overhead: dict = defaultdict(dict) for r in summary: if r["approach"] in compare and r["mean_s"] is not None: s = r["slices"] @@ -253,7 +253,7 @@ def plot_wfd_savings(data: dict, output_dir: Path): summary = data["summary"] slices_set = sorted(set(r["slices"] for r in summary)) - means = defaultdict(dict) + means: dict = defaultdict(dict) raw_means = {} for r in summary: if r["mean_s"] is not None: diff --git a/diagnostics/switchbot_webgui.py b/diagnostics/switchbot_webgui.py index 5df81015..f139b97c 100644 --- a/diagnostics/switchbot_webgui.py +++ b/diagnostics/switchbot_webgui.py @@ -27,6 +27,7 @@ import asyncio import logging from contextlib import asynccontextmanager +from typing import Any import uvicorn from fastapi import FastAPI @@ -86,7 +87,7 @@ class Bot: def __init__(self, address: str): self.address = address - self._client = None + self._client: Any = None self._lock = asyncio.Lock() self._morse_task: asyncio.Task | None = None self.state = "unknown" @@ -181,7 +182,7 @@ async def disconnect(self): await self._client.disconnect() -BOT: Bot | None = None +BOT: Any = None @asynccontextmanager diff --git a/examples/example_dispim_workflows.py b/examples/example_dispim_workflows.py deleted file mode 100644 index e81e7574..00000000 --- a/examples/example_dispim_workflows.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python -""" -Gently DiSPIM Workflow Examples -============================== - -Demonstrates the complete DiSPIM functionality using device-agnostic Bluesky plans. -Shows the progression from atomic plans to complex workflows for real DiSPIM experiments. - -This example shows: -1. Device-agnostic atomic plans (focus_sweep) -2. Autofocus functionality for precise positioning -3. Two-point calibration for coordinate mapping -4. Embryo detection with bottom camera -5. Complete multi-embryo acquisition workflows - -All plans work with proper Ophyd devices and standard Bluesky plan stubs. -""" - -import logging - -import numpy as np -from bluesky import RunEngine -from bluesky.callbacks import LiveTable - -# Import gently components -from gently import ( - # Configuration - AutofocusConfig, - CalibrationConfig, - FitFunction, - # Analysis utilities - FocusAlgorithm, # Complete workflow -) - -# Import optional napari visualization -try: - import napari # noqa: F401 - - NAPARI_AVAILABLE = True -except ImportError: - NAPARI_AVAILABLE = False - - -def setup_dispim_session(): - """ - Setup DiSPIM session with devices and RunEngine - - In practice, this would use actual MM installation paths: - system = create_dispim_system("/path/to/micromanager", "/path/to/config.cfg") - """ - print("Setting up DiSPIM session...") - - # Create RunEngine - RE = RunEngine({}) - - # For demonstration, create a mock system - # In practice: system = create_dispim_system(mm_dir, config_file) - print(" [Note: Using mock system for demonstration]") - print(" [In practice: system = create_dispim_system(mm_dir, config_file)]") - system = None # Would be actual DiSPIMSystem - - # Setup live callbacks - live_table = LiveTable(["piezo_a_position", "galvo_a_position", "camera_a_image"]) - RE.subscribe(live_table) - - return RE, system - - -def setup_napari_visualization(RE): - """Setup optional napari visualization for real-time image display""" - print("\n" + "=" * 60) - print("OPTIONAL: NAPARI VISUALIZATION SETUP") - print("=" * 60) - - if not NAPARI_AVAILABLE: - print("\n⚠ Napari not available - skipping visualization setup") - print(" To enable real-time image visualization:") - print(" pip install napari[all]") - print(" Then restart and run this example again.") - return None - - print("\n✅ Napari available - setting up real-time visualization") - - # Setup napari callback for all DiSPIM experiments - napari_callback = enable_full_visualization(RE) # noqa: F821 - - if napari_callback.enabled: - print(" ✅ Napari callback enabled and subscribed to RunEngine") - print(" ✅ Real-time image display will show:") - print(" - Focus sweep image stacks (3D)") - print(" - Embryo detection scan images") - print(" - Dual-channel DiSPIM data (green/magenta)") - print(" - Individual camera acquisitions") - - print(f"\n Napari viewer: '{napari_callback.viewer.title}'") - print(f" - Focus sweeps: {napari_callback.show_focus_sweeps}") - print(f" - Embryo detection: {napari_callback.show_embryo_detection}") - print(f" - Dual channels: {napari_callback.dual_channel_mode}") - - print("\n 💡 The napari window is now open - you can:") - print(" - Adjust layer visibility and colors") - print(" - Explore 3D image stacks") - print(" - Take screenshots and export movies") - print(" - View images in real-time during experiments") - - else: - print(" ❌ Napari callback failed to initialize") - return None - - return napari_callback - - -def demonstrate_atomic_plans(RE, light_sheet): - """Demonstrate device-agnostic atomic plans""" - print("\n" + "=" * 60) - print("1. ATOMIC PLANS - Device-Agnostic Building Blocks") - print("=" * 60) - - print("\nThe foundation: focus_sweep(positioner, positions, detector)") - print("This atomic plan works with ANY positioner and detector:") - - # Define scan positions - positions = np.linspace(-10, 10, 11) # 11 positions from -10 to +10 μm - - print(f"\n Positions to scan: {positions}") - print(f" Number of positions: {len(positions)}") - - if light_sheet is not None: - # Example 1: Piezo focus sweep - print("\n Example 1: Piezo focus sweep") - print(" focus_sweep(light_sheet.piezo, positions, light_sheet.camera)") - # RE(focus_sweep(light_sheet.piezo, positions, light_sheet.camera)) - - # Example 2: Galvo focus sweep - print("\n Example 2: Galvo focus sweep") - print(" focus_sweep(light_sheet.galvo, positions, light_sheet.camera)") - # RE(focus_sweep(light_sheet.galvo, positions, light_sheet.camera)) - - # Example 3: XY stage sweep (device-agnostic) - print("\n Example 3: XY stage sweep (device-agnostic!)") - print(" focus_sweep(xy_stage.x, positions, light_sheet.camera)") - # RE(focus_sweep(xy_stage.x, positions, light_sheet.camera)) - else: - print(" [Would execute: RE(focus_sweep(device, positions, detector))]") - - print("\nKey insight: Same atomic plan, different devices!") - print("This is the power of device-agnostic Bluesky plans.") - - -def demonstrate_autofocus_functionality(RE, light_sheet): - """Demonstrate autofocus functionality for precise positioning""" - print("\n" + "=" * 60) - print("2. AUTOFOCUS FUNCTIONALITY - Precise Device Positioning") - print("=" * 60) - - print("\nAutofocus builds on the atomic focus_sweep plan:") - print("dispim_piezo_autofocus() = focus_sweep() + analysis + validation") - - # Create autofocus configuration - config = AutofocusConfig( - num_positions=21, - step_size_um=0.5, - algorithm=FocusAlgorithm.VOLATH.value, - fit_function=FitFunction.GAUSSIAN.value, - minimum_r_squared=0.75, - center_at_current=True, - ) - - print("\nAutofocus Configuration:") - print(f" Positions: {config.num_positions}") - print(f" Step size: {config.step_size_um} μm") - print(f" Algorithm: {config.algorithm}") - print(f" Fit function: {config.fit_function}") - print(f" Min R²: {config.minimum_r_squared}") - - if light_sheet is not None: - print("\nExecuting autofocus:") - print(" RE(dispim_piezo_autofocus(light_sheet, config))") - # RE(dispim_piezo_autofocus(light_sheet, config)) - - if NAPARI_AVAILABLE: - print("\n 📺 With napari visualization:") - print(" - Images stream to napari in real-time") - print(" - 3D focus stack builds as positions are scanned") - print(" - Can see focus quality at each position") - print(" - Visual feedback on autofocus progress") - else: - print("\n[Would execute: RE(dispim_piezo_autofocus(light_sheet, config))]") - - print("\nAutofocus workflow:") - print(" 1. bps.stage(light_sheet) # Save current state") - print(" 2. focus_sweep(piezo, positions, camera) # Atomic plan") - print(" 3. analyze_focus_stack(positions, images) # Find best position") - print(" 4. bps.mv(piezo, best_position) # Move to focus") - print(" 5. bps.unstage(light_sheet) # Restore if failed") - - print("\nThis enables precise, automated positioning for experiments!") - - -def demonstrate_calibration_workflow(RE, light_sheet): - """Demonstrate calibration workflows for coordinate mapping""" - print("\n" + "=" * 60) - print("3. CALIBRATION WORKFLOW - Coordinate System Mapping") - print("=" * 60) - - print("\nTwo-point calibration uses autofocus at each calibration point:") - print("dispim_two_point_calibration() = move + autofocus + move + autofocus + fit") - - # Create calibration configuration - autofocus_config = AutofocusConfig( - num_positions=11, # Faster for calibration - step_size_um=1.0, - algorithm=FocusAlgorithm.VOLATH.value, - ) - - cal_config = CalibrationConfig( - point1_um=25.0, - point2_um=75.0, - autofocus_each_point=True, - autofocus_config=autofocus_config, - ) - - print("\nCalibration Configuration:") - print(f" Point 1: {cal_config.point1_um} μm") - print(f" Point 2: {cal_config.point2_um} μm") - print(f" Autofocus at each point: {cal_config.autofocus_each_point}") - - if light_sheet is not None: - print("\nExecuting calibration:") - print(" RE(dispim_two_point_calibration(light_sheet, cal_config))") - # RE(dispim_two_point_calibration(light_sheet, cal_config)) - else: - print("\n[Would execute: RE(dispim_two_point_calibration(light_sheet, cal_config))]") - - print("\nCalibration workflow:") - print(" 1. bps.mv(piezo, point1) # Move to first point") - print(" 2. dispim_galvo_autofocus() # Focus galvo (uses atomic plans)") - print(" 3. bps.trigger_and_read([...]) # Record positions") - print(" 4. bps.mv(piezo, point2) # Move to second point") - print(" 5. dispim_galvo_autofocus() # Focus galvo again") - print(" 6. bps.trigger_and_read([...]) # Record positions") - print(" 7. calculate_linear_fit() # Determine calibration") - - print("\nCalibration enables coordinate transformations between devices!") - - -def demonstrate_embryo_detection_workflow(RE, dispim_system): - """Demonstrate embryo detection with bottom camera""" - print("\n" + "=" * 60) - print("4. EMBRYO DETECTION WORKFLOW - Automated Sample Finding") - print("=" * 60) - - print("\nEmbryo detection uses bottom camera for automated sample finding:") - print("find_embryos_with_bottom_camera() = XY scan + image analysis + position recording") - - # Create detection configuration - detection_config = { - "scan_area": { - "x_start": -1000, - "x_stop": 1000, # μm - "y_start": -1000, - "y_stop": 1000, # μm - "step_size": 200, # μm between positions - }, - "detection": { - "min_size_pixels": 50, - "max_size_pixels": 500, - "brightness_threshold": 0.3, - "circularity_threshold": 0.7, - }, - "safety": { - "z_position_um": 0.0, # Safe Z position during XY scan - "max_scan_time_minutes": 10, - }, - } - - print("\nDetection Configuration:") - print( - f" Scan area: {detection_config['scan_area']['x_start']} to" - f" {detection_config['scan_area']['x_stop']} μm (X)" - ) - print( - f" {detection_config['scan_area']['y_start']} to" - f" {detection_config['scan_area']['y_stop']} μm (Y)" - ) - print(f" Step size: {detection_config['scan_area']['step_size']} μm") - print( - f" Detection thresholds: size" - f" {detection_config['detection']['min_size_pixels']}" - f"-{detection_config['detection']['max_size_pixels']} pixels" - ) - - if dispim_system is not None: - print("\nExecuting embryo detection:") - print(" RE(find_embryos_with_bottom_camera(dispim_system, detection_config))") - # RE(find_embryos_with_bottom_camera(dispim_system, detection_config)) - - if NAPARI_AVAILABLE: - print("\n 📺 With napari visualization:") - print(" - Each XY position shows in napari as it's acquired") - print(" - See scan progress across the sample area") - print(" - Detected embryos can be highlighted in real-time") - print(" - Build up a mosaic view of the scanned region") - else: - print( - "\n[Would execute:" - " RE(find_embryos_with_bottom_camera(dispim_system, detection_config))]" - ) - - print("\nDetection workflow:") - print(" 1. bps.mv(xy_stage.z, safe_z_position) # Move to safe Z") - print(" 2. XY grid scan with bottom camera # Scan entire area") - print(" 3. Analyze images for embryo features # Find circular objects") - print(" 4. Record embryo positions in stage coords # Store locations") - print(" 5. Convert to light sheet coordinates # Transform coords") - - print("\nAutomated detection finds all samples for batch processing!") - - -def demonstrate_complete_workflow(RE, dispim_system): - """Demonstrate complete multi-embryo acquisition workflow""" - print("\n" + "=" * 60) - print("5. COMPLETE WORKFLOW - Multi-Embryo Light Sheet Acquisition") - print("=" * 60) - - print("\nThe complete workflow combines all components:") - print("full_dispim_workflow() = calibration + embryo_detection + acquisition") - - # Create complete workflow configuration - workflow_config = { - "system_setup": { - "center_devices": True, - "run_calibration": True, - "validate_hardware": True, - }, - "calibration": { - "point1_um": 25.0, - "point2_um": 75.0, - "autofocus_each_point": True, - "autofocus_config": { - "num_positions": 11, - "step_size_um": 1.0, - "algorithm": "volath", - }, - }, - "embryo_detection": { - "x_start": -1000, - "x_stop": 1000, - "y_start": -1000, - "y_stop": 1000, - "step_size": 200, - "detection_thresholds": { - "min_size": 50, - "max_size": 500, - "brightness": 0.3, - "circularity": 0.7, - }, - }, - "acquisition": { - "autofocus_config": { - "num_positions": 21, - "step_size_um": 0.5, - "algorithm": "volath", - }, - "z_stack": { - "range_um": 50, # ±25 μm around focus - "step_size_um": 1.0, - }, - "dual_sided": True, - "time_points": 1, - }, - } - - print("\nWorkflow Configuration:") - print( - f" Calibration: {workflow_config['calibration']['point1_um']} to" - f" {workflow_config['calibration']['point2_um']} μm" - ) - print( - f" Detection area: {workflow_config['embryo_detection']['x_start']} to" - f" {workflow_config['embryo_detection']['x_stop']} μm" - ) - print(f" Z-stack range: ±{workflow_config['acquisition']['z_stack']['range_um'] // 2} μm") - print(f" Dual-sided: {workflow_config['acquisition']['dual_sided']}") - - if dispim_system is not None: - print("\nExecuting complete workflow:") - print(" RE(full_dispim_workflow(dispim_system, workflow_config))") - # RE(full_dispim_workflow(dispim_system, workflow_config)) - else: - print("\n[Would execute: RE(full_dispim_workflow(dispim_system, workflow_config))]") - - print("\nComplete workflow stages:") - print(" 1. System initialization and hardware validation") - print(" 2. Two-point calibration (with autofocus)") - print(" 3. Embryo detection with bottom camera") - print(" 4. For each detected embryo:") - print(" a. Move to embryo position") - print(" b. Autofocus both sides") - print(" c. Acquire dual-sided Z-stack") - print(" d. Save data with metadata") - - print("\nAutomated, high-throughput DiSPIM experiments!") - - -def demonstrate_extensibility(RE): - """Demonstrate how the atomic approach enables easy extension""" - print("\n" + "=" * 60) - print("6. EXTENSIBILITY - Easy Addition of New Capabilities") - print("=" * 60) - - print("\nBecause plans are device-agnostic, new capabilities are easy:") - - print("\nNew hardware? Same plans work:") - print(" focus_sweep(new_positioner, positions, new_detector)") - print(" dispim_piezo_autofocus(new_light_sheet, config)") - - print("\nNew algorithms? Just swap the analysis:") - print(" config.algorithm = 'new_algorithm'") - print(" Same dispim_piezo_autofocus() plan!") - - print("\nNew workflows? Compose existing plans:") - print(" def adaptive_autofocus_with_ai(light_sheet, ai_callback):") - print(" yield from dispim_piezo_autofocus(light_sheet, config)") - print(" decision = ai_callback(result)") - print(" if decision.refine:") - print(" yield from dispim_galvo_autofocus(light_sheet, refined_config)") - - print("\nIntegration with other systems:") - print(" def combined_microscopy_workflow(dispim, confocal, shared_stage):") - print(" yield from focus_sweep(shared_stage.z, positions, dispim.camera)") - print(" yield from focus_sweep(shared_stage.z, positions, confocal.camera)") - print(" # Same atomic plan, different systems!") - - print("\nThe atomic approach scales naturally!") - - -def main(): - """Main demonstration of DiSPIM workflow functionality""" - print("Gently DiSPIM Workflow Examples") - print("=" * 60) - print() - print("This example demonstrates complete DiSPIM functionality using") - print("device-agnostic Bluesky plans for real microscopy experiments.") - print() - print("Key concepts:") - print(" - Atomic plans work with ANY compatible devices") - print(" - Autofocus enables precise positioning") - print(" - Complex workflows compose atomic plans") - print(" - Easy extensibility through device agnosticism") - - # Setup session - RE, system = setup_dispim_session() - light_sheet = getattr(system, "side_a", None) if system else None - - # Setup optional napari visualization - setup_napari_visualization(RE) - - # Run demonstrations - demonstrate_atomic_plans(RE, light_sheet) - demonstrate_autofocus_functionality(RE, light_sheet) - demonstrate_calibration_workflow(RE, light_sheet) - demonstrate_embryo_detection_workflow(RE, system) - demonstrate_complete_workflow(RE, system) - demonstrate_extensibility(RE) - - # Summary - print("\n" + "=" * 60) - print("SUMMARY - Complete DiSPIM Functionality") - print("=" * 60) - - print("\n✓ Created proper Ophyd devices (DiSPIMPiezo, DiSPIMCamera, etc.)") - print("✓ Built device-agnostic atomic plans (focus_sweep, move_and_acquire)") - print("✓ Implemented autofocus for precise positioning") - print("✓ Added calibration workflows for coordinate mapping") - print("✓ Created embryo detection for automated sample finding") - print("✓ Integrated complete multi-embryo acquisition workflows") - if NAPARI_AVAILABLE: - print("✓ Enabled real-time image visualization with napari") - - print("\nKey benefits:") - print(" 1. Device-agnostic - plans work with any compatible hardware") - print(" 2. Composable - atomic plans build into complex workflows") - print(" 3. Extensible - easy to add new capabilities") - print(" 4. Reliable - proper Bluesky integration with error handling") - - print("\nNext steps:") - print(" 1. Test with real DiSPIM hardware using safety protocols") - if not NAPARI_AVAILABLE: - print(" 2. Install napari for real-time visualization: pip install napari[all]") - print(" 3. Add image analysis for embryo detection") - print(" 4. Integrate with VLM for intelligent workflows") - print(" 5. Extend to other microscopy systems") - else: - print(" 2. Add image analysis for embryo detection") - print(" 3. Integrate with VLM for intelligent workflows") - print(" 4. Extend to other microscopy systems") - - print("\nThe transformation is complete:") - print(" 635-line Java monolith → Composable Bluesky atomic plans") - print(" Device-specific code → Device-agnostic interfaces") - print(" Rigid workflows → Flexible, extensible compositions") - - print("\nGently DiSPIM: Where atomic plans meet experimental flexibility! 🔬") - - -if __name__ == "__main__": - # Setup logging - logging.basicConfig(level=logging.INFO) - - # Run demonstration - main() diff --git a/gently/core/store.py b/gently/core/store.py index 6a60a202..8060c484 100644 --- a/gently/core/store.py +++ b/gently/core/store.py @@ -36,7 +36,7 @@ from contextlib import contextmanager from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, cast import numpy as np @@ -311,7 +311,7 @@ def get_session(self, session_id: str) -> SessionInfo | None: return None d = dict(row) self._parse_json_field(d, "metadata") - return d + return d # type: ignore[return-value] # dict matches TypedDict shape def list_sessions(self) -> list[SessionInfo]: """Return all sessions ordered by last_active descending.""" @@ -321,7 +321,7 @@ def list_sessions(self) -> list[SessionInfo]: d = dict(row) self._parse_json_field(d, "metadata") result.append(d) - return result + return result # type: ignore[return-value] # dict matches TypedDict shape def touch_session(self, session_id: str): """Update last_active timestamp.""" @@ -397,7 +397,7 @@ def get_embryo(self, session_id: str, embryo_id: str) -> EmbryoInfo | None: return None d = dict(row) self._parse_json_field(d, "calibration") - return d + return d # type: ignore[return-value] # dict matches TypedDict shape def list_embryos(self, session_id: str) -> list[EmbryoInfo]: rows = self._conn.execute( @@ -409,7 +409,7 @@ def list_embryos(self, session_id: str) -> list[EmbryoInfo]: d = dict(row) self._parse_json_field(d, "calibration") result.append(d) - return result + return result # type: ignore[return-value] # dict matches TypedDict shape # ================================================================== # Volumes @@ -726,7 +726,7 @@ def list_volumes(self, session_id: str, embryo_id: str | None = None) -> list[Vo if d.get("metadata"): d["metadata"] = json.loads(d["metadata"]) result.append(d) - return result + return result # type: ignore[return-value] # dict matches TypedDict shape def get_acquisition_params(self, session_id: str, embryo_id: str | None = None) -> dict | None: """ @@ -876,7 +876,7 @@ def list_projections(self, session_id: str, embryo_id: str) -> list[ProjectionIn "SELECT * FROM projections WHERE session_id = ? AND embryo_id = ? ORDER BY timepoint", (session_id, embryo_id), ).fetchall() - return [dict(r) for r in rows] + return [dict(r) for r in rows] # type: ignore[misc] # dict matches TypedDict shape # ================================================================== # Perception Runs & Predictions @@ -911,7 +911,7 @@ def create_perception_run( now, ), ) - return cursor.lastrowid + return cast("int", cursor.lastrowid) def store_prediction( self, @@ -978,7 +978,7 @@ def store_prediction( now, ), ) - return cursor.lastrowid + return cast("int", cursor.lastrowid) def complete_perception_run( self, run_id: int, status: str = "completed", error_message: str | None = None @@ -1021,7 +1021,7 @@ def get_predictions( if d.get("observed_features"): d["observed_features"] = json.loads(d["observed_features"]) result.append(d) - return result + return result # type: ignore[return-value] # dict matches TypedDict shape # ================================================================== # Ground Truth @@ -1066,7 +1066,7 @@ def get_ground_truth(self, session_id: str, embryo_id: str) -> list[GroundTruthE "WHERE session_id = ? AND embryo_id = ? ORDER BY start_timepoint", (session_id, embryo_id), ).fetchall() - return [dict(r) for r in rows] + return [dict(r) for r in rows] # type: ignore[misc] # dict matches TypedDict shape # ================================================================== # Utility @@ -1098,7 +1098,7 @@ def stats(self) -> StoreStats: counts["disk_usage_mb"] = round(total_bytes / (1024 * 1024), 1) counts["db_size_mb"] = round(self._db_path.stat().st_size / (1024 * 1024), 2) - return counts + return counts # type: ignore[return-value] # dict matches TypedDict shape @property def db_path(self) -> Path: diff --git a/gently/dataset/aggregator.py b/gently/dataset/aggregator.py index c48a99c3..37105afb 100644 --- a/gently/dataset/aggregator.py +++ b/gently/dataset/aggregator.py @@ -13,7 +13,7 @@ import sqlite3 from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, cast from .schema import init_database @@ -197,6 +197,7 @@ def _process_session(self, data: dict[str, Any]) -> str: return "skipped" # Check if exists + assert self.conn is not None existing = self.conn.execute( "SELECT session_id FROM sessions WHERE session_id = ?", (session_id,) ).fetchone() @@ -220,6 +221,7 @@ def _process_session(self, data: dict[str, Any]) -> str: } if existing: + assert self.conn is not None self.conn.execute( """ UPDATE sessions SET @@ -236,6 +238,7 @@ def _process_session(self, data: dict[str, Any]) -> str: ) result = "updated" else: + assert self.conn is not None self.conn.execute( """ INSERT INTO sessions @@ -260,6 +263,7 @@ def _process_session(self, data: dict[str, Any]) -> str: for embryo_id, embryo_data in embryos.items(): self._process_embryo(session_id, embryo_id, embryo_data) + assert self.conn is not None self.conn.commit() return result @@ -271,6 +275,7 @@ def _process_embryo(self, session_id: str, embryo_id: str, data: dict[str, Any]) # Get UID from data, or generate backward-compatible UID embryo_uid = data.get("uid") or f"{session_id}_{embryo_id}" + assert self.conn is not None self.conn.execute( """ INSERT OR REPLACE INTO embryos @@ -331,6 +336,7 @@ def aggregate_volumes(self, since: datetime | None = None) -> dict[str, int]: logger.error(f"Error processing volume {meta_file}: {e}") stats["skipped"] += 1 + assert self.conn is not None self.conn.commit() self._complete_aggregation_log( log_id, stats["added"] + stats["skipped"], stats["added"], 0 @@ -363,6 +369,7 @@ def aggregate_volumes(self, since: datetime | None = None) -> dict[str, int]: logger.error(f"Error processing TIFF {tif_file}: {e}") stats["skipped"] += 1 + assert self.conn is not None self.conn.commit() return stats @@ -378,6 +385,7 @@ def _process_volume(self, data: dict[str, Any], meta_file: Path) -> bool: return False # Check if exists + assert self.conn is not None existing = self.conn.execute("SELECT uid FROM volumes WHERE uid = ?", (uid,)).fetchone() if existing: return False @@ -394,12 +402,14 @@ def _process_volume(self, data: dict[str, Any], meta_file: Path) -> bool: # Look up embryo_uid from embryos table, or generate backward-compatible UID embryo_uid = None if session_id and embryo_id: + assert self.conn is not None result = self.conn.execute( "SELECT embryo_uid FROM embryos WHERE session_id = ? AND embryo_id = ?", (session_id, embryo_id), ).fetchone() embryo_uid = result[0] if result else f"{session_id}_{embryo_id}" + assert self.conn is not None self.conn.execute( """ INSERT INTO volumes @@ -433,6 +443,7 @@ def _process_volume_from_tiff(self, tif_path: Path, session_id: str) -> bool: uid = f"tiff_{tif_path.stem}" # Check if exists + assert self.conn is not None existing = self.conn.execute("SELECT uid FROM volumes WHERE uid = ?", (uid,)).fetchone() if existing: return False @@ -463,12 +474,14 @@ def _process_volume_from_tiff(self, tif_path: Path, session_id: str) -> bool: # Look up embryo_uid from embryos table, or generate backward-compatible UID embryo_uid = None if session_id and embryo_id: + assert self.conn is not None result = self.conn.execute( "SELECT embryo_uid FROM embryos WHERE session_id = ? AND embryo_id = ?", (session_id, embryo_id), ).fetchone() embryo_uid = result[0] if result else f"{session_id}_{embryo_id}" + assert self.conn is not None self.conn.execute( """ INSERT INTO volumes @@ -530,6 +543,7 @@ def aggregate_images(self, since: datetime | None = None) -> dict[str, int]: logger.error(f"Error processing image {meta_file}: {e}") stats["skipped"] += 1 + assert self.conn is not None self.conn.commit() self._complete_aggregation_log( log_id, stats["added"] + stats["skipped"], stats["added"], 0 @@ -552,6 +566,7 @@ def _process_image(self, data: dict[str, Any]) -> bool: return False # Check if exists + assert self.conn is not None existing = self.conn.execute("SELECT uid FROM images WHERE uid = ?", (uid,)).fetchone() if existing: return False @@ -563,12 +578,14 @@ def _process_image(self, data: dict[str, Any]) -> bool: # Look up embryo_uid from embryos table, or generate backward-compatible UID embryo_uid = None if session_id and embryo_id: + assert self.conn is not None result = self.conn.execute( "SELECT embryo_uid FROM embryos WHERE session_id = ? AND embryo_id = ?", (session_id, embryo_id), ).fetchone() embryo_uid = result[0] if result else f"{session_id}_{embryo_id}" + assert self.conn is not None self.conn.execute( """ INSERT INTO images @@ -634,6 +651,7 @@ def aggregate_ground_truth(self) -> dict[str, int]: for embryo_id, stages in transitions.items(): for stage, start_timepoint in stages.items(): # Check if exists + assert self.conn is not None existing = self.conn.execute( """ SELECT id FROM ground_truth @@ -643,6 +661,7 @@ def aggregate_ground_truth(self) -> dict[str, int]: ).fetchone() if existing: + assert self.conn is not None self.conn.execute( """ UPDATE ground_truth SET @@ -653,6 +672,7 @@ def aggregate_ground_truth(self) -> dict[str, int]: ) stats["updated"] += 1 else: + assert self.conn is not None self.conn.execute( """ INSERT INTO ground_truth @@ -674,6 +694,7 @@ def aggregate_ground_truth(self) -> dict[str, int]: except Exception as e: logger.error(f"Error processing ground truth {gt_file}: {e}") + assert self.conn is not None self.conn.commit() self._complete_aggregation_log( log_id, @@ -690,6 +711,7 @@ def aggregate_ground_truth(self) -> dict[str, int]: def _get_last_run_time(self) -> datetime | None: """Get the timestamp of the last successful aggregation.""" + assert self.conn is not None result = self.conn.execute(""" SELECT MAX(completed_at) FROM aggregation_log WHERE status = 'completed' @@ -701,6 +723,7 @@ def _get_last_run_time(self) -> datetime | None: def _set_last_run_time(self, timestamp: datetime): """Record the current aggregation time.""" + assert self.conn is not None self.conn.execute( """ INSERT OR REPLACE INTO metadata (key, value, updated_at) @@ -708,10 +731,12 @@ def _set_last_run_time(self, timestamp: datetime): """, (timestamp.isoformat(), timestamp.isoformat()), ) + assert self.conn is not None self.conn.commit() def _start_aggregation_log(self, source_type: str, source_path: str) -> int: """Start a new aggregation log entry.""" + assert self.conn is not None cursor = self.conn.execute( """ INSERT INTO aggregation_log (source_type, source_path, started_at, status) @@ -719,11 +744,13 @@ def _start_aggregation_log(self, source_type: str, source_path: str) -> int: """, (source_type, source_path, datetime.now().isoformat()), ) + assert self.conn is not None self.conn.commit() - return cursor.lastrowid + return cast("int", cursor.lastrowid) def _complete_aggregation_log(self, log_id: int, processed: int, added: int, updated: int): """Mark an aggregation log as completed.""" + assert self.conn is not None self.conn.execute( """ UPDATE aggregation_log SET @@ -733,10 +760,12 @@ def _complete_aggregation_log(self, log_id: int, processed: int, added: int, upd """, (processed, added, updated, datetime.now().isoformat(), log_id), ) + assert self.conn is not None self.conn.commit() def _fail_aggregation_log(self, log_id: int, error_message: str): """Mark an aggregation log as failed.""" + assert self.conn is not None self.conn.execute( """ UPDATE aggregation_log SET @@ -745,6 +774,7 @@ def _fail_aggregation_log(self, log_id: int, error_message: str): """, (datetime.now().isoformat(), error_message, log_id), ) + assert self.conn is not None self.conn.commit() diff --git a/gently/dataset/embryo_dataset.py b/gently/dataset/embryo_dataset.py index b6cd3a66..48828692 100644 --- a/gently/dataset/embryo_dataset.py +++ b/gently/dataset/embryo_dataset.py @@ -32,7 +32,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, cast import numpy as np @@ -375,7 +375,7 @@ def iter_images( AND i.timepoint = v.timepoint WHERE v.embryo_id = ? """ - params = [embryo_id] + params: list[Any] = [embryo_id] if session_id: query += " AND v.session_id = ?" @@ -766,7 +766,7 @@ def create_perception_run( ), ) self.conn.commit() - return cursor.lastrowid + return cast("int", cursor.lastrowid) def store_prediction( self, @@ -832,7 +832,7 @@ def store_prediction( datetime.now().isoformat(), ), ) - prediction_id = cursor.lastrowid + prediction_id = cast("int", cursor.lastrowid) else: # Legacy schema: separate observed_features + reasoning_traces tables confidence_level = None @@ -869,7 +869,7 @@ def store_prediction( ), ) - prediction_id = cursor.lastrowid + prediction_id = cast("int", cursor.lastrowid) # Store observed features if provided if observed_features: @@ -986,7 +986,7 @@ def compute_run_metrics(self, run_id: int) -> dict[str, Any]: accuracy = correct / total if total > 0 else 0 # Build confusion matrix - confusion = {} + confusion: dict[str, Any] = {} for pred_stage, gt_stage, _, _ in rows: if gt_stage not in confusion: confusion[gt_stage] = {} @@ -1396,7 +1396,7 @@ def get_run_predictions( LEFT JOIN reasoning_traces rt ON p.id = rt.prediction_id WHERE p.perception_run_id = ? """ - params = [run_id] + params: list[Any] = [run_id] if embryo_id: query += " AND p.embryo_id = ?" diff --git a/gently/dataset/explorer_server.py b/gently/dataset/explorer_server.py index 3c9e816d..1ea35b2b 100644 --- a/gently/dataset/explorer_server.py +++ b/gently/dataset/explorer_server.py @@ -18,6 +18,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path +from typing import Any import numpy as np from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect @@ -41,8 +42,8 @@ logger = logging.getLogger(__name__) # Lazy imports for explorer-specific projection functions -tifffile = None -PIL_Image = None +tifffile: Any = None +PIL_Image: Any = None def ensure_projection_deps(): @@ -447,7 +448,7 @@ def projection_spin_3d( return grid, "6 perspective views rotating around volume" -PROJECTION_METHODS = { +PROJECTION_METHODS: dict[str, Any] = { "dual_view": projection_dual_view, "depth_colored": projection_depth_colored, "multi_slice": projection_multi_slice, @@ -912,7 +913,7 @@ async def get_run_predictions( SELECT * FROM predictions WHERE perception_run_id = ? """ - params = [run_id] + params: list[Any] = [run_id] if embryo_id: query += " AND embryo_id = ?" @@ -1069,7 +1070,8 @@ async def get_unified_timeline(embryo_uid: str): # Build unified image list with ground truth based on row index # within each session/embryo images = [] - session_embryo_counts = {} # Track row index within each session/embryo + # keyed by (session_id, embryo_id); value is the row index within that group + session_embryo_counts: dict[tuple[Any, Any], int] = {} for idx, r in enumerate(rows): session_id = r[1] diff --git a/pyproject.toml b/pyproject.toml index 6567d600..d9802f71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,33 +119,3 @@ ignore_missing_imports = true explicit_package_bases = true mypy_path = "." -# Modules with pre-existing mypy errors, exempted while the codebase is -# incrementally annotated (see issue #46). New modules should not be added -# here; PRs that substantively touch a listed module should fix its errors -# and remove it from this list as part of that change. -[[tool.mypy.overrides]] -module = [ - "benchmarks.perception.live_viewer", - "benchmarks.perception.metrics", - "benchmarks.perception.testset", - "diagnostics.benchmark_gentlystore_fps", - "diagnostics.benchmark_volume_fps", - "diagnostics.plot_benchmark_results", - "diagnostics.switchbot_webgui", - "examples.example_dispim_workflows", - "gently.core.store", - "gently.dataset.aggregator", - "gently.dataset.embryo_dataset", - "gently.dataset.explorer_server", - "scripts.auto_label_examples", - "scripts.extract_stage_examples", - "scripts.gemini_stage_test", - "scripts.launch_viz_server", - "scripts.projection_explorer", - "scripts.replay_session", - "scripts.stage_annotator", - "tests.test_campaign_coordination", - "tests.test_text_tool_call_extraction", - "tests.validate_recursive_autofocus", -] -ignore_errors = true diff --git a/scripts/auto_label_examples.py b/scripts/auto_label_examples.py index b12f6626..055db082 100644 --- a/scripts/auto_label_examples.py +++ b/scripts/auto_label_examples.py @@ -18,6 +18,7 @@ import json import sys from pathlib import Path +from typing import Any import anthropic import numpy as np @@ -229,7 +230,7 @@ async def auto_label_batch( # Save results for review output_file = output_file or Path("labeled_results.json") - save_data = { + save_data: dict[str, Any] = { stage: [{k: v for k, v in r.items() if k != "image_b64"} for r in results] for stage, results in results_by_stage.items() } @@ -350,7 +351,7 @@ async def interactive_review(results_file: Path, examples_dir: Path): elif choice == "p": # Just populate from saved file - need to reload images print("Reloading images...") - results_with_images = {} + results_with_images: dict = {} for stage, items in data.items(): if stage.startswith("_"): continue diff --git a/scripts/extract_stage_examples.py b/scripts/extract_stage_examples.py index 208e49c9..3660d549 100644 --- a/scripts/extract_stage_examples.py +++ b/scripts/extract_stage_examples.py @@ -32,6 +32,7 @@ import sys from datetime import datetime from pathlib import Path +from typing import Any import numpy as np @@ -41,8 +42,8 @@ from gently.harness.perception.stages import STAGES # Lazy imports -tifffile = None -PIL_Image = None +tifffile: Any = None +PIL_Image: Any = None def ensure_dependencies(): @@ -77,7 +78,7 @@ def discover_volumes(session_dir: Path, embryo_id: str | None = None) -> dict[st tif_files = list(session_dir.glob("*.tif")) + list(session_dir.glob("*.tiff")) - embryo_volumes = {} + embryo_volumes: dict = {} for f in tif_files: parts = f.stem.split("_") diff --git a/scripts/gemini_stage_test.py b/scripts/gemini_stage_test.py index 0a9a44b1..b724384c 100644 --- a/scripts/gemini_stage_test.py +++ b/scripts/gemini_stage_test.py @@ -21,6 +21,7 @@ import time from datetime import datetime from pathlib import Path +from typing import Any # Add parent directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -28,9 +29,9 @@ import numpy as np # Lazy imports for optional dependencies -cv2 = None -tifffile = None -genai = None +cv2: Any = None +tifffile: Any = None +genai: Any = None def ensure_dependencies(): @@ -74,7 +75,7 @@ def discover_volumes(session_dir: Path, embryo_id: str | None = None) -> dict: tif_files = list(session_dir.glob("*.tif")) + list(session_dir.glob("*.tiff")) - embryo_volumes = {} + embryo_volumes: dict = {} for f in tif_files: parts = f.stem.split("_") @@ -288,7 +289,7 @@ def create_embryo_video( # Create video writer = None frame_count = 0 - first_shape = None + first_shape: Any = None try: for i, vol_path in enumerate(volume_paths): @@ -494,7 +495,7 @@ def build_stage_classification_prompt(frame_count: int, fps: int, duration_secon def analyze_with_gemini( - video_path: str, + video_path: str | Path, model: str = "gemini-3-pro-preview", api_key: str | None = None, frame_count: int = 100, @@ -633,7 +634,7 @@ def timestamp_to_seconds(timestamp: str) -> float: def create_annotated_video( - source_video: str, stage_data: dict, output_path: str, fps: int = 10 + source_video: str, stage_data: dict, output_path: str | Path, fps: int = 10 ) -> dict: """ Create annotated video with stage labels overlaid. @@ -667,7 +668,7 @@ def create_annotated_video( if i + 1 < len(stages): next_stage = stages[i + 1] if "frame" in next_stage: - end_frame = int(next_stage["frame"]) + end_frame: float = int(next_stage["frame"]) elif "timestamp" in next_stage: end_seconds = timestamp_to_seconds(next_stage["timestamp"]) end_frame = int(end_seconds * fps) diff --git a/scripts/launch_viz_server.py b/scripts/launch_viz_server.py index b10eea91..bcae0842 100644 --- a/scripts/launch_viz_server.py +++ b/scripts/launch_viz_server.py @@ -150,7 +150,8 @@ async def run( if t >= _effective_tp(paths, max_timepoints): continue - projection = project_volume(paths[t]) + # paths[t] is a (timestamp, path) tuple from _discover_volumes + projection = project_volume(paths[t][1]) proj_uid = f"proj_{session_id}_{eid}_t{t:04d}" vol_uid = f"volume_{session_id}_{eid}_t{t:04d}" diff --git a/scripts/projection_explorer.py b/scripts/projection_explorer.py index 88319905..57e39db6 100644 --- a/scripts/projection_explorer.py +++ b/scripts/projection_explorer.py @@ -34,15 +34,16 @@ from datetime import datetime from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path +from typing import Any from urllib.parse import parse_qs, urlparse import numpy as np from scipy import ndimage # Lazy imports -tifffile = None -PIL_Image = None -plt = None +tifffile: Any = None +PIL_Image: Any = None +plt: Any = None def ensure_dependencies(): @@ -87,7 +88,7 @@ def discover_volumes(session_dir: Path, embryo_id: str | None = None) -> dict[st return {} tif_files = list(session_dir.glob("*.tif")) + list(session_dir.glob("*.tiff")) - embryo_volumes = {} + embryo_volumes: dict = {} for f in tif_files: parts = f.stem.split("_") @@ -906,9 +907,9 @@ def __init__(self, session_path: Path): self.embryo_volumes = discover_volumes(session_path) self.current_embryo = None self.current_idx = 0 - self._volume_cache = {} # (embryo, idx) -> volume - self._projection_cache = {} # (embryo, idx, method) -> (img, desc) - self._crop_bounds = {} # embryo -> (y_min, y_max, x_min, x_max) + self._volume_cache: dict = {} # (embryo, idx) -> volume + self._projection_cache: dict = {} # (embryo, idx, method) -> (img, desc) + self._crop_bounds: dict = {} # embryo -> (y_min, y_max, x_min, x_max) self._cache_size = 20 # Keep more volumes in memory if self.embryo_volumes: @@ -1036,7 +1037,7 @@ def preload_adjacent(self, idx: int, radius: int = 2): class ExplorerHandler(BaseHTTPRequestHandler): """HTTP handler for projection explorer.""" - session_manager: SessionManager = None + session_manager: SessionManager # assigned on the class before the server starts serving current_methods: list[str] = ["dual_view", "depth_colored"] current_z_slice: int = 0 # For 3D volume viewer diff --git a/scripts/replay_session.py b/scripts/replay_session.py index ec2b02c4..6badda7d 100644 --- a/scripts/replay_session.py +++ b/scripts/replay_session.py @@ -108,6 +108,7 @@ def main(argv=None) -> int: session = matches[0] session_dir = store._session_dir(session["session_id"]) + assert session_dir is not None log_path = session_dir / "events.jsonl" if not log_path.exists(): print(f"No events.jsonl in {session_dir}", file=sys.stderr) diff --git a/scripts/stage_annotator.py b/scripts/stage_annotator.py index 6171d603..14ee5b19 100644 --- a/scripts/stage_annotator.py +++ b/scripts/stage_annotator.py @@ -148,12 +148,12 @@ def save_example(img: np.ndarray, stage: str, source_name: str) -> str: # Global state class AppState: - images = [] # List of (path, projection) tuples + images: list = [] # List of (path, projection) tuples current_idx = 0 session_id = "" - embryos = {} # embryo_name -> [(path, proj), ...] + embryos: dict = {} # embryo_name -> [(path, proj), ...] current_embryo = None - embryo_list = [] + embryo_list: list = [] state = AppState() @@ -708,7 +708,7 @@ def save_with_labels(self, labels: dict): saved = 0 # Group paths by stage - stage_paths = {} + stage_paths: dict = {} for path_str, stage in labels.items(): if stage not in STAGES: continue diff --git a/tests/test_campaign_coordination.py b/tests/test_campaign_coordination.py index 21f10302..305e76d2 100644 --- a/tests/test_campaign_coordination.py +++ b/tests/test_campaign_coordination.py @@ -80,7 +80,7 @@ def check(name, condition, detail=""): # Verify shared campaign has expected fields if shared: - sc = next((c for c in shared if c["id"] == cid), {}) + sc: dict = next((c for c in shared if c["id"] == cid), {}) check("Shared campaign has item_count", "item_count" in sc) check("Shared campaign has completed_count", "completed_count" in sc) diff --git a/tests/test_text_tool_call_extraction.py b/tests/test_text_tool_call_extraction.py deleted file mode 100644 index b79c2fc2..00000000 --- a/tests/test_text_tool_call_extraction.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Tests for _extract_text_tool_calls fallback parser.""" - -import json - -from gently.harness.conversation import _extract_text_tool_calls - - -class TestExtractTextToolCalls: - def test_no_tool_calls(self): - text = "Hello! How can I help you today?" - cleaned, calls = _extract_text_tool_calls(text) - assert cleaned == text - assert calls == [] - - def test_single_tool_call_with_arguments(self): - tc = json.dumps( - { - "name": "ask_user_choice", - "arguments": { - "question": "What next?", - "options": [{"id": "a", "label": "Option A"}], - }, - } - ) - text = f"Hey there!\n\n\n{tc}\n" - cleaned, calls = _extract_text_tool_calls(text) - assert "" not in cleaned - assert len(calls) == 1 - assert calls[0]["name"] == "ask_user_choice" - assert calls[0]["input"]["question"] == "What next?" - - def test_single_tool_call_with_input_key(self): - tc = json.dumps( - { - "name": "get_status", - "input": {"embryo_id": "embryo_1"}, - } - ) - text = f"Checking...\n{tc}" - cleaned, calls = _extract_text_tool_calls(text) - assert len(calls) == 1 - assert calls[0]["input"]["embryo_id"] == "embryo_1" - - def test_preserves_surrounding_text(self): - tc = json.dumps({"name": "foo", "arguments": {}}) - text = f"Before text.\n\n{tc}\n\nAfter text." - cleaned, calls = _extract_text_tool_calls(text) - assert "Before text." in cleaned - assert "After text." in cleaned - assert "" not in cleaned - assert len(calls) == 1 - - def test_multiple_tool_calls(self): - tc1 = json.dumps({"name": "tool_a", "arguments": {"x": 1}}) - tc2 = json.dumps({"name": "tool_b", "arguments": {"y": 2}}) - text = f"Text\n{tc1}\nMore\n{tc2}" - cleaned, calls = _extract_text_tool_calls(text) - assert len(calls) == 2 - assert calls[0]["name"] == "tool_a" - assert calls[1]["name"] == "tool_b" - - def test_invalid_json_skipped(self): - text = "Hello\nnot valid json" - cleaned, calls = _extract_text_tool_calls(text) - assert calls == [] - - def test_missing_name_skipped(self): - tc = json.dumps({"arguments": {"x": 1}}) - text = f"{tc}" - cleaned, calls = _extract_text_tool_calls(text) - assert calls == [] - - def test_empty_text(self): - cleaned, calls = _extract_text_tool_calls("") - assert cleaned == "" - assert calls == [] diff --git a/tests/validate_recursive_autofocus.py b/tests/validate_recursive_autofocus.py deleted file mode 100644 index 3fd2d088..00000000 --- a/tests/validate_recursive_autofocus.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -""" -Validate Recursive Autofocus Implementation -========================================== - -Quick validation test for the recursive autofocus functionality using mock devices. -Tests the plan structure and parameter handling without requiring real hardware. -""" - -import logging -import sys -from unittest.mock import Mock - -import numpy as np - - -# Mock pymmcore for testing without hardware -class MockPyMMCore: - class CMMCore: - def __init__(self): - self.current_position = 150.0 # Start at middle of new range - - def getPosition(self, device): - return self.current_position - - def setPosition(self, device, position): - # Simulate device movement - self.current_position = position - print(f"Mock {device} moved to {position:.2f} μm") - - def waitForDevice(self, device): - pass - - def getImage(self): - # Return mock image data - return np.random.randint(0, 1000, (100, 100), dtype=np.uint16) - - -# Install mock -if "pymmcore" not in sys.modules: - sys.modules["pymmcore"] = MockPyMMCore() - -# Import after mocking -from bluesky import RunEngine # noqa: E402 - -from gently.hardware.dispim.devices import DiSPIMCamera, DiSPIMZstage # noqa: E402 -from gently.hardware.dispim.plans.acquisition import recursive_focus_single_round # noqa: E402 - - -def test_device_limits(): - """Test that devices have correct limits and rounding""" - print("Testing Device Limits and Rounding") - print("=" * 40) - - mock_core = MockPyMMCore.CMMCore() - - # Test Z-stage limits - z_stage = DiSPIMZstage("TestZStage", mock_core, name="test_z") - print(f"Z-stage limits: {z_stage.limits}") - - # Test position rounding (this would be tested by trying problematic float values) - test_positions = [160.85999999999999, 100.333333333333333, 75.0000000000001] - - for pos in test_positions: - try: - z_stage.set(pos) - print(f"✓ Position {pos} handled successfully") - except ValueError as e: - print(f"✗ Position {pos} failed: {e}") - - print() - - -def test_recursive_autofocus_plan(): - """Test the recursive autofocus plan structure""" - print("Testing Recursive Autofocus Plan") - print("=" * 40) - - mock_core = MockPyMMCore.CMMCore() - - # Create mock devices - z_stage = DiSPIMZstage("TestZStage", mock_core, name="test_z") - camera = DiSPIMCamera("TestCamera", mock_core, name="test_camera") - - # Mock the camera's trigger method to avoid actual image acquisition - camera.trigger = Mock(return_value=Mock(done=True)) - - # Create RunEngine for testing - RE = RunEngine() - - # Test parameters - coarse_range = 50.0 - fine_range = 10.0 - coarse_steps = 5 # Fewer steps for quick test - fine_steps = 7 - - print("Testing with:") - print(f" Coarse range: ±{coarse_range / 2} μm ({coarse_steps} steps)") - print(f" Fine range: ±{fine_range / 2} μm ({fine_steps} steps)") - print(f" Starting position: {mock_core.current_position} μm") - print() - - try: - # Run the recursive autofocus plan - plan = recursive_focus_single_round( - z_stage, - camera, - coarse_range=coarse_range, - fine_range=fine_range, - coarse_steps=coarse_steps, - fine_steps=fine_steps, - ) - - # Execute plan - RE(plan) - - print("✓ Recursive autofocus plan executed successfully") - print(f"Final position: {mock_core.current_position:.2f} μm") - - # Verify position is within limits - if z_stage.limits[0] <= mock_core.current_position <= z_stage.limits[1]: - print(f"✓ Final position within limits {z_stage.limits}") - else: - print(f"✗ Final position outside limits {z_stage.limits}") - - except Exception as e: - print(f"✗ Recursive autofocus plan failed: {e}") - import traceback - - traceback.print_exc() - - print() - - -def test_position_bounds(): - """Test position bounds checking""" - print("Testing Position Bounds") - print("=" * 40) - - mock_core = MockPyMMCore.CMMCore() - z_stage = DiSPIMZstage("TestZStage", mock_core, name="test_z") - - # Test positions at various points - test_cases = [ - (25.0, "Below lower limit", False), - (50.0, "At lower limit", True), - (150.0, "Middle of range", True), - (250.0, "At upper limit", True), - (275.0, "Above upper limit", False), - ] - - for pos, description, should_pass in test_cases: - try: - z_stage.set(pos) - if should_pass: - print(f"✓ {description} ({pos}): Accepted") - else: - print(f"✗ {description} ({pos}): Should have been rejected") - except ValueError: - if not should_pass: - print(f"✓ {description} ({pos}): Correctly rejected") - else: - print(f"✗ {description} ({pos}): Should have been accepted") - - print() - - -def main(): - """Run all validation tests""" - print("Recursive Autofocus Validation") - print("=" * 50) - print() - - # Reduce log noise - logging.basicConfig(level=logging.WARNING) - - # Run tests - test_device_limits() - test_position_bounds() - test_recursive_autofocus_plan() - - print("=" * 50) - print("Validation Complete!") - print() - print("Usage Instructions:") - print("=" * 20) - print("1. Simple focus test:") - print(" python test_embryo_focus.py") - print() - print("2. Recursive autofocus test:") - print(" python test_embryo_focus.py recursive") - print() - print("The recursive autofocus performs:") - print(" - Coarse scan (±50μm default)") - print(" - Analysis to find best coarse position") - print(" - Fine scan around best position (±10μm default)") - print(" - Final move to optimal position from curve fit") - - -if __name__ == "__main__": - main() From 9917e12a46e4a8efa696e7126c61224d263527bc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:11:46 +0530 Subject: [PATCH 185/314] =?UTF-8?q?feat(manual-mode):=20Manual=20view=20?= =?UTF-8?q?=E2=80=94=20SPIM=20live=20canvas,=20galvo/piezo/exposure,=20ill?= =?UTF-8?q?umination,=20acquire,=20temp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gently/ui/web/static/css/main.css | 257 +++++++++++++++++ gently/ui/web/static/js/devices.js | 445 ++++++++++++++++++++++++++++- gently/ui/web/templates/index.html | 119 ++++++++ 3 files changed, 815 insertions(+), 6 deletions(-) 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..57aa3bd0 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,34 @@ 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 = 50; + 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 _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 +179,29 @@ 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'); + _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 +1343,386 @@ const DevicesManager = (function () { } } + // ===================================================================== + // Lightsheet live panel (Manual view) + // ===================================================================== + + async function toggleLightsheetStream() { + if (!_lsToggle) return; + _lsToggle.disabled = true; + try { + 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); + } 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 }), + }); + } else { + res = await fetch('/api/devices/acquire/volume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure }), + }); + } + 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); + } + + // Embed temperature graph in the rail + const _lsTgEl = document.getElementById('devices-ls-tempgraph'); + if (_lsTgEl && window.TemperatureGraph) { + TemperatureGraph.init(_lsTgEl, 'current'); + } + + syncInitialLightsheetState(); + } + // ===================================================================== // Room-light toggle // ===================================================================== @@ -1552,6 +1983,7 @@ const DevicesManager = (function () { cacheDom(); setupViewSwitcher(); setupCameraWiring(); + setupManualWiring(); setupRoomLight(); setupTemperature(); const _tgEl = document.getElementById('devices-temp-graph'); @@ -1577,13 +2009,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/templates/index.html b/gently/ui/web/templates/index.html index 7e1f0726..dc6718c2 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

+ + +
From c69c1aefab842b2c7fb83cc116afed91c0300a96 Mon Sep 17 00:00:00 2001 From: subindevs <36504048+subindevs@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:44:57 +0100 Subject: [PATCH 186/314] Restore dependencies accidentally stripped in 2be3587 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 2be3587 ("mypy: clear gently library modules") silently deleted every entry from [project.dependencies], all [project.optional-dependencies] extras (sam, torch-gpu, torch-cpu, device), the entire [dependency-groups] dev group (pytest, ruff, pre-commit, mypy), and the project classifiers — leaving only the explanatory comments. The commit message mentions none of this; it was an unintended edit. With the dependency lists empty, `uv sync` installs nothing, so the project cannot be installed or run and mypy can only ever see Any-typed third-party imports. Restores all stripped entries verbatim from 2be3587^ (the only intended pyproject change on this branch remains the removal of the [[tool.mypy.overrides]] block). Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d9802f71..0ae5a358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,18 +15,41 @@ authors = [ {name = "Kesavan Subburam", email = "pskeshu@gmail.com"}, ] classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ] dependencies = [ + "anthropic>=0.39.0", + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "aiohttp>=3.9.0", # numpy capped <2: pymmcore 10.1.1.70.6 is a C-extension built against the # numpy 1.x ABI and fails to import under numpy 2.x. The wheel declares no # upper bound, so we enforce it here. + "numpy>=1.24.0,<2", + "pillow>=10.0.0", + "tifffile>=2023.0.0", + "scipy>=1.10.0", + "scikit-image>=0.21.0", + "jinja2>=3.1.0", + "pyyaml>=6.0", + "matplotlib>=3.7.0", + "python-dotenv>=1.0.0", # Perception harness — installed as an editable sibling clone (see # [tool.uv.sources]); clone gently-project/gently-perception next to this repo. + "gently-perception", # Device control. # pymmcore pinned exactly: its MMCore must match the device-interface version # (70) of the Micro-Manager device adapters installed on the microscope PC. # Bumping this requires upgrading the MM adapter install in lockstep. + "pymmcore==10.1.1.70.6", + "bluesky>=1.11.0", + "ophyd>=1.9.0", # NOTE: torch / torchvision / nvidia-ml-py are NOT base deps — CUDA build is # machine-specific. Install via an extra: `uv sync --extra torch-gpu` (CUDA # 11.8) or `uv sync --extra torch-cpu`. All torch imports are lazy/guarded. @@ -35,6 +58,7 @@ dependencies = [ [project.optional-dependencies] sam = [ + "segment-anything", ] # PyTorch — CUDA build is per-machine, so it lives in mutually-exclusive extras # wired to dedicated indexes below (see [tool.uv.sources] / [[tool.uv.index]]). @@ -44,23 +68,36 @@ sam = [ # and only need a recent NVIDIA driver, so the same build runs across GPU machines # regardless of their system CUDA version. torch-gpu = [ + "torch>=2.0.0", + "torchvision>=0.15.0", + "nvidia-ml-py>=12.0.0", ] torch-cpu = [ + "torch>=2.0.0", + "torchvision>=0.15.0", ] # Device-layer accessories for the microscope computer. Core hardware-control # deps (pymmcore, bluesky, ophyd) are already in [project.dependencies]; these # are the extras the device layer needs: BLE (SwitchBot room light), serial and # MQTT transports (ACUITYnano thermal controller). Install: uv sync --extra device device = [ + "bleak>=0.21.0", + "pyserial>=3.5", + "paho-mqtt>=1.6.0", ] # Test/dev tooling — installed by `uv sync` (uv enables the `dev` group by # default) but excluded from gently's published runtime requirements. [dependency-groups] dev = [ + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "ruff>=0.4.0", + "pre-commit>=3.7.0", # Pinned so local runs, the pre-commit hook, and CI all use the same # mypy. Keep in sync with the mirrors-mypy rev in .pre-commit-config.yaml # and the install step in .github/workflows/lint.yml. + "mypy==2.1.0", ] [project.scripts] From 37f9156c196b1c9e91f2f2823dd795abcde8ab7b Mon Sep 17 00:00:00 2001 From: subindevs <36504048+subindevs@users.noreply.github.com> Date: Sun, 28 Jun 2026 08:51:23 +0100 Subject: [PATCH 187/314] ci: add deps-installed mypy job (non-blocking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing mypy step runs deps-less (pip install mypy only), so with ignore_missing_imports every third-party import is Any and real type mismatches stay hidden — `mypy .` reports clean against the weaker check. Adds a separate mypy-strict job that installs the project via uv (cloning the gently-perception sibling first, as [tool.uv.sources] requires) and runs `uv run mypy .` with real stubs present. This is the check contributors get locally per CONTRIBUTING.md and catches genuine mismatches the deps-less run cannot. Marked continue-on-error for now: the deps-installed run still surfaces errors the deps-less baseline hid, and isolating it in its own job keeps the heavy uv/sibling setup from breaking the required lint gate. Flip to a required gate once it is clean. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/lint.yml | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d11c7f22..dc6a9056 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,10 +25,43 @@ jobs: - name: ruff format check run: ruff format --check . + # Fast baseline mypy without project deps. With ignore_missing_imports, + # third-party imports fall back to Any, so this is the weaker check — kept + # as the required gate because it needs no heavy install and stays green. + # The stronger, deps-installed run lives in the mypy-strict job below. # Pin to match the pre-commit mirrors-mypy rev and the pyproject dev # group so CI, local commits, and `uv sync` never run different mypys. - name: Install mypy run: pip install mypy==2.1.0 - - name: mypy + - name: mypy (deps-less) run: mypy . + + # Deps-installed mypy: with the real third-party packages present (numpy, + # anthropic, …) mypy resolves their actual types instead of Any, surfacing + # genuine mismatches the deps-less run above cannot see. This is what a + # contributor gets from `uv run mypy .` after `uv sync` (see CONTRIBUTING.md). + # Non-blocking for now (continue-on-error) while the remaining deps-only + # errors are worked through — flip this to a required gate once it is clean. + mypy-strict: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + # gently-perception is not on PyPI; it is installed editable from a sibling + # clone (see [tool.uv.sources]), so `uv sync` fails without it. + - name: Clone gently-perception sibling + run: git clone --depth 1 https://github.com/gently-project/gently-perception.git ../gently-perception + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.10" + enable-cache: true + + - name: uv sync + run: uv sync + + - name: mypy (deps-installed) + run: uv run mypy . From c749223234217d0f0deda365ee750f93347f91be Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:21:51 +0530 Subject: [PATCH 188/314] fix(manual-mode): init temperature graph per-view in switchView (singleton dual-init) --- gently/ui/web/static/js/devices.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 57aa3bd0..13237d4e 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -1714,12 +1714,6 @@ const DevicesManager = (function () { ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame); } - // Embed temperature graph in the rail - const _lsTgEl = document.getElementById('devices-ls-tempgraph'); - if (_lsTgEl && window.TemperatureGraph) { - TemperatureGraph.init(_lsTgEl, 'current'); - } - syncInitialLightsheetState(); } @@ -1937,6 +1931,19 @@ 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'); + } + } } function setupViewSwitcher() { @@ -1986,8 +1993,6 @@ const DevicesManager = (function () { setupManualWiring(); setupRoomLight(); setupTemperature(); - const _tgEl = document.getElementById('devices-temp-graph'); - if (_tgEl && window.TemperatureGraph) TemperatureGraph.init(_tgEl, 'current'); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); From 5e12b60667f80e27e8137d8470ef55169081663c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:25:26 +0530 Subject: [PATCH 189/314] docs(manual-mode): lightsheet FPS measurement protocol + transport decision gate (B1 Task 7) --- .../2026-06-28-lightsheet-fps-measurement.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md 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)_ From 779e6c68d5a6392f74e2b528b39ff7926ace6963 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 13:54:32 +0530 Subject: [PATCH 190/314] fix(manual-mode): brightfield-safe + focal-plane-correct acquire, sequence-stop on kickoff, park-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: DiSPIMVolumeScanner.trigger() stops any running camera sequence before startSequenceAcquisition — prevents MMCore collision when live streamer is active (covers both acquire_single_volume_plan and burst_plan callers). park-guard: _park_lightsheet_sync now fires set_spim_state("Idle") once per stream session (not per frame) and calls setPosition only when the value changed. Cuts 4 serial round-trips per frame to 0 on steady state. _stop_lightsheet_sequence_sync resets the guard flags. I1: acquire/burst + acquire/volume proxy routes forward optional laser_config from body → client. JS runLightsheetAcquire sends laser_config:"ALL OFF" in both bodies (brightfield-safe Manual-view acquire). I2: proxy routes forward optional piezo_center + galvo_center; JS sends piezo_center:_lsPiezo and galvo_center:_lsGalvo (galvo confirmed degrees via sa_offset_y / configure_y_axis chain — same unit as live param). I3: new setLaserOff() helper calls POST /api/devices/laser/off and updates #devices-ls-laser-status from real API result. Called on switchView('manual') and on toggleLightsheetStream start path. exposure: _lsExposure default and HTML input value aligned to 20 ms (matches device-layer _ls_params default). Tests: 4 new proxy tests for laser_config+focal-plane forwarding; streamer _make_dl seeds _ls_parked/_ls_spim_idle. All 22 target tests pass. --- gently/hardware/dispim/device_layer.py | 47 ++++++++++++++----- gently/hardware/dispim/devices/acquisition.py | 8 ++++ gently/ui/web/routes/data.py | 34 +++++++++++++- gently/ui/web/static/js/devices.js | 35 ++++++++++++-- gently/ui/web/templates/index.html | 4 +- tests/test_lightsheet_routes.py | 35 ++++++++++++++ tests/test_lightsheet_streamer.py | 3 ++ 7 files changed, 148 insertions(+), 18 deletions(-) diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 6dc97dbb..0e44ebfa 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -177,6 +177,8 @@ def __init__( 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. @@ -977,22 +979,41 @@ async def _broadcast_camera(self, payload: dict[str, Any]): # ========================================================================= def _park_lightsheet_sync(self) -> None: - """Park scanner galvo + imaging piezo at the current live params (static sheet).""" + """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: - try: - scanner.set_spim_state("Idle") - except Exception: - pass - scanner.sa_offset_y.setPosition(float(p["galvo"])) + 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: - try: - piezo.set_spim_state("Idle") - except Exception: - pass - piezo.setPosition(float(p["piezo"])) + 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.""" @@ -1044,6 +1065,10 @@ def _stop_lightsheet_sequence_sync(self) -> None: 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") 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/data.py b/gently/ui/web/routes/data.py index 625fb449..cfbeb11c 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -575,16 +575,31 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 @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}.""" + """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") @@ -592,14 +607,29 @@ async def acquire_burst(payload: dict = Body(...)): # noqa: B008 @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}.""" + """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") diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 13237d4e..2de449e4 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -90,7 +90,7 @@ const DevicesManager = (function () { // Lightsheet live params — debounced POST to /api/devices/lightsheet/live/params let _lsGalvo = 0; let _lsPiezo = 50; - let _lsExposure = 50; + let _lsExposure = 20; // matches device-layer _ls_params default (20 ms) let _lsParamTimer = null; // Lightsheet control inputs (rail) @@ -98,6 +98,7 @@ const DevicesManager = (function () { 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 @@ -199,6 +200,7 @@ const DevicesManager = (function () { _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'); @@ -1347,10 +1349,27 @@ 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'; @@ -1363,6 +1382,8 @@ const DevicesManager = (function () { } 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}`; @@ -1627,13 +1648,19 @@ const DevicesManager = (function () { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ frames: 10, mode: 'brightfield', - num_slices: 50, exposure_ms: _lsExposure }), + 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 }), + body: JSON.stringify({ num_slices: 50, exposure_ms: _lsExposure, + laser_config: 'ALL OFF', + piezo_center: _lsPiezo, + galvo_center: _lsGalvo }), }); } if (!res.ok) { @@ -1944,6 +1971,8 @@ const DevicesManager = (function () { if (el) TemperatureGraph.init(el, 'current'); } } + // Entering Manual view — gate lasers off immediately (brightfield-safe). + if (viewName === 'manual') setLaserOff(); } function setupViewSwitcher() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index dc6718c2..1b1dba8e 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -752,7 +752,7 @@

Properties

ms
@@ -799,7 +799,7 @@

Properties

- Laser: OFF (brightfield) + Laser: OFF (brightfield)
diff --git a/tests/test_lightsheet_routes.py b/tests/test_lightsheet_routes.py index 4d3cecd9..0f7f172b 100644 --- a/tests/test_lightsheet_routes.py +++ b/tests/test_lightsheet_routes.py @@ -210,6 +210,7 @@ def test_stage_move_missing_xy_400(): # --------------------------------------------------------------------------- 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( @@ -222,11 +223,29 @@ def test_acquire_burst_forwards(): ) +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( @@ -237,6 +256,22 @@ def test_acquire_volume_forwards(): 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 # --------------------------------------------------------------------------- diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py index 78b79c4b..c36377a1 100644 --- a/tests/test_lightsheet_streamer.py +++ b/tests/test_lightsheet_streamer.py @@ -53,6 +53,9 @@ def _make_dl(): "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 From 909aacc6ad4d991f77a3ca3138aece868b5ae3bc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 14:26:46 +0530 Subject: [PATCH 191/314] docs(tactic): spec + plan for temp-change burst tactic (C, 6 tasks) --- .../2026-06-28-temp-change-burst-tactic.md | 280 ++++++++++++++++++ ...6-06-28-temp-change-burst-tactic-design.md | 102 +++++++ 2 files changed, 382 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md create mode 100644 docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md diff --git a/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md new file mode 100644 index 00000000..8c740b3f --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md @@ -0,0 +1,280 @@ +# Temp-Change Burst Tactic (C) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** A scripted temperature-change burst protocol — brightfield bursts before a setpoint change, during the ramp (until lock), and after — launchable as an agent tool, observable on the Experiment tab. + +**Architecture:** A thin async `TimelapseOrchestrator.run_temp_change_burst_protocol` driver composing existing `BurstAcquisition` (extended to force lasers off), `set_temperature`/`get_temperature`, and brightfield primitives; new timeline EventTypes so the tactic + setpoint changes render; an agent tool that launches the driver via `asyncio.create_task`. + +**Tech Stack:** Python asyncio, the gently EventBus + TimelineManager, pytest (`asyncio_mode=auto`). + +## Global Constraints +- C composes A (temperature stamp/persistence) + B1 (`set_laser_config("ALL OFF")`). No new deps. +- Brightfield every burst: `laser_config="ALL OFF"`; lasers never left on, even on error/cancel. +- Lock contract: poll `client.get_temperature()["state"]` until `"LOCKED" in state` (the device reports `'[ SYSTEM LOCKED ]'`). +- Bursts are temperature-stamped automatically (A) and emit `BURST_START/COMPLETE` (render for free). +- New EventTypes use `auto()` (wire serializes `.name`). +- Tests: fakes for client + burst; `asyncio_mode=auto` (no decorator). Rig-deferred: real ramp timing. +- Git hygiene: stage only your files by explicit path; never `git add -A` (pre-existing untracked screenshots/mockups + uv.lock are not yours). + +--- + +### Task 1: Timeline EventTypes for the tactic + +**Files:** Modify `gently/core/event_bus.py` (3 new `auto()` members near the other domain events); Modify `gently/harness/session/timeline.py` (add 3 entries to the EventType→subtype map, near the `BURST_*` entries ~line 213-225). Test: `tests/test_temp_protocol_events.py`. + +**Interfaces:** Produces `EventType.TEMPERATURE_SETPOINT_CHANGED`, `EventType.TEMP_PROTOCOL_STARTED`, `EventType.TEMP_PROTOCOL_COMPLETED`, each mapped to a timeline subtype (`setpoint_changed`, `temp_protocol_started`, `temp_protocol_completed`). + +- [ ] **Step 1: failing test** +```python +# tests/test_temp_protocol_events.py +from gently.core.event_bus import EventType + +def test_new_event_types_exist(): + for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"): + assert getattr(EventType, n).name == n + +def test_timeline_maps_the_subtypes(): + from gently.harness.session import timeline as tl + src = open(tl.__file__, encoding="utf-8").read() + for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"): + assert sub in src +``` +- [ ] **Step 2: run, expect FAIL** — `pytest tests/test_temp_protocol_events.py -v` +- [ ] **Step 3: implement** — in `event_bus.py`, alongside the burst events: +```python + TEMPERATURE_SETPOINT_CHANGED = auto() # discrete setpoint change (timeline) + TEMP_PROTOCOL_STARTED = auto() # temp-change burst protocol began + TEMP_PROTOCOL_COMPLETED = auto() # protocol ended +``` +In `timeline.py`'s map (mirror the `EventType.BURST_START: {...}` entries), add: +```python + EventType.TEMPERATURE_SETPOINT_CHANGED: {"category": "temperature", "event_subtype": "setpoint_changed"}, + EventType.TEMP_PROTOCOL_STARTED: {"category": "tactic", "event_subtype": "temp_protocol_started"}, + EventType.TEMP_PROTOCOL_COMPLETED: {"category": "tactic", "event_subtype": "temp_protocol_completed"}, +``` +> Confirm the real map structure (keys/value shape) from the existing `BURST_START` entry and match it exactly. +- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures. +- [ ] **Step 5: commit** — `git add gently/core/event_bus.py gently/harness/session/timeline.py tests/test_temp_protocol_events.py && git commit -m "feat(tactic): timeline event types for temp-change burst protocol"` + +--- + +### Task 2: Brightfield burst — thread `laser_config` + +**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.__init__` ~line 95 add param; its `client.acquire_burst(...)` call ~line 159-168 pass it); Modify `gently/app/orchestration/timelapse.py` (`queue_burst` ~line 2058 add `laser_config=None`, pass to `BurstAcquisition`). Test: `tests/test_burst_laser_config.py`. + +**Interfaces:** Consumes `client.acquire_burst(..., laser_config=...)` (already accepts it, `client.py:642`). Produces `BurstAcquisition(..., laser_config=None)` and `queue_burst(..., laser_config=None)`. + +- [ ] **Step 1: failing test** +```python +# tests/test_burst_laser_config.py +import asyncio +from gently.app.orchestration.exclusive import BurstAcquisition + +class FakeClient: + def __init__(self): self.calls = [] + async def acquire_burst(self, **kw): self.calls.append(kw); return {"success": True, "request_id": "b1", "frames": []} + +async def test_burst_passes_laser_config(monkeypatch): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF") + assert b._laser_config == "ALL OFF" + # the run() path forwards _laser_config into client.acquire_burst kwargs: + # (unit-level: assert the attribute + that run threads it — see note) +``` +> NOTE: `BurstAcquisition.run` needs an orchestrator with a client + embryo lookup + persistence; a full run is heavy. Assert at minimum that `__init__` stores `_laser_config` and that the `acquire_burst` call site in `run` includes `laser_config=self._laser_config` (verify by reading; optionally add a focused test that monkeypatches the embryo/persistence to capture the `acquire_burst` kwargs). Keep the test honest — if a full run is impractical, assert the attribute and add an inline source check that `laser_config=self._laser_config` appears in the `acquire_burst(...)` call. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — add `laser_config: str | None = None` to `__init__`, store `self._laser_config = laser_config`; in the `client.acquire_burst(...)` call add `laser_config=self._laser_config`. In `queue_burst`, add `laser_config: str | None = None` and pass `laser_config=laser_config` into the `BurstAcquisition(...)` construction. +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): thread laser_config through BurstAcquisition + queue_burst (brightfield bursts)` + +--- + +### Task 3: `wait_for_temperature_lock` helper + +**Files:** Create `gently/app/orchestration/temperature_protocol.py` (module for the helper + later the driver). Test: `tests/test_wait_for_lock.py`. + +**Interfaces:** Produces `async def wait_for_temperature_lock(client, timeout_s, poll_s=2.0) -> bool`. + +- [ ] **Step 1: failing test** +```python +# tests/test_wait_for_lock.py +from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock + +class FakeClient: + def __init__(self, states): self.states = list(states); self.calls = 0 + async def get_temperature(self): + i = min(self.calls, len(self.states) - 1); self.calls += 1 + return {"state": self.states[i]} + +async def test_returns_true_when_locked(): + c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"]) + assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True + +async def test_returns_false_on_timeout(): + c = FakeClient(["[ HEATING ]"]) + assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False +``` +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** +```python +# gently/app/orchestration/temperature_protocol.py +import asyncio, logging +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() + t0 = loop.time() + while True: + try: + resp = await client.get_temperature() + except Exception as exc: + 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) +``` +- [ ] **Step 4: run, expect PASS** +- [ ] **Step 5: commit** — `feat(tactic): wait_for_temperature_lock poll helper` + +--- + +### Task 4: The protocol driver + +**Files:** Modify `gently/app/orchestration/temperature_protocol.py` (add the driver fn that takes the orchestrator). Test: `tests/test_temp_protocol_driver.py`. + +**Interfaces:** Produces `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) -> dict`. `burst_runner` is an injectable `async (BurstAcquisition)->None` for tests (defaults to `lambda b: b.run(orchestrator)`). + +- [ ] **Step 1: failing test** +```python +# tests/test_temp_protocol_driver.py +from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol +from gently.core.event_bus import EventType + +class FakeClient: + def __init__(self): self.laser=None; self.led=None; self.setpoint=None; self._poll=0 + async def set_laser_config(self, c): self.laser=c + async def set_led(self, s): self.led=s + async def set_temperature(self, t): self.setpoint=t + async def get_temperature(self): + self._poll += 1 + return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"} + +class FakeOrch: + def __init__(self, client): self._client=client; self._temperature_provider=lambda: None; self.events=[] + @property + def client(self): return self._client + def _emit_event(self, et, data): self.events.append((et, data)) + +async def test_phase_order_and_brightfield(monkeypatch): + client = FakeClient(); orch = FakeOrch(client) + bursts = [] + async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + res = await run_temp_change_burst_protocol( + orch, "emb1", 25.0, frames=3, bursts_before=1, bursts_after=1, + lock_timeout_s=5.0, poll_s=0.001, burst_runner=runner) + assert client.laser == "ALL OFF" and client.led == "Open" + assert client.setpoint == 25.0 + assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield + assert len(bursts) >= 3 # before + >=1 during + after + ets = [e[0] for e in orch.events] + assert EventType.TEMP_PROTOCOL_STARTED in ets + assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets + assert EventType.TEMP_PROTOCOL_COMPLETED in ets + assert res["locked"] is True +``` +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — append to `temperature_protocol.py`: +```python +from gently.app.orchestration.exclusive import BurstAcquisition +from gently.core.event_bus import EventType + +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): + client = orchestrator.client + if burst_runner is None: + 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._phase = phase + await burst_runner(b) + + 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}) + 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() + 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 + if loop.time() - t0 >= lock_timeout_s: + break + for _ in range(bursts_after): + await one_burst("after") + except asyncio.CancelledError: + cancelled = True; raise + except Exception as exc: + 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}) + return {"locked": locked, "cancelled": cancelled, "error": error} +``` +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): temp-change burst protocol driver (brightfield before/during/after)` + +--- + +### Task 5: Agent tool + +**Files:** Create `gently/app/tools/temperature_protocol_tools.py` (or add to an existing tools module — follow the `@tool` pattern). Test: `tests/test_temp_protocol_tool.py`. + +**Interfaces:** Produces a `run_temp_change_burst_protocol` agent tool that resolves orchestrator+client from `context`, launches the driver via `asyncio.create_task`, returns a started message; validates embryo/client presence. + +- [ ] **Step 1: failing test** — assert the tool, given a context with a fake orchestrator/client, creates a task and returns a "started" string; given no client, returns an error without creating a task. +> Confirm the real `@tool` decorator + context helpers (`ctx_get(context,"client")`, `require_agent`, `require_timelapse_orchestrator`) from an existing tool (e.g. `gently/app/tools/temperature_tools.py`); mirror them. Write the test to the real registration shape. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — mirror an existing tool: resolve `orchestrator` + `client`, guard None (return error dict/string), `asyncio.create_task(run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, frames=frames, bursts_before=bursts_before, bursts_after=bursts_after))`, return `f"Temp-change burst protocol started for {embryo_id} → {target_setpoint_c} C"`. +- [ ] **Step 4: run, expect PASS** +- [ ] **Step 5: commit** — `feat(tactic): run_temp_change_burst_protocol agent tool` + +--- + +### Task 6: Observability in strategy_snapshot + +**Files:** Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` ~line 627; mirror the burst-phase handling at ~762/777). Test: `tests/test_temp_protocol_snapshot.py`. + +**Interfaces:** Consumes the timeline subtypes (`temp_protocol_started/completed`, `setpoint_changed`). Produces, in the snapshot: a `temp_protocol` band (open on started, close on completed) and a `setpoint_changes` list. + +- [ ] **Step 1: failing test** — feed `_replay_timeline` (or `build_strategy_snapshot` over a temp `timeline.jsonl`) a sequence: `temp_protocol_started`, `setpoint_changed(to=25)`, `burst_started/completed`, `temp_protocol_completed`; assert the snapshot exposes a temp_protocol span and a setpoint change of 25. +> Confirm the real `_replay_timeline` input/output shape from the existing burst handling; match the snapshot dict structure it already produces. +- [ ] **Step 2: run, expect FAIL** +- [ ] **Step 3: implement** — in `_replay_timeline`, handle the three subtypes: on `temp_protocol_started` open a band (record start t + params); on `temp_protocol_completed` close it; on `setpoint_changed` append `{t, to}` to a `setpoint_changes` list in the snapshot. Surface them in the returned snapshot dict next to the existing phases. +- [ ] **Step 4: run, expect PASS**; `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(tactic): surface temp-protocol band + setpoint changes in strategy snapshot` + +--- + +## Self-Review +- §2.1 brightfield burst → Task 2; §2.2 wait-for-lock → Task 3; §2.3 driver → Task 4; §2.4 events → Tasks 1 & 6; §2.5 tool → Task 5. ✓ +- Open confirmations (explicit): the timeline map value-shape (Task 1), the burst `run` acquire_burst call site (Task 2), the `@tool` + context helpers (Task 5), the `_replay_timeline` shape (Task 6). Each names a fallback. +- Type consistency: `laser_config="ALL OFF"` everywhere; event names `TEMPERATURE_SETPOINT_CHANGED`/`TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` across Tasks 1/4/6; driver returns `{locked,cancelled,error}`. diff --git a/docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md b/docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md new file mode 100644 index 00000000..3989a19a --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-temp-change-burst-tactic-design.md @@ -0,0 +1,102 @@ +# Design: Temperature-change burst tactic (sub-project C) + +Status: design decided 2026-06-28 (autonomous, from recon; flag any change before the rig run). +Base branch: off `feature/manual-mode-live-view` (B1) — C composes A's temperature persistence/stamp +and B1's `set_laser_config("ALL OFF")` brightfield primitive. + +## 0. Roadmap context + +Track 2, fast-follow to A + B1. C **automates** the hand-driven experiment B1 enables: bursts +before a temperature setpoint change, bursts during the ramp, bursts after lock — all in +brightfield (LED, no laser). It is the repeatable form of next week's experiment. The Operations-tab +observability rework (D) builds on the timeline events C emits. + +## 1. Overview + +The experiment is a **scripted, wall-clock sequence**, not a reactive rule. Recon confirmed +gently's `MonitoringMode`s are reactive rule-installers with no timed loop, so C is built as **an +agent tool that launches an async timed sequence** (a thin `TimelapseOrchestrator` method so it can +reach `_emit_event` / `queue_burst` / the client), composing primitives that already exist: +- bursts with temperature-stamped persistence + `BURST_START/COMPLETE` timeline events + (`exclusive.py` `BurstAcquisition`), +- `set_temperature` / `get_temperature` with the `'[ SYSTEM LOCKED ]'` state contract, +- `set_laser_config("ALL OFF")` + `set_led("Open")` for brightfield. + +## 2. What must be built (four units) + +### 2.1 Brightfield burst (`laser_config` through the burst op) +`BurstAcquisition.run` calls `client.acquire_burst(...)` but never passes `laser_config`, so it +can't force lasers off. Add an optional `laser_config: str | None = None` to +`BurstAcquisition.__init__` (`exclusive.py`) and thread it into the `acquire_burst(...)` call; add +`laser_config=None` to `TimelapseOrchestrator.queue_burst(...)` and pass it through. A brightfield +burst is then `laser_config="ALL OFF"`. + +### 2.2 Wait-for-lock helper +No wait-for-lock exists. Add `async wait_for_temperature_lock(client, timeout_s, poll_s=2.0) -> bool` +(a small util or orchestrator method): poll `client.get_temperature()` until `"LOCKED" in state`, +return True; return False on timeout. Substring match on `'[ SYSTEM LOCKED ]'`. + +### 2.3 The protocol driver +`TimelapseOrchestrator.run_temp_change_burst_protocol(self, 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)` — an `async def`: +1. Brightfield on: `await client.set_laser_config("ALL OFF")`, `await client.set_led("Open")`. +2. Emit `TEMP_PROTOCOL_STARTED` (embryo, target, params). +3. **Before:** run `bursts_before` bursts sequentially — construct + `BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=self._temperature_provider, laser_config="ALL OFF")` and `await burst.run(self)` + (direct sequential run — the protocol owns the sequence; no queue contention for a standalone + experiment). +4. **Change setpoint:** `await client.set_temperature(target_setpoint_c)`; emit + `TEMPERATURE_SETPOINT_CHANGED` (from, to). +5. **During:** loop — run one burst, then `await client.get_temperature()`; break when + `"LOCKED" in state` or `lock_timeout_s` elapsed (event-loop clock). +6. **After:** run `bursts_after` bursts. +7. Emit `TEMP_PROTOCOL_COMPLETED` (counts, final temp). +Each burst is temperature-stamped automatically (A). Bursts emit `BURST_START/COMPLETE` → render +for free. + +### 2.4 Observability events (timeline) +Add `EventType.TEMPERATURE_SETPOINT_CHANGED`, `TEMP_PROTOCOL_STARTED`, `TEMP_PROTOCOL_COMPLETED`. +Map each in `gently/harness/session/timeline.py`'s EventType→subtype map (so they persist to +`timeline.jsonl`). Handle the subtypes in `strategy_snapshot.py:_replay_timeline` — open a +`"temp_protocol"` band on STARTED, close on COMPLETED, and record setpoint-change markers — so the +Experiment/Operations tab shows the tactic. (`TEMPERATURE_UPDATE` stays high-volume/non-persisted; +only the discrete setpoint *change* is a timeline event.) + +### 2.5 Agent tool +`run_temp_change_burst_protocol(embryo_id, target_setpoint_c, frames=60, bursts_before=1, +bursts_after=1, context=...)` in `gently/app/tools/` — resolves the orchestrator + client, launches +the driver via `asyncio.create_task` (the ramp is minutes; don't block the agent turn), returns a +"protocol started" message. Callable from chat (and later a manual-mode button / Operations tab). + +## 3. Data flow +tool → `asyncio.create_task(orchestrator.run_temp_change_burst_protocol(...))` → brightfield set → +bursts (BurstAcquisition.run → client.acquire_burst(laser_config="ALL OFF") → persisted under +`bursts/{id}/` with temperature stamp) → setpoint change (client.set_temperature + sampler records +`temperature.jsonl`) → poll-until-lock → after-bursts → events to EventBus → `timeline.jsonl` → +`strategy_snapshot` → Experiment tab. + +## 4. Error handling +- No client / no embryo → tool returns an error, no task launched. +- A burst failure mid-protocol → log, emit `TEMP_PROTOCOL_COMPLETED` with an `error` field, restore + state (lasers already off). Never leave lasers on. +- Lock never reached → `during` loop exits on `lock_timeout_s`; protocol proceeds to after-bursts and + notes `locked=false`. +- Cancellation (`asyncio.CancelledError`) → stop queuing, emit completion with `cancelled=true`. + +## 5. Testing +- `BurstAcquisition` passes `laser_config` to a fake `acquire_burst` (unit). +- `wait_for_temperature_lock`: a fake client returning non-locked then locked → True; always + non-locked → False on timeout (use a tiny timeout/poll). +- The driver against fakes (fake client + a `BurstAcquisition.run` patched to a no-op recorder): + asserts the phase order (brightfield set → before bursts → setpoint change → during-until-lock → + after bursts → completed), the emitted events, and `laser_config="ALL OFF"` on every burst. +- timeline map + `strategy_snapshot` replay: the three new subtypes open/close a temp_protocol band + + setpoint marker. +- Rig-deferred: the real thermal ramp timing. + +## 6. Out of scope +- The full Operations-tab visual rework (D) — C only emits the events D will render richly. +- A general user-authored tactic library (G). +- Manual-mode UI button to launch the protocol (nice follow-up; chat/tool is enough for next week). From 7ec42d0a8dc1301c009f95fcb415bd89528ca088 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 14:28:14 +0530 Subject: [PATCH 192/314] feat(tactic): timeline event types for temp-change burst protocol Add three EventType members (TEMPERATURE_SETPOINT_CHANGED, TEMP_PROTOCOL_STARTED, TEMP_PROTOCOL_COMPLETED) to event_bus.py and map them to timeline subtypes in timeline.py for persistence to timeline.jsonl. Includes unit tests. --- gently/core/event_bus.py | 5 +++++ gently/harness/session/timeline.py | 18 ++++++++++++++++++ tests/test_temp_protocol_events.py | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 tests/test_temp_protocol_events.py diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index 9774bdaa..ee89ab7c 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -127,6 +127,11 @@ class EventType(Enum): BURST_FRAME = auto() # {embryo_id, request_id, frame_idx, total_frames} BURST_COMPLETE = auto() # {embryo_id, request_id, mp4_path, sustained_hz, frames_captured} + # Temperature protocol events (Phase X / 10) — temp-change burst tactic + TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, old_temp, new_temp} + TEMP_PROTOCOL_STARTED = auto() # {embryo_id, protocol_name} + TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, protocol_name, success} + # Reactive control telemetry (Phase 5 / 10) POWER_RAMP_STEP = auto() # {embryo_id, rule, wavelength, old_pct, new_pct, direction} diff --git a/gently/harness/session/timeline.py b/gently/harness/session/timeline.py index b0ae7b92..4f227ec3 100644 --- a/gently/harness/session/timeline.py +++ b/gently/harness/session/timeline.py @@ -228,6 +228,24 @@ def description(self) -> str: "icon": "v", "severity": "success", }, + EventType.TEMPERATURE_SETPOINT_CHANGED: { + "event_type": "temperature", + "event_subtype": "setpoint_changed", + "icon": "T", + "severity": "info", + }, + EventType.TEMP_PROTOCOL_STARTED: { + "event_type": "tactic", + "event_subtype": "temp_protocol_started", + "icon": "~", + "severity": "info", + }, + EventType.TEMP_PROTOCOL_COMPLETED: { + "event_type": "tactic", + "event_subtype": "temp_protocol_completed", + "icon": "+", + "severity": "success", + }, } diff --git a/tests/test_temp_protocol_events.py b/tests/test_temp_protocol_events.py new file mode 100644 index 00000000..7344a985 --- /dev/null +++ b/tests/test_temp_protocol_events.py @@ -0,0 +1,17 @@ +"""Test for temperature protocol event types (Task 1 of temp-change-burst-tactic)""" + +from gently.core.event_bus import EventType + + +def test_new_event_types_exist(): + """Verify the three new EventType members exist""" + for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"): + assert getattr(EventType, n).name == n + + +def test_timeline_maps_the_subtypes(): + """Verify timeline.py contains the mapping entries with correct subtypes""" + from gently.harness.session import timeline as tl + src = open(tl.__file__, encoding="utf-8").read() + for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"): + assert sub in src From 5412c3067daffdcde02977dea7c7c7589ebc8710 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 14:54:28 +0530 Subject: [PATCH 193/314] feat(tactic): thread laser_config through BurstAcquisition + queue_burst (brightfield bursts) --- gently/app/orchestration/exclusive.py | 3 +++ gently/app/orchestration/timelapse.py | 2 ++ tests/test_burst_laser_config.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 tests/test_burst_laser_config.py diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index 83b196d7..b741e57d 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -101,12 +101,14 @@ def __init__( num_slices: int = 1, request_id: str | None = None, temperature_provider=None, + laser_config: str | None = None, ): super().__init__(target_embryo_id=target_embryo_id, request_id=request_id) self.frames = frames self.mode = mode if mode in ("1hz", "asap") else "1hz" self.num_slices = num_slices self._temperature_provider = temperature_provider + self._laser_config = laser_config async def run(self, orchestrator) -> ExclusiveResult: from gently.core import EventType @@ -166,6 +168,7 @@ async def run(self, orchestrator) -> ExclusiveResult: piezo_amplitude=piezo_amplitude, piezo_center=piezo_center, laser_power_488_pct=getattr(embryo, "laser_power_488_pct", None), + laser_config=self._laser_config, ) except Exception as e: logger.error("Burst failed for %s: %s", self.target_embryo_id, e) diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index 5918bd9c..c640f926 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -2061,6 +2061,7 @@ def queue_burst( mode: str = "1hz", num_slices: int = 1, force: bool = False, + laser_config: str | None = None, ) -> str: """Queue a burst acquisition for ``embryo_id``. @@ -2100,6 +2101,7 @@ def queue_burst( mode=mode, num_slices=num_slices, temperature_provider=self._temperature_provider, + laser_config=laser_config, ) self._exclusive_queue.append(op) logger.info( diff --git a/tests/test_burst_laser_config.py b/tests/test_burst_laser_config.py new file mode 100644 index 00000000..9e973715 --- /dev/null +++ b/tests/test_burst_laser_config.py @@ -0,0 +1,23 @@ +"""Task 2: BurstAcquisition laser_config threading tests.""" +import asyncio +import pytest +from gently.app.orchestration.exclusive import BurstAcquisition + + +class FakeClient: + def __init__(self): + self.calls = [] + + async def acquire_burst(self, **kw): + self.calls.append(kw) + return {"success": True, "request_id": "b1", "frames": []} + + +async def test_burst_passes_laser_config(monkeypatch): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF") + assert b._laser_config == "ALL OFF" + + +async def test_burst_laser_config_default_none(): + b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1) + assert b._laser_config is None From 030a4434491dfd72ca7e9f89ecd40d3535edc587 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:04:41 +0530 Subject: [PATCH 194/314] feat(tactic): wait_for_temperature_lock poll helper --- .../app/orchestration/temperature_protocol.py | 17 +++++++++++++++++ tests/test_wait_for_lock.py | 15 +++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 gently/app/orchestration/temperature_protocol.py create mode 100644 tests/test_wait_for_lock.py diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py new file mode 100644 index 00000000..d3bc25c2 --- /dev/null +++ b/gently/app/orchestration/temperature_protocol.py @@ -0,0 +1,17 @@ +import asyncio, logging +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() + t0 = loop.time() + while True: + try: + resp = await client.get_temperature() + except Exception as exc: + 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) diff --git a/tests/test_wait_for_lock.py b/tests/test_wait_for_lock.py new file mode 100644 index 00000000..6ee8fbe3 --- /dev/null +++ b/tests/test_wait_for_lock.py @@ -0,0 +1,15 @@ +from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock + +class FakeClient: + def __init__(self, states): self.states = list(states); self.calls = 0 + async def get_temperature(self): + i = min(self.calls, len(self.states) - 1); self.calls += 1 + return {"state": self.states[i]} + +async def test_returns_true_when_locked(): + c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"]) + assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True + +async def test_returns_false_on_timeout(): + c = FakeClient(["[ HEATING ]"]) + assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False From 28abaa1fede1cdcac1230aeeff11a113bc2b23cc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:14:40 +0530 Subject: [PATCH 195/314] feat(tactic): temp-change burst protocol driver (brightfield before/during/after) --- .../app/orchestration/temperature_protocol.py | 52 +++++++++++++++++++ tests/test_temp_protocol_driver.py | 39 ++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/test_temp_protocol_driver.py diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py index d3bc25c2..60058133 100644 --- a/gently/app/orchestration/temperature_protocol.py +++ b/gently/app/orchestration/temperature_protocol.py @@ -1,4 +1,6 @@ import asyncio, 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: @@ -15,3 +17,53 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. 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): + """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 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._phase = phase + await burst_runner(b) + + 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}) + 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() + 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 + if loop.time() - t0 >= lock_timeout_s: + break + for _ in range(bursts_after): + await one_burst("after") + except asyncio.CancelledError: + cancelled = True; raise + except Exception as exc: + 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}) + return {"locked": locked, "cancelled": cancelled, "error": error} diff --git a/tests/test_temp_protocol_driver.py b/tests/test_temp_protocol_driver.py new file mode 100644 index 00000000..b2cdb62d --- /dev/null +++ b/tests/test_temp_protocol_driver.py @@ -0,0 +1,39 @@ +import pytest +from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol +from gently.core.event_bus import EventType + + +class FakeClient: + def __init__(self): self.laser=None; self.led=None; self.setpoint=None; self._poll=0 + async def set_laser_config(self, c): self.laser=c + async def set_led(self, s): self.led=s + async def set_temperature(self, t): self.setpoint=t + async def get_temperature(self): + self._poll += 1 + return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"} + + +class FakeOrch: + def __init__(self, client): self._client=client; self._temperature_provider=lambda: None; self.events=[] + @property + def client(self): return self._client + def _emit_event(self, et, data): self.events.append((et, data)) + + +@pytest.mark.asyncio +async def test_phase_order_and_brightfield(monkeypatch): + client = FakeClient(); orch = FakeOrch(client) + bursts = [] + async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + res = await run_temp_change_burst_protocol( + orch, "emb1", 25.0, frames=3, bursts_before=1, bursts_after=1, + lock_timeout_s=5.0, poll_s=0.001, burst_runner=runner) + assert client.laser == "ALL OFF" and client.led == "Open" + assert client.setpoint == 25.0 + assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield + assert len(bursts) >= 3 # before + >=1 during + after + ets = [e[0] for e in orch.events] + assert EventType.TEMP_PROTOCOL_STARTED in ets + assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets + assert EventType.TEMP_PROTOCOL_COMPLETED in ets + assert res["locked"] is True From 6af4396f22a310b91f0b0ca6672dcfc387ccda61 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:23:28 +0530 Subject: [PATCH 196/314] feat(tactic): run_temp_change_burst_protocol agent tool Adds temperature_protocol_tools.py with the @tool-decorated run_temp_change_burst_protocol_tool: resolves agent/orchestrator/client from context, guards all three (returns error strings if absent), then launches the driver via asyncio.create_task so the thermal ramp runs in the background and the agent turn returns immediately. Registered in tools/__init__.py. TDD: 5 tests covering happy path (task created + started message), no-client, no-orchestrator, no-agent, and registry discoverability. --- gently/app/tools/__init__.py | 2 + .../app/tools/temperature_protocol_tools.py | 94 ++++++++++ tests/test_temp_protocol_tool.py | 174 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 gently/app/tools/temperature_protocol_tools.py create mode 100644 tests/test_temp_protocol_tool.py diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index d8ab70db..16d7aebb 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -42,6 +42,7 @@ resolution_tools, session_tools, stage_tools, + temperature_protocol_tools, temperature_tools, timelapse_tools, volume_tools, @@ -88,6 +89,7 @@ def register_all_tools(): "resolution_tools", "session_tools", "stage_tools", + "temperature_protocol_tools", "temperature_tools", "timelapse_tools", "volume_tools", diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py new file mode 100644 index 00000000..dd25d2d5 --- /dev/null +++ b/gently/app/tools/temperature_protocol_tools.py @@ -0,0 +1,94 @@ +""" +Temperature Protocol Tools + +Agent tool for the scripted temp-change burst protocol. The thermal ramp takes +several minutes so the driver runs as a background asyncio task — the agent turn +returns immediately with a "started" confirmation. +""" + +import asyncio + +from gently.harness.tools.helpers import ctx_get, require_agent, require_microscope, require_timelapse_orchestrator +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + + +@tool( + name="run_temp_change_burst_protocol", + description=( + "Start a scripted temperature-change burst protocol in the background. " + "Acquires brightfield burst(s) before the setpoint change, then polls the " + "thermal controller — imaging continuously during the ramp — and acquires " + "burst(s) after lock is reached. " + "The thermal ramp typically takes several minutes; this tool returns " + "immediately and the protocol runs as a background task. " + "Use when you want time-resolved brightfield data spanning a temperature " + "transition (e.g. cold-shock → recovery, 15 C → 25 C developmental-rate shift)." + ), + category=ToolCategory.EXPERIMENT, + requires_microscope=True, + examples=[ + ToolExample( + "Capture a temperature shift from 15 C to 25 C for embryo_1", + {"embryo_id": "embryo_1", "target_setpoint_c": 25.0}, + ), + ToolExample( + "Cold-shock embryo_2 to 15 C with 2 bursts before and after", + {"embryo_id": "embryo_2", "target_setpoint_c": 15.0, "bursts_before": 2, "bursts_after": 2}, + ), + ], +) +async def run_temp_change_burst_protocol_tool( + embryo_id: str, + target_setpoint_c: float, + frames: int = 60, + bursts_before: int = 1, + bursts_after: int = 1, + context: dict | None = None, +) -> str: + """Launch the temp-change burst driver as a background asyncio task. + + Parameters + ---------- + embryo_id : str + ID of the embryo to image during the temperature transition. + target_setpoint_c : float + Target temperature setpoint in degrees Celsius. + frames : int + Number of frames per burst acquisition (default 60). + bursts_before : int + Number of brightfield bursts to capture before the setpoint change (default 1). + bursts_after : int + Number of brightfield bursts to capture after the controller locks (default 1). + """ + agent, err = require_agent(context) + if err: + return err + + orchestrator, err = require_timelapse_orchestrator(agent) + if err: + return err + + client, err = require_microscope(context) + if err: + return err + + from gently.app.orchestration.temperature_protocol import ( + run_temp_change_burst_protocol as _driver, + ) + + asyncio.create_task( + _driver( + orchestrator, + embryo_id, + target_setpoint_c, + frames=frames, + bursts_before=bursts_before, + bursts_after=bursts_after, + ) + ) + + return ( + f"Temp-change burst protocol started for {embryo_id} → {target_setpoint_c} C " + f"({bursts_before} burst(s) before, {bursts_after} burst(s) after lock). " + f"Running in background — use get_temperature to monitor ramp progress." + ) diff --git a/tests/test_temp_protocol_tool.py b/tests/test_temp_protocol_tool.py new file mode 100644 index 00000000..a992f5d9 --- /dev/null +++ b/tests/test_temp_protocol_tool.py @@ -0,0 +1,174 @@ +""" +Tests for the run_temp_change_burst_protocol agent tool (Task 5). + +TDD: write failing tests first, then implement the tool. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeClient: + """Minimal fake microscope client.""" + async def set_temperature(self, t): + return {"success": True, "temperature_c": t, "state": "[ HEATING ]"} + + async def get_temperature(self): + return {"success": True, "temperature_c": 20.0, "state": "[ SYSTEM LOCKED ]"} + + +class FakeOrchestrator: + """Minimal fake timelapse orchestrator with a client attribute.""" + def __init__(self, client): + self.client = client + + def _emit_event(self, *args, **kwargs): + pass + + +class FakeAgent: + """Minimal fake agent — carries timelapse_orchestrator.""" + def __init__(self, orchestrator): + self.timelapse_orchestrator = orchestrator + + +def _make_context(*, with_client=True, with_orchestrator=True): + """Build a fake context dict.""" + client = FakeClient() if with_client else None + orchestrator = FakeOrchestrator(client) if with_orchestrator else None + agent = FakeAgent(orchestrator) if with_orchestrator else FakeAgent(None) + return {"agent": agent, "client": client} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_tool_creates_task_and_returns_started(): + """Happy path: context has agent+orchestrator+client → task is created, 'started' returned.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=True, with_orchestrator=True) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + # Cancel the coroutine immediately so there's no dangling task + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + frames=30, + bursts_before=1, + bursts_after=1, + context=context, + ) + + assert len(created_tasks) == 1, "Expected exactly one asyncio task to be created" + assert "started" in result.lower(), f"Expected 'started' in result, got: {result!r}" + assert "emb1" in result, f"Expected embryo_id in result, got: {result!r}" + assert "25.0" in result or "25" in result, f"Expected setpoint in result, got: {result!r}" + + +@pytest.mark.asyncio +async def test_tool_no_client_returns_error_no_task(): + """No client in context → returns error string, no asyncio task created.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=False, with_orchestrator=True) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when client is absent" + assert "error" in result.lower() or "not connected" in result.lower(), ( + f"Expected error message, got: {result!r}" + ) + + +@pytest.mark.asyncio +async def test_tool_no_orchestrator_returns_error_no_task(): + """No timelapse orchestrator → returns error string, no asyncio task created.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = _make_context(with_client=True, with_orchestrator=False) + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when orchestrator is absent" + assert "error" in result.lower() or "not initialized" in result.lower(), ( + f"Expected error message, got: {result!r}" + ) + + +@pytest.mark.asyncio +async def test_tool_no_agent_context_returns_error(): + """No agent in context → returns error immediately.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + + context = {"client": FakeClient()} # no "agent" key + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0 + assert "error" in result.lower(), f"Expected error message, got: {result!r}" + + +def test_tool_is_registered(): + """The tool should be discoverable in the global tool registry after package import.""" + import gently.app.tools # noqa: F401 — triggers registration + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + tool_names = [t.name for t in registry.list_all()] + assert "run_temp_change_burst_protocol" in tool_names, ( + f"Tool not found in registry. Registered tools: {tool_names}" + ) From c986c92fd5262c74c9311dfa208cfdb31327a3c5 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 15:47:15 +0530 Subject: [PATCH 197/314] feat(tactic): surface temp-protocol band + setpoint changes in strategy snapshot Initialize temp_protocol (None) and setpoint_changes ([]) on every embryo dict in _build_embryos_static. In _replay_timeline, handle the three new subtypes from Task 1: temp_protocol_started opens a band (start + params), temp_protocol_completed closes it (end), setpoint_changed appends {t, to} to the list. Bursts continue to render as burst phases unchanged. --- gently/ui/web/strategy_snapshot.py | 25 ++++ tests/test_temp_protocol_snapshot.py | 190 +++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/test_temp_protocol_snapshot.py diff --git a/gently/ui/web/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py index 91c560e7..4948ae9b 100644 --- a/gently/ui/web/strategy_snapshot.py +++ b/gently/ui/web/strategy_snapshot.py @@ -552,6 +552,9 @@ def _build_embryos_static( ], "trigger_events": [], "power_history_488": [{"at": 0.0, "pct": float(laser_488)}], + # Filled in by _replay_timeline when temperature-tactic events arrive. + "temp_protocol": None, + "setpoint_changes": [], # Filled in by _project_forward. "projected_cadence_s": initial_cadence, "projected_end_s": None, @@ -782,6 +785,28 @@ def _replay_timeline( # closed at now_offset_s by the tail pass below. _close_open_phase(emb, at_s) + elif subtype == "temp_protocol_started" and embryo_id in by_id: + emb = by_id[embryo_id] + emb["temp_protocol"] = { + "start": at_s, + "end": None, + "target_setpoint_c": data.get("target_setpoint_c"), + "frames": data.get("frames"), + "bursts_before": data.get("bursts_before"), + "bursts_after": data.get("bursts_after"), + } + + elif subtype == "temp_protocol_completed" and embryo_id in by_id: + emb = by_id[embryo_id] + tp = emb.get("temp_protocol") + if tp is not None: + tp["end"] = at_s + + elif subtype == "setpoint_changed" and embryo_id in by_id: + emb = by_id[embryo_id] + to_val = data.get("to") + emb["setpoint_changes"].append({"t": at_s, "to": to_val}) + elif subtype == "stopped": # Session-level stop: close every open phase at this time. for emb in embryo_dicts: diff --git a/tests/test_temp_protocol_snapshot.py b/tests/test_temp_protocol_snapshot.py new file mode 100644 index 00000000..574940fe --- /dev/null +++ b/tests/test_temp_protocol_snapshot.py @@ -0,0 +1,190 @@ +"""Task 6: temp-protocol band + setpoint changes visible in strategy_snapshot. + +Feed _replay_timeline (via build_strategy_snapshot over a hand-written +timeline.jsonl) a sequence: + temp_protocol_started → setpoint_changed(to=25) → + burst_started → burst_completed → temp_protocol_completed + +Assert the returned snapshot exposes: + embryos[0]["temp_protocol"] — a band with start < end, correct params + embryos[0]["setpoint_changes"] — [{t: ..., to: 25}] +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +import yaml + +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +SESSION_ID = "sess-tp1" +EMBRYO_ID = "e1" +STARTED_AT = datetime(2025, 1, 1, 10, 0, 0) +TARGET_C = 25.0 + + +def _ts(delta_s: float) -> str: + return (STARTED_AT + timedelta(seconds=delta_s)).isoformat() + + +def _write_session(session_dir: Path) -> None: + session_dir.mkdir(parents=True, exist_ok=True) + + # Minimal session.yaml + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "temp-protocol test"}), encoding="utf-8" + ) + + # timelapse.yaml — minimal; one embryo + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": { + EMBRYO_ID: {"interval_seconds": 120}, + }, + } + (session_dir / "timelapse.yaml").write_text( + yaml.dump(timelapse), encoding="utf-8" + ) + + # timeline.jsonl — the sequence under test + events = [ + { + "event_id": "ev-tps", + "event_type": "tactic", + "event_subtype": "temp_protocol_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": { + "embryo_id": EMBRYO_ID, + "target_setpoint_c": TARGET_C, + "frames": 60, + "bursts_before": 1, + "bursts_after": 1, + }, + }, + { + "event_id": "ev-sc", + "event_type": "temperature", + "event_subtype": "setpoint_changed", + "timestamp": _ts(20), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "to": TARGET_C}, + }, + { + "event_id": "ev-bs", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(30), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 60}, + }, + { + "event_id": "ev-bc", + "event_type": "timelapse", + "event_subtype": "burst_completed", + "timestamp": _ts(40), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID}, + }, + { + "event_id": "ev-tpc", + "event_type": "tactic", + "event_subtype": "temp_protocol_completed", + "timestamp": _ts(50), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "locked": True, "cancelled": False, "error": None}, + }, + ] + timeline_path = session_dir / "timeline.jsonl" + with open(timeline_path, "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_temp_protocol_band_present(tmp_path: Path) -> None: + """Snapshot exposes a temp_protocol band with start and end on the embryo.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + assert snap["embryos"], "no embryos in snapshot" + emb = snap["embryos"][0] + + tp = emb.get("temp_protocol") + assert tp is not None, "temp_protocol key missing from embryo" + assert tp["start"] == pytest.approx(10.0), f"expected start=10s, got {tp['start']}" + assert tp["end"] == pytest.approx(50.0), f"expected end=50s, got {tp['end']}" + assert tp["target_setpoint_c"] == pytest.approx(TARGET_C) + + +def test_setpoint_changes_recorded(tmp_path: Path) -> None: + """Snapshot exposes setpoint_changes list with one entry {t, to}.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + sc = emb.get("setpoint_changes") + assert sc is not None, "setpoint_changes key missing from embryo" + assert len(sc) == 1, f"expected 1 setpoint change, got {len(sc)}" + assert sc[0]["t"] == pytest.approx(20.0) + assert sc[0]["to"] == pytest.approx(TARGET_C) + + +def test_burst_phase_still_present(tmp_path: Path) -> None: + """Existing burst phase handling is not disrupted by temp-protocol events.""" + session_dir = tmp_path / "sessions" / "20250101_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase disappeared after temp-protocol changes" + bp = burst_phases[0] + assert bp["start"] == pytest.approx(30.0) + # end is extended to now_offset_s by the tail-close sweep when no + # cadence_changed follows — just confirm it's set and >= burst start + assert bp["end"] is not None and bp["end"] >= 30.0 + + +def test_temp_protocol_fields_initialized_even_without_events(tmp_path: Path) -> None: + """Embryo dicts always have temp_protocol and setpoint_changes, even with no events.""" + session_dir = tmp_path / "sessions" / "20250101_1000_empty" + session_dir.mkdir(parents=True, exist_ok=True) + (session_dir / "session.yaml").write_text("{}", encoding="utf-8") + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": {EMBRYO_ID: {"interval_seconds": 120}}, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + # No timeline.jsonl + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + emb = snap["embryos"][0] + assert "temp_protocol" in emb, "temp_protocol key absent with no events" + assert "setpoint_changes" in emb, "setpoint_changes key absent with no events" + assert emb["temp_protocol"] is None + assert emb["setpoint_changes"] == [] From dc0387251da3314792a0901741b92f424b9e6c4b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:13:31 +0530 Subject: [PATCH 198/314] fix(tactic): correct event docstrings, guard against concurrent timelapse, harden driver test - event_bus.py: fix TEMPERATURE_SETPOINT_CHANGED/TEMP_PROTOCOL_STARTED/TEMP_PROTOCOL_COMPLETED inline comments to match actual emitted payloads ({embryo_id,to} / {embryo_id,target_setpoint_c, frames,bursts_before,bursts_after} / {embryo_id,locked,cancelled,error}) - temperature_protocol_tools.py: refuse to launch if orchestrator._status == TimelapseStatus.RUNNING (would contend with timelapse RunEngine); returns clear refusal string, no task created - test_temp_protocol_driver.py: assert burst phase sequence (first=="before", last=="after") and event order (STARTED < SETPOINT_CHANGED < COMPLETED) - test_temp_protocol_tool.py: add test_tool_refuses_during_active_timelapse for the guard --- .../app/tools/temperature_protocol_tools.py | 7 +++++ gently/core/event_bus.py | 6 ++-- tests/test_temp_protocol_driver.py | 12 ++++++++ tests/test_temp_protocol_tool.py | 30 +++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py index dd25d2d5..678b08b8 100644 --- a/gently/app/tools/temperature_protocol_tools.py +++ b/gently/app/tools/temperature_protocol_tools.py @@ -75,6 +75,13 @@ async def run_temp_change_burst_protocol_tool( from gently.app.orchestration.temperature_protocol import ( run_temp_change_burst_protocol as _driver, ) + from gently.app.orchestration.timelapse_models import TimelapseStatus + + if getattr(orchestrator, "_status", None) == TimelapseStatus.RUNNING: + return ( + "Refusing to start temp-change protocol: a timelapse is currently running " + "(would contend for the RunEngine). Stop the timelapse first." + ) asyncio.create_task( _driver( diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index ee89ab7c..0b5db1cd 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -128,9 +128,9 @@ class EventType(Enum): BURST_COMPLETE = auto() # {embryo_id, request_id, mp4_path, sustained_hz, frames_captured} # Temperature protocol events (Phase X / 10) — temp-change burst tactic - TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, old_temp, new_temp} - TEMP_PROTOCOL_STARTED = auto() # {embryo_id, protocol_name} - TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, protocol_name, success} + TEMPERATURE_SETPOINT_CHANGED = auto() # {embryo_id, to} + TEMP_PROTOCOL_STARTED = auto() # {embryo_id, target_setpoint_c, frames, bursts_before, bursts_after} + TEMP_PROTOCOL_COMPLETED = auto() # {embryo_id, locked, cancelled, error} # Reactive control telemetry (Phase 5 / 10) POWER_RAMP_STEP = auto() # {embryo_id, rule, wavelength, old_pct, new_pct, direction} diff --git a/tests/test_temp_protocol_driver.py b/tests/test_temp_protocol_driver.py index b2cdb62d..aa112f03 100644 --- a/tests/test_temp_protocol_driver.py +++ b/tests/test_temp_protocol_driver.py @@ -37,3 +37,15 @@ async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser" assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets assert EventType.TEMP_PROTOCOL_COMPLETED in ets assert res["locked"] is True + + # Phase order: first burst must be "before", last must be "after" + phases = [b["phase"] for b in bursts] + assert phases[0] == "before", f"Expected first burst phase 'before', got {phases[0]!r}" + assert phases[-1] == "after", f"Expected last burst phase 'after', got {phases[-1]!r}" + + # Event order: STARTED < SETPOINT_CHANGED < COMPLETED + idx_started = ets.index(EventType.TEMP_PROTOCOL_STARTED) + idx_changed = ets.index(EventType.TEMPERATURE_SETPOINT_CHANGED) + idx_completed = ets.index(EventType.TEMP_PROTOCOL_COMPLETED) + assert idx_started < idx_changed, "TEMP_PROTOCOL_STARTED must precede TEMPERATURE_SETPOINT_CHANGED" + assert idx_changed < idx_completed, "TEMPERATURE_SETPOINT_CHANGED must precede TEMP_PROTOCOL_COMPLETED" diff --git a/tests/test_temp_protocol_tool.py b/tests/test_temp_protocol_tool.py index a992f5d9..519c4375 100644 --- a/tests/test_temp_protocol_tool.py +++ b/tests/test_temp_protocol_tool.py @@ -172,3 +172,33 @@ def test_tool_is_registered(): assert "run_temp_change_burst_protocol" in tool_names, ( f"Tool not found in registry. Registered tools: {tool_names}" ) + + +@pytest.mark.asyncio +async def test_tool_refuses_during_active_timelapse(): + """If orchestrator._status == RUNNING, refuse without creating a task.""" + from gently.app.tools.temperature_protocol_tools import run_temp_change_burst_protocol_tool + from gently.app.orchestration.timelapse_models import TimelapseStatus + + context = _make_context(with_client=True, with_orchestrator=True) + # Simulate an active timelapse + context["agent"].timelapse_orchestrator._status = TimelapseStatus.RUNNING + + created_tasks = [] + + def fake_create_task(coro, **kwargs): + coro.close() + mock_task = MagicMock() + created_tasks.append(mock_task) + return mock_task + + with patch("asyncio.create_task", side_effect=fake_create_task): + result = await run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=context, + ) + + assert len(created_tasks) == 0, "No task should be created when timelapse is running" + assert "refusing" in result.lower(), f"Expected refusal message, got: {result!r}" + assert "timelapse" in result.lower(), f"Expected 'timelapse' in refusal message, got: {result!r}" From 59c429ab8393cbdeb1777fce2bce92a72d511ad8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:34:41 +0530 Subject: [PATCH 199/314] docs(operations): spec + plan for Operations tab (D, 4 tasks) --- .../plans/2026-06-28-operations-tab.md | 103 ++++++++++++++++++ .../specs/2026-06-28-operations-tab-design.md | 84 ++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-operations-tab.md create mode 100644 docs/superpowers/specs/2026-06-28-operations-tab-design.md diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md new file mode 100644 index 00000000..8ce94846 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -0,0 +1,103 @@ +# Operations Tab (D) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the composed temp-change tactic observable: rename Experiment→Operations, render the protocol band + setpoint markers + phased bursts on the swimlane, and make the tab refresh live. + +**Architecture:** Reuse `GET /api/experiments/{session}/strategy` (C added `temp_protocol` + `setpoint_changes` per-embryo). One backend enabler (emit burst `phase`), the rest is `experiment-overview.js` rendering + live event subscriptions. + +**Tech Stack:** vanilla-JS + hand-rolled SVG (`experiment-overview.js`), the EventBus→websocket relay, pytest for the small backend bit. + +## Global Constraints +- No new endpoints; reuse `/strategy`. C owns the snapshot builder; D consumes its fields. +- Snapshot fields (per-embryo, from C Task 6): `temp_protocol: {start, end, target_setpoint_c, frames, bursts_before, bursts_after} | None`, `setpoint_changes: [{t, to}]`. +- Rename is label-only (`index.html:119` nav text, `:393` `

`); leave ids + `TABS.EXPERIMENT` unchanged. +- Tactic name is a frontend constant keyed off `temp_protocol` (events carry no name). +- Build against the REAL emitter payloads, not the stale enum docstrings. +- Live: subscribe via `ClientEventBus.on(...)` (mirror `experiment-strip.js:23`), debounced (~500ms) `loadStrategy()`+`render()`. Relay already emits all events by `.name` — no backend change. +- Frontend: no JS unit harness → `node --check` + Chrome-MCP harness + UI audit; live rig verification deferred. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Rename Experiment → Operations (label-only) +**Files:** Modify `gently/ui/web/templates/index.html` (nav button text `:119`, `

` `:393`). Test: none (pure copy) — verified by `grep`. +- [ ] **Step 1:** change the nav button text at `:119` from `Experiment` to `Operations`, and the `

` at `:393` from `Experiment` to `Operations`. Leave `data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`, `TABS.EXPERIMENT` untouched. +- [ ] **Step 2:** `grep -n ">Operations<" gently/ui/web/templates/index.html` shows both; `grep -n "data-tab=\"experiment\"" ` still present (ids unchanged). +- [ ] **Step 3: commit** — `git add gently/ui/web/templates/index.html && git commit -m "feat(operations): rename Experiment tab to Operations (label only)"` + +--- + +### Task 2: Emit burst `phase` + record it in the snapshot +**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.run` BURST_START emit ~line 127-135 add `phase`); Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` `burst_started` handler ~line 762 record `phase` on the burst phase dict). Test: `tests/test_burst_phase_snapshot.py`. + +**Interfaces:** Produces `BURST_START` data with `"phase": getattr(self, "_phase", None)`; snapshot burst phase dict gains `"phase"`. + +- [ ] **Step 1: failing test** +```python +# tests/test_burst_phase_snapshot.py +import json, os +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +def _write(tmp_path, events): + sd = tmp_path / "sess"; (sd / "embryos" / "e1").mkdir(parents=True) + (sd / "session.yaml").write_text("session_id: s1\nname: t\nstarted_at: '2026-06-28T10:00:00'\n") + (sd / "embryos" / "e1" / "embryo.yaml").write_text("embryo_id: e1\nrole: Test\n") + with open(sd / "timeline.jsonl", "w") as f: + for e in events: f.write(json.dumps(e) + "\n") + return sd + +def test_burst_phase_recorded(tmp_path): + sd = _write(tmp_path, [ + {"event_subtype": "burst_started", "embryo_id": "e1", "timestamp": "2026-06-28T10:00:10", + "data": {"embryo_id": "e1", "frames": 60, "mode": "1hz", "phase": "during"}}, + {"event_subtype": "burst_completed", "embryo_id": "e1", "timestamp": "2026-06-28T10:01:10", + "data": {"embryo_id": "e1"}}, + ]) + snap = build_strategy_snapshot(str(sd), "s1") + emb = next(e for e in snap["embryos"] if e["id"] == "e1") + burst = next(p for p in emb["phases"] if p.get("mode") == "burst") + assert burst.get("phase") == "during" +``` +> Confirm the real `build_strategy_snapshot` session-dir layout + timeline line shape from C Task 6's test (`tests/test_temp_protocol_snapshot.py`) and match it (session.yaml keys, embryo.yaml, timeline event shape). Adapt `_write` to the real shape; keep the `phase` assertion. +- [ ] **Step 2: run, expect FAIL** (phase is None / key absent) +- [ ] **Step 3: implement** — in `exclusive.py` `BurstAcquisition.run`, add `"phase": getattr(self, "_phase", None)` to the `BURST_START` event `data` dict. In `strategy_snapshot.py` `burst_started` handler, add `"phase": data.get("phase")` to the appended burst phase dict (next to `frames`/`hz`). +- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures. +- [ ] **Step 5: commit** — `feat(operations): emit burst phase in BURST_START + record in snapshot` + +--- + +### Task 3: Render the tactic band on the swimlane +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`_renderSwimlanes` ~line 332: insert a session-level band; helpers for the protocol pill + setpoint markers, reusing the chip idiom ~728-748). Verify: `node --check` + Chrome-MCP harness. + +- [ ] **Step 1: build the band** — In `_renderSwimlanes`, before drawing embryo rows (which start at `TOP_AXIS_H`), aggregate the protocol from the embryos: find the embryo with a non-null `temp_protocol` (and its `setpoint_changes`). If present, insert a band of fixed height (e.g. 28px) and shift embryo rows down by that height: + - a **tactic pill** rect spanning `xFor(temp_protocol.start)`..`xFor(temp_protocol.end ?? now)`, labeled `Temp-change burst → ${temp_protocol.target_setpoint_c}°C` (a frontend constant prefix); + - for each `setpoint_changes[i]`: a vertical marker line at `xFor(.t)` + a `→ ${.to}°C` chip (reuse the existing chip-drawing helper used for cadence/power chips ~728-748); + - tint the burst blocks (already drawn per-embryo ~558-575) by their `phase` (before/during/after) — e.g. a subtle hue or a 1-char badge — using the `phase` field added in Task 2. + If no embryo has `temp_protocol`, draw no band (layout unchanged) — keep the calm empty state. +- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). +- [ ] **Step 3: Chrome-MCP harness** — build `scratchpad/operationsdemo/` (copy `event-bus.js`, `experiment-overview.js`, `main.css`); an `index.html` that stubs `fetch('/api/experiments/current/strategy')` to return a snapshot with one embryo carrying a `temp_protocol` band (start/end), `setpoint_changes:[{t,to:25}]`, and a few phased burst phases; mount `ExperimentOverview`; screenshot the band; run the UI audit; report deferral of live rig verification. +- [ ] **Step 4: commit** — `feat(operations): tactic band — protocol pill + setpoint markers + phased bursts` + +--- + +### Task 4: Live refresh on events +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`init` — add subscriptions + a debounced refetch). Verify: `node --check` + harness (simulate an event → refetch fires). + +- [ ] **Step 1: implement** — in `ExperimentOverview.init`, after the initial `loadStrategy()`, register (mirroring `experiment-strip.js:23`): +```javascript +const _debouncedReload = (() => { let t; return () => { clearTimeout(t); t = setTimeout(() => { this.loadStrategy().then(() => this.render()); }, 500); }; })(); +['TEMP_PROTOCOL_STARTED','TEMP_PROTOCOL_COMPLETED','TEMPERATURE_SETPOINT_CHANGED','BURST_START','BURST_COMPLETE','EMBRYO_CADENCE_CHANGED','POWER_RAMP_STEP'] + .forEach(ev => ClientEventBus.on(ev, _debouncedReload)); +``` +(Adapt to the real `ExperimentOverview` method names for load/render + `this` binding — confirm from the file; if `loadStrategy` isn't promise-returning, wrap appropriately. Don't double-register if `init` can run twice — guard with a flag.) +- [ ] **Step 2:** `node --check` (exit 0). +- [ ] **Step 3: harness** — in the Task-3 harness, dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch is triggered (e.g. fetch call count increments after the debounce). Screenshot/log. +- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh of the Operations tab` + +--- + +## Self-Review +- Rename → Task 1; burst phase enabler → Task 2; tactic band render → Task 3; live refresh → Task 4. ✓ +- Open confirmations: real `build_strategy_snapshot` layout (Task 2), the `_renderSwimlanes` insertion point + chip helper (Task 3), the real `ExperimentOverview` load/render method names + init-guard (Task 4). +- Type consistency: `temp_protocol {start,end,target_setpoint_c}` + `setpoint_changes [{t,to}]` + burst `phase` across Tasks 2/3/4. diff --git a/docs/superpowers/specs/2026-06-28-operations-tab-design.md b/docs/superpowers/specs/2026-06-28-operations-tab-design.md new file mode 100644 index 00000000..26a1307a --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-operations-tab-design.md @@ -0,0 +1,84 @@ +# Design: Operations tab — observable tactics (sub-project D) + +Status: design decided 2026-06-28 (autonomous, from recon). +Base branch: off `feature/temp-change-tactic` (C) — D renders the `temp_protocol`/`setpoint_changes` +snapshot fields C adds, and the protocol events C emits. + +## 0. Roadmap context +Track 2. The user's ask: "when at the beginning of an experiment, tactics have been decided, it +should reflect and be communicated in the experiments tab… tactics should be observable — what and +how tactics are used in operations." Today's Experiment tab is embryo/cadence-centric and has **no +named-tactic concept**, no temperature/setpoint representation, and **does not auto-refresh** +(fetches once per tab click). D makes the composed temp-change tactic observable and live. + +## 1. Overview +Three changes, all small, no new endpoints (reuse `GET /api/experiments/{session}/strategy`): +1. **Rename** the tab "Experiment" → "Operations" (label-only). +2. **Surface the tactic** in the swimlane SVG: a session-level band above the embryo rows showing + the named protocol pill (spanning its start→complete), setpoint-change markers, and burst + grouping/labels (before/during/after). +3. **Live refresh**: subscribe the tab to the relevant EventBus events (already relayed to the + browser) + debounced re-fetch, so it updates during a run instead of only on tab click. + +## 2. Architecture + +### 2.1 Rename (label-only) +`index.html:119` nav button text and `:393` `

` text → "Operations". +Leave all ids (`data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`) and +`TABS.EXPERIMENT` (`app.js`) unchanged — routing/JS untouched. + +### 2.2 Burst phase in the event (1-line backend enabler) +The driver sets `b._phase` ("before"/"during"/"after") on each `BurstAcquisition`, but +`BURST_START` does not emit it, so the timeline can't distinguish phases. In +`gently/app/orchestration/exclusive.py` `BurstAcquisition.run`, include `"phase": +getattr(self, "_phase", None)` in the `BURST_START` event data. Then `strategy_snapshot._replay_timeline`'s +`burst_started` handler records `phase` on the burst phase dict (next to `frames`/`hz`). (C Task 6 +already adds the `temp_protocol` band + `setpoint_changes`; this adds per-burst phase labeling.) + +### 2.3 Frontend — the tactic band (`experiment-overview.js`) +Consume the snapshot's new fields (`temp_protocol`: `{start, end, target_setpoint_c, ...}` and +`setpoint_changes`: `[{at, to}]`, from C Task 6) and render, in `_renderSwimlanes` (line 332), a +**session-level band inserted above the per-embryo rows** (the SVG currently starts embryo rows at +`TOP_AXIS_H` — insert one band-height there and shift rows down): +- a **tactic pill** spanning the protocol window: label `"Temp-change burst → {target_setpoint_c}°C"` + (tactic name is a frontend constant keyed off the `temp_protocol` field — events carry no name). +- **setpoint markers** at each `setpoint_changes[].at`: a vertical line + a `"→ N°C"` chip, reusing + the existing cadence/power-chip drawing idiom (lines ~728-748). +- **burst phase tint/labels**: the per-embryo burst blocks (lines ~558-575) already render; tint or + badge those whose `phase` is before/during/after (from 2.2). Empty state unchanged when no tactic. + +### 2.4 Frontend — live refresh (`experiment-overview.js`) +In `ExperimentOverview.init`, add `ClientEventBus.on(...)` subscriptions (mirroring +`experiment-strip.js:23`) for: `TEMP_PROTOCOL_STARTED`, `TEMP_PROTOCOL_COMPLETED`, +`TEMPERATURE_SETPOINT_CHANGED`, `BURST_START`, `BURST_COMPLETE`, `EMBRYO_CADENCE_CHANGED`, +`POWER_RAMP_STEP` — each calls a **debounced** (~500 ms) `loadStrategy()`+`render()`. The websocket +relay (`server.py:386`) already emits all of these by `.name`, so no backend change. Keep the +existing now-ticker. + +## 3. Data flow +protocol/burst events → EventBus → websocket (by `.name`) → `ClientEventBus` → debounced +`loadStrategy()` → `GET /strategy` → `build_strategy_snapshot` (now with `temp_protocol` + +`setpoint_changes` + per-burst `phase`) → `_renderSwimlanes` draws the tactic band + markers + tinted +bursts. + +## 4. Error handling +- No active session / no tactic → existing calm empty state; the band simply absent when + `temp_protocol` is null. +- Snapshot missing the new fields (older session) → render without the band (defensive optional + chaining). +- Event flood → the debounce coalesces; re-fetch is cheap (single GET). + +## 5. Testing +- Backend: `_replay_timeline` records `phase` on burst phases when `BURST_START` carries it (extend + C Task 6's snapshot test). +- Frontend: `node --check`; a Chrome-MCP harness (like A/B1) feeding a snapshot with a `temp_protocol` + band + `setpoint_changes` + phased bursts → screenshot the tactic band; verify a simulated + `TEMP_PROTOCOL_STARTED` event triggers a debounced re-fetch. UI audit. Live rig verification deferred. + +## 6. Out of scope +- A general user-authored tactic library (G). +- New strategy endpoints or backend snapshot restructuring (reuse `/strategy`; C owns the builder). +- The Rules table (unchanged). +- Wiring the docstring fix for the temp EventTypes (the enum comments wrongly say + `{old_temp,new_temp}`/`{protocol_name,success}`) — fix opportunistically; D builds against the real + emitter payloads (`{to}` / `{locked,cancelled,error}`). From 95f912635ab3907bc0a718ee0dc148b8cc0e3209 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:37:10 +0530 Subject: [PATCH 200/314] feat(operations): rename Experiment tab to Operations (label only) --- gently/ui/web/templates/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1b1dba8e..7edcd230 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -116,7 +116,7 @@

Take a quick look

Now
- +
@@ -390,7 +390,7 @@

Embryo Monitoring

-

Experiment

+

Operations

From 380336fbc99be1b830b1cf68cca1403696fca084 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:39:56 +0530 Subject: [PATCH 201/314] feat(operations): emit burst phase in BURST_START + record in snapshot --- gently/app/orchestration/exclusive.py | 1 + gently/ui/web/strategy_snapshot.py | 1 + tests/test_burst_phase_snapshot.py | 130 ++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 tests/test_burst_phase_snapshot.py diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index b741e57d..03917b0c 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -131,6 +131,7 @@ async def run(self, orchestrator) -> ExclusiveResult: "request_id": self.request_id, "frames": self.frames, "mode": self.mode, + "phase": getattr(self, "_phase", None), }, ) diff --git a/gently/ui/web/strategy_snapshot.py b/gently/ui/web/strategy_snapshot.py index 4948ae9b..9eeb57e1 100644 --- a/gently/ui/web/strategy_snapshot.py +++ b/gently/ui/web/strategy_snapshot.py @@ -774,6 +774,7 @@ def _replay_timeline( "end": None, "frames": int(data.get("frames") or 0), "hz": hz, + "phase": data.get("phase"), } ) diff --git a/tests/test_burst_phase_snapshot.py b/tests/test_burst_phase_snapshot.py new file mode 100644 index 00000000..1c2f28fe --- /dev/null +++ b/tests/test_burst_phase_snapshot.py @@ -0,0 +1,130 @@ +"""Task 2: Burst phase field emitted in BURST_START and recorded in snapshot. + +Feeds _replay_timeline (via build_strategy_snapshot over a hand-written +timeline.jsonl) a burst_started event that carries ``phase="during"`` and +asserts the resulting snapshot burst phase dict includes ``"phase": "during"``. +""" + +import json +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +import yaml + +from gently.ui.web.strategy_snapshot import build_strategy_snapshot + +SESSION_ID = "sess-bp1" +EMBRYO_ID = "e1" +STARTED_AT = datetime(2026, 6, 28, 10, 0, 0) + + +def _ts(delta_s: float) -> str: + return (STARTED_AT + timedelta(seconds=delta_s)).isoformat() + + +def _write_session(session_dir: Path) -> None: + session_dir.mkdir(parents=True, exist_ok=True) + + # Minimal session.yaml + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "burst-phase test"}), encoding="utf-8" + ) + + # timelapse.yaml — minimal; one embryo + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": { + EMBRYO_ID: {"interval_seconds": 120}, + }, + } + (session_dir / "timelapse.yaml").write_text( + yaml.dump(timelapse), encoding="utf-8" + ) + + # timeline.jsonl + events = [ + { + "event_id": "ev-bs", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 60, "phase": "during"}, + }, + { + "event_id": "ev-bc", + "event_type": "timelapse", + "event_subtype": "burst_completed", + "timestamp": _ts(70), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID}, + }, + ] + timeline_path = session_dir / "timeline.jsonl" + with open(timeline_path, "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + +def test_burst_phase_recorded(tmp_path: Path) -> None: + """Snapshot burst phase dict carries the phase field from BURST_START data.""" + session_dir = tmp_path / "sessions" / "20260628_1000_test" + _write_session(session_dir) + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + + assert snap["embryos"], "no embryos in snapshot" + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase not found in snapshot" + bp = burst_phases[0] + assert bp.get("phase") == "during", ( + f"expected phase='during', got {bp.get('phase')!r}; full burst dict: {bp}" + ) + + +def test_burst_phase_none_when_absent(tmp_path: Path) -> None: + """A burst_started event without a phase field records phase=None gracefully.""" + session_dir = tmp_path / "sessions" / "20260628_1000_nophase" + session_dir.mkdir(parents=True, exist_ok=True) + + (session_dir / "session.yaml").write_text( + yaml.dump({"name": "burst-nophase test"}), encoding="utf-8" + ) + timelapse = { + "started_at": STARTED_AT.isoformat(), + "base_interval_seconds": 120, + "embryos": {EMBRYO_ID: {"interval_seconds": 120}}, + } + (session_dir / "timelapse.yaml").write_text(yaml.dump(timelapse), encoding="utf-8") + + events = [ + { + "event_id": "ev-bs2", + "event_type": "timelapse", + "event_subtype": "burst_started", + "timestamp": _ts(10), + "source": "test", + "session_id": SESSION_ID, + "embryo_id": EMBRYO_ID, + "data": {"embryo_id": EMBRYO_ID, "mode": "1hz", "frames": 30}, + }, + ] + with open(session_dir / "timeline.jsonl", "w", encoding="utf-8") as f: + for ev in events: + f.write(json.dumps(ev) + "\n") + + snap = build_strategy_snapshot(session_dir, SESSION_ID) + emb = snap["embryos"][0] + burst_phases = [p for p in emb["phases"] if p.get("mode") == "burst"] + assert burst_phases, "burst phase not found" + bp = burst_phases[0] + # "phase" key must exist; value is None when not supplied + assert "phase" in bp, f"'phase' key absent from burst dict: {bp}" + assert bp["phase"] is None, f"expected None, got {bp['phase']!r}" From 2a3d8b0e61fe1221fb736ce4645ccdd0bcb3e3a1 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 16:48:21 +0530 Subject: [PATCH 202/314] =?UTF-8?q?feat(operations):=20tactic=20band=20?= =?UTF-8?q?=E2=80=94=20protocol=20pill=20+=20setpoint=20markers=20+=20phas?= =?UTF-8?q?ed=20bursts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a session-level tactic band on the swimlane SVG when any embryo carries a `temp_protocol`. A 28px band is inserted just below the time axis (embryo rows shifted down by the same amount). The band contains a tactic pill spanning `temp_protocol.start → end`, "→ N°C" setpoint-change chips at each `setpoint_changes[].t`, and a gutter label. Burst lane rects and balloon backgrounds are tinted blue/amber/green when the phase field is before/during/after; null phase renders unchanged. node --check: pass. --- gently/ui/web/static/css/experiment.css | 66 ++++++++++++++ .../ui/web/static/js/experiment-overview.js | 88 ++++++++++++++++++- 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 9b343378..6d8ad789 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -626,6 +626,72 @@ color: var(--accent-cyan); } +/* ---- Tactic band — session-level protocol strip above embryo rows ------- */ +/* Background strip */ +.expov-svg-tactic-band-bg { + fill: rgba(245, 158, 11, 0.06); + stroke: rgba(245, 158, 11, 0.14); + stroke-width: 1; +} + +/* Tactic pill: spans the protocol window */ +.expov-svg-tactic-pill { + fill: rgba(245, 158, 11, 0.18); + stroke: rgba(245, 158, 11, 0.60); + stroke-width: 1; +} +.expov-svg-tactic-pill-label { + fill: #fbbf24; + font-size: 9.5px; + font-weight: 700; + font-family: 'JetBrains Mono', monospace; +} + +/* Gutter label ("tactic") */ +.expov-svg-tactic-band-label { + fill: var(--text-muted); + font-size: 9px; + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.7; +} + +/* Setpoint marker — vertical line + "→ N°C" chip */ +.expov-svg-tactic-setpoint-line { + stroke: rgba(245, 158, 11, 0.55); + stroke-width: 1; + stroke-dasharray: 3 2; +} +.expov-svg-tactic-setpoint-chip-bg { + fill: rgba(30, 20, 0, 0.88); + stroke: rgba(245, 158, 11, 0.55); + stroke-width: 1; +} +.expov-svg-tactic-setpoint-chip { + fill: #fbbf24; + font-size: 9px; + font-weight: 600; + font-family: 'JetBrains Mono', monospace; +} + +/* Burst phase tints — override fill on the burst lane rect + balloon bg. + before = cool blue (pre-stimulus), during = warm amber (active), + after = soft green (recovery). null phase renders as the default red. */ +.expov-burst-phase-before { + fill: #3b82f6; + opacity: 0.80; +} +.expov-burst-phase-during { + fill: #f59e0b; + opacity: 0.90; +} +.expov-burst-phase-after { + fill: #22c55e; + opacity: 0.80; +} + .expov-trigger-diamond-inline { display: inline-block; width: 8px; diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 9e7fc221..eddd97d8 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -367,8 +367,22 @@ const ExperimentOverview = { const TOP_AXIS_H = 36; // top axis area (time labels + wall-clock) const BOTTOM_PAD = 8; + const TACTIC_BAND_H = 28; // session-level tactic band height + + // Aggregate tactic protocol from embryos (first embryo with one wins) + let tacticProto = null; + let tacticSetpointChanges = []; + for (const emb of s.embryos) { + if (emb.temp_protocol) { + tacticProto = emb.temp_protocol; + tacticSetpointChanges = emb.setpoint_changes || []; + break; + } + } + const bandShift = tacticProto ? TACTIC_BAND_H : 0; + const rows = s.embryos.length; - const H = TOP_AXIS_H + rows * ROW_H + BOTTOM_PAD; + const H = TOP_AXIS_H + bandShift + rows * ROW_H + BOTTOM_PAD; const svg = svgEl('svg', { class: 'expov-swimlanes-svg', @@ -426,6 +440,67 @@ const ExperimentOverview = { nowMarker.appendChild(chipText); svg.appendChild(nowMarker); + // ----- tactic band: session-level protocol pill + setpoint markers + if (tacticProto) { + const bandY = TOP_AXIS_H + 2; + const bandContentH = TACTIC_BAND_H - 4; + const bandMidY = bandY + bandContentH / 2; + const bandG = svgEl('g', { class: 'expov-tactic-band-g' }); + + // Background strip spanning the full lane area + bandG.appendChild(svgEl('rect', { + x: LEFT, y: bandY, width: LANE_W, height: bandContentH, rx: 3, + class: 'expov-svg-tactic-band-bg' + })); + + // Tactic pill: spans protocol start → end (or now if still running) + const pillX0 = xForT(tacticProto.start); + const pillX1 = xForT(tacticProto.end != null ? tacticProto.end : s.now_offset_s); + const pillW = Math.max(4, pillX1 - pillX0); + const pillLabel = `Temp-change burst → ${tacticProto.target_setpoint_c}°C`; + bandG.appendChild(svgEl('rect', { + x: pillX0, y: bandY + 2, width: pillW, height: bandContentH - 4, rx: 4, + class: 'expov-svg-tactic-pill' + })); + if (pillW > 24) { + bandG.appendChild(svgEl('text', { + x: pillX0 + 7, y: bandMidY + 3.5, + class: 'expov-svg-tactic-pill-label' + }, pillLabel)); + } + + // Setpoint markers: vertical line + "→ N°C" chip (reuses chip idiom) + tacticSetpointChanges.forEach(sc => { + const scX = xForT(sc.t); + const markerLabel = `→ ${sc.to}°C`; + const chipW = markerLabel.length * 5.6 + 10; + const chipY = bandY + 2; + bandG.appendChild(svgEl('line', { + x1: scX, x2: scX, y1: bandY, y2: bandY + bandContentH, + class: 'expov-svg-tactic-setpoint-line' + })); + bandG.appendChild(svgEl('rect', { + x: scX - chipW / 2, y: chipY, + width: chipW, height: 12, rx: 3, + class: 'expov-svg-tactic-setpoint-chip-bg' + })); + bandG.appendChild(svgEl('text', { + x: scX, y: chipY + 9, + 'text-anchor': 'middle', + class: 'expov-svg-tactic-setpoint-chip' + }, markerLabel)); + }); + + // Gutter label + bandG.appendChild(svgEl('text', { + x: LEFT - 8, y: bandMidY + 3.5, + 'text-anchor': 'end', + class: 'expov-svg-tactic-band-label' + }, 'tactic')); + + svg.appendChild(bandG); + } + // Stash the bits the ticker needs to update without re-rendering. this._nowTickerCtx = { marker: nowMarker, @@ -444,7 +519,7 @@ const ExperimentOverview = { // ----- one group per embryo s.embryos.forEach((emb, i) => { - const rowTop = TOP_AXIS_H + i * ROW_H; + const rowTop = TOP_AXIS_H + bandShift + i * ROW_H; const rowG = svgEl('g'); rowG.appendChild(this._renderLaneRow(s, emb, { LEFT, LANE_W, RIGHT, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, @@ -537,7 +612,9 @@ const ExperimentOverview = { const x1Raw = xForT(ph.end); const x1 = Math.max(x1Raw, x0 + 4); const width = x1 - x0; - const cls = `expov-svg-phase-${ph.mode}`; + const cls = (ph.mode === 'burst' && ph.phase) + ? `expov-svg-phase-${ph.mode} expov-burst-phase-${ph.phase}` + : `expov-svg-phase-${ph.mode}`; g.appendChild(svgEl('rect', { x: x0, y: laneY, width, height: LANE_H, rx: 2, class: cls @@ -559,9 +636,12 @@ const ExperimentOverview = { const bx = (xForT(ph.start) + xForT(ph.end)) / 2; const balloonY = laneY - 12; const halfW = 30; + const balloonCls = ph.phase + ? `expov-svg-burst-balloon-bg expov-burst-phase-${ph.phase}` + : 'expov-svg-burst-balloon-bg'; g.appendChild(svgEl('rect', { x: bx - halfW, y: balloonY - 10, width: halfW * 2, height: 12, rx: 3, - class: 'expov-svg-burst-balloon-bg' + class: balloonCls })); g.appendChild(svgEl('text', { x: bx, y: balloonY - 1, From 6003b8ee510f28b044460ca0102f12eef63f4921 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:16:04 +0530 Subject: [PATCH 203/314] docs(operations): redesign D as operation-spine (data-driven + scenario mode) after design audit --- .../plans/2026-06-28-operations-tab.md | 123 +++++------- .../specs/2026-06-28-operations-tab-design.md | 177 ++++++++++-------- 2 files changed, 144 insertions(+), 156 deletions(-) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index 8ce94846..ab1768b2 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -1,103 +1,70 @@ -# Operations Tab (D) Implementation Plan +# Operations — operation spine (D, redesigned) Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. -**Goal:** Make the composed temp-change tactic observable: rename Experiment→Operations, render the protocol band + setpoint markers + phased bursts on the swimlane, and make the tab refresh live. +**Goal:** Replace the legacy swimlane with a data-driven **operation-spine** Operations view that shows tactics planned/in-use/used, renders any tactic case from a generic model, and is testable offline via scenario fixtures. -**Architecture:** Reuse `GET /api/experiments/{session}/strategy` (C added `temp_protocol` + `setpoint_changes` per-embryo). One backend enabler (emit burst `phase`), the rest is `experiment-overview.js` rendering + live event subscriptions. +**Architecture:** A generic *operation* model (tactics with state + optional live readouts/phases); a rewritten `experiment-overview.js` overview = the spine renderer (audit fixes baked in); a repo scenario library (6 fixtures) + `?scenario=` dev mode; a backend `build_operation_view(snapshot)` transform served on `/strategy`; live event-driven refresh. -**Tech Stack:** vanilla-JS + hand-rolled SVG (`experiment-overview.js`), the EventBus→websocket relay, pytest for the small backend bit. +**Reference (the validated prototype — read it for the exact renderer, CSS, model, and fixtures):** +`/tmp/claude-1000/-home-dna-lab-projects-gently/3192c9a4-bc99-4c0a-9cbd-e094de730bcf/scratchpad/opsdesign/harness.html` ## Global Constraints -- No new endpoints; reuse `/strategy`. C owns the snapshot builder; D consumes its fields. -- Snapshot fields (per-embryo, from C Task 6): `temp_protocol: {start, end, target_setpoint_c, frames, bursts_before, bursts_after} | None`, `setpoint_changes: [{t, to}]`. -- Rename is label-only (`index.html:119` nav text, `:393` `

`); leave ids + `TABS.EXPERIMENT` unchanged. -- Tactic name is a frontend constant keyed off `temp_protocol` (events carry no name). -- Build against the REAL emitter payloads, not the stale enum docstrings. -- Live: subscribe via `ClientEventBus.on(...)` (mirror `experiment-strip.js:23`), debounced (~500ms) `loadStrategy()`+`render()`. Relay already emits all events by `.name` — no backend change. -- Frontend: no JS unit harness → `node --check` + Chrome-MCP harness + UI audit; live rig verification deferred. +- Keep the Experiment→Operations rename (done) + the burst-`phase` event (done). DISCARD the swimlane band. +- Operation model: `{title, session, tactics:[{seq, state:'used'|'active'|'queued', name, kind, target?, summary?, desc?, trigger?, live?:{readouts:[{label,value,sub?,bar?}], phases?:[{name,state:'done'|'active'|'todo',count,pips:[...]}]}}]}`. +- Audit fixes (baked into the renderer): (1) queued spine is colored blue + first queued marked "next" (decided-plan must read as a cocked instrument, not empty); (2) active phase dominates + "awaiting lock"-style status is the phase headline; (3) FLATTEN — no card-in-card, values on the panel face + hairline dividers; (4) active row's whole left column amber + colored left edge per state (green/amber/blue); (5) copy "queued" (not "going to be used"), replace "first/second/last" with `trigger`. Keep mono instrument values + domain copy. +- Renderer must handle ALL six fixtures (phased protocol, readout monitor, one-shot, all-queued, idle) without error. +- No new endpoint: extend `/api/experiments/{session}/strategy` payload with an `operation` object. +- Frontend has no JS unit harness → `node --check` + Chrome-MCP audit across fixtures. - Git hygiene: stage only your files by explicit path; never `git add -A`. --- -### Task 1: Rename Experiment → Operations (label-only) -**Files:** Modify `gently/ui/web/templates/index.html` (nav button text `:119`, `

` `:393`). Test: none (pure copy) — verified by `grep`. -- [ ] **Step 1:** change the nav button text at `:119` from `Experiment` to `Operations`, and the `

` at `:393` from `Experiment` to `Operations`. Leave `data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`, `TABS.EXPERIMENT` untouched. -- [ ] **Step 2:** `grep -n ">Operations<" gently/ui/web/templates/index.html` shows both; `grep -n "data-tab=\"experiment\"" ` still present (ids unchanged). -- [ ] **Step 3: commit** — `git add gently/ui/web/templates/index.html && git commit -m "feat(operations): rename Experiment tab to Operations (label only)"` +### Task 1: Discard the swimlane band +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (+ its css) — revert the tactic-band rendering added in commit 2a3d8b0, returning the Overview to its pre-band baseline (the spine replaces it in Task 2). Keep everything else (Rules view, modes, etc.). +- [ ] **Step 1:** `git revert --no-commit 2a3d8b0` (or manually remove the band code + css if revert conflicts), keeping the rename (Task-1) and burst-phase (Task-2) commits intact. Confirm `git diff` shows only the band removal. +- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). +- [ ] **Step 3: commit** — `git add gently/ui/web/static/js/experiment-overview.js && git commit -m "feat(operations): discard swimlane tactic-band (replaced by operation spine)"` --- -### Task 2: Emit burst `phase` + record it in the snapshot -**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.run` BURST_START emit ~line 127-135 add `phase`); Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` `burst_started` handler ~line 762 record `phase` on the burst phase dict). Test: `tests/test_burst_phase_snapshot.py`. - -**Interfaces:** Produces `BURST_START` data with `"phase": getattr(self, "_phase", None)`; snapshot burst phase dict gains `"phase"`. - -- [ ] **Step 1: failing test** -```python -# tests/test_burst_phase_snapshot.py -import json, os -from gently.ui.web.strategy_snapshot import build_strategy_snapshot - -def _write(tmp_path, events): - sd = tmp_path / "sess"; (sd / "embryos" / "e1").mkdir(parents=True) - (sd / "session.yaml").write_text("session_id: s1\nname: t\nstarted_at: '2026-06-28T10:00:00'\n") - (sd / "embryos" / "e1" / "embryo.yaml").write_text("embryo_id: e1\nrole: Test\n") - with open(sd / "timeline.jsonl", "w") as f: - for e in events: f.write(json.dumps(e) + "\n") - return sd - -def test_burst_phase_recorded(tmp_path): - sd = _write(tmp_path, [ - {"event_subtype": "burst_started", "embryo_id": "e1", "timestamp": "2026-06-28T10:00:10", - "data": {"embryo_id": "e1", "frames": 60, "mode": "1hz", "phase": "during"}}, - {"event_subtype": "burst_completed", "embryo_id": "e1", "timestamp": "2026-06-28T10:01:10", - "data": {"embryo_id": "e1"}}, - ]) - snap = build_strategy_snapshot(str(sd), "s1") - emb = next(e for e in snap["embryos"] if e["id"] == "e1") - burst = next(p for p in emb["phases"] if p.get("mode") == "burst") - assert burst.get("phase") == "during" -``` -> Confirm the real `build_strategy_snapshot` session-dir layout + timeline line shape from C Task 6's test (`tests/test_temp_protocol_snapshot.py`) and match it (session.yaml keys, embryo.yaml, timeline event shape). Adapt `_write` to the real shape; keep the `phase` assertion. -- [ ] **Step 2: run, expect FAIL** (phase is None / key absent) -- [ ] **Step 3: implement** — in `exclusive.py` `BurstAcquisition.run`, add `"phase": getattr(self, "_phase", None)` to the `BURST_START` event `data` dict. In `strategy_snapshot.py` `burst_started` handler, add `"phase": data.get("phase")` to the appended burst phase dict (next to `frames`/`hz`). -- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures. -- [ ] **Step 5: commit** — `feat(operations): emit burst phase in BURST_START + record in snapshot` +### Task 2: Scenario library + the operation-spine renderer (the core) +**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (the 6 fixtures, promoted from the harness); Modify `gently/ui/web/static/js/experiment-overview.js` (replace the Overview render path with the data-driven spine renderer from the harness, audit fixes applied) + the experiment css (spine/node/phase styles from the harness). Verify: `node --check` + Chrome-MCP audit. + +**Interfaces:** Produces `OperationView.render(rootEl, operationModel)` (or fold into `ExperimentOverview`); consumes the operation model (§ Global Constraints) from the live snapshot (Task 3) or a scenario fixture; `OPERATIONS_SCENARIOS` (the fixture map) + a `?scenario=` loader (dev mode). + +- [ ] **Step 1: port the fixtures** — create `operations-scenarios.js` exposing `window.OPERATIONS_SCENARIOS = {temp_strain, expression_onset, pre_hatching, transmission_survey, decided_plan, idle}` — copy the fixture objects from the harness `SCENARIOS` (the `.op` values), as the repo test corpus. Apply the copy fixes: `state:'queued'` (not 'planned' label), add `trigger` fields ("when temp locks", "after #1", etc.) replacing "first/second/last". +- [ ] **Step 2: the renderer** — port the harness renderer (`renderOperation`/`renderTactic`/`renderReadout`/`renderPhase`) into `experiment-overview.js`, replacing the swimlane Overview path, WITH the audit fixes: state-colored spine connector (green/amber/blue-dashed), whole-left-column amber for active, colored left edge per state, flattened active card (remove inner card backgrounds; hairline divider between readouts and phases), active phase dominant + status headline, first-queued "next" marker, `STATE_LABEL.queued='queued'`. Keep mono labels. +- [ ] **Step 3: scenario dev mode** — on init, if `new URLSearchParams(location.search).get('scenario')` matches a fixture, render that fixture (no fetch); else fetch the live operation (Task 3). Guard so scenario mode is opt-in via the param. +- [ ] **Step 4: `node --check`** both JS files (exit 0). +- [ ] **Step 5: Chrome-MCP audit** — serve the static files, load `?scenario=temp_strain`, `expression_onset`, `decided_plan`, `idle`; screenshot each; verify the audit fixes (queued reads cocked, active dominates, flattened, amber left column, "queued" copy) and that all six render without error; fix flaws. +- [ ] **Step 6: commit** — `feat(operations): operation-spine renderer + scenario library (data-driven, 6 cases)` --- -### Task 3: Render the tactic band on the swimlane -**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`_renderSwimlanes` ~line 332: insert a session-level band; helpers for the protocol pill + setpoint markers, reusing the chip idiom ~728-748). Verify: `node --check` + Chrome-MCP harness. +### Task 3: Backend transform `build_operation_view` +**Files:** Modify `gently/ui/web/strategy_snapshot.py` (add `build_operation_view(snapshot)` mapping the snapshot → operation model; have `build_strategy_snapshot` include `operation` in its return, OR add it in the route). Test: `tests/test_operation_view.py`. -- [ ] **Step 1: build the band** — In `_renderSwimlanes`, before drawing embryo rows (which start at `TOP_AXIS_H`), aggregate the protocol from the embryos: find the embryo with a non-null `temp_protocol` (and its `setpoint_changes`). If present, insert a band of fixed height (e.g. 28px) and shift embryo rows down by that height: - - a **tactic pill** rect spanning `xFor(temp_protocol.start)`..`xFor(temp_protocol.end ?? now)`, labeled `Temp-change burst → ${temp_protocol.target_setpoint_c}°C` (a frontend constant prefix); - - for each `setpoint_changes[i]`: a vertical marker line at `xFor(.t)` + a `→ ${.to}°C` chip (reuse the existing chip-drawing helper used for cadence/power chips ~728-748); - - tint the burst blocks (already drawn per-embryo ~558-575) by their `phase` (before/during/after) — e.g. a subtle hue or a 1-char badge — using the `phase` field added in Task 2. - If no embryo has `temp_protocol`, draw no band (layout unchanged) — keep the calm empty state. -- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). -- [ ] **Step 3: Chrome-MCP harness** — build `scratchpad/operationsdemo/` (copy `event-bus.js`, `experiment-overview.js`, `main.css`); an `index.html` that stubs `fetch('/api/experiments/current/strategy')` to return a snapshot with one embryo carrying a `temp_protocol` band (start/end), `setpoint_changes:[{t,to:25}]`, and a few phased burst phases; mount `ExperimentOverview`; screenshot the band; run the UI audit; report deferral of live rig verification. -- [ ] **Step 4: commit** — `feat(operations): tactic band — protocol pill + setpoint markers + phased bursts` +**Interfaces:** Produces `build_operation_view(snapshot: dict) -> dict` (the operation model); `/api/experiments/{session}/strategy` response gains `operation`. ---- +- [ ] **Step 1: failing test** — feed a snapshot dict (one embryo with a `temp_protocol` band {start,end,target_setpoint_c}, `setpoint_changes`, phased bursts, and an active monitoring mode) to `build_operation_view`; assert the returned `operation.tactics` includes: a `used` monitor tactic, an `active` temp-change tactic whose `live.phases` reflect before/during/after, and the `state`s are correct. (Confirm the real snapshot shape from `tests/test_temp_protocol_snapshot.py`.) +- [ ] **Step 2: run, FAIL.** +- [ ] **Step 3: implement** `build_operation_view`: derive tactics from the snapshot — completed monitoring modes/bursts → `used`; the active `temp_protocol`/monitoring mode → `active` (with readouts from temp + current burst, phases from the protocol's before/during/after burst counts); planned/queued items → `queued`. Map fields into the §model. Include `operation` in the snapshot/route payload. +- [ ] **Step 4: run, PASS;** `pytest -q` clean. +- [ ] **Step 5: commit** — `feat(operations): build_operation_view transform on the strategy snapshot` -### Task 4: Live refresh on events -**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (`init` — add subscriptions + a debounced refetch). Verify: `node --check` + harness (simulate an event → refetch fires). +--- -- [ ] **Step 1: implement** — in `ExperimentOverview.init`, after the initial `loadStrategy()`, register (mirroring `experiment-strip.js:23`): -```javascript -const _debouncedReload = (() => { let t; return () => { clearTimeout(t); t = setTimeout(() => { this.loadStrategy().then(() => this.render()); }, 500); }; })(); -['TEMP_PROTOCOL_STARTED','TEMP_PROTOCOL_COMPLETED','TEMPERATURE_SETPOINT_CHANGED','BURST_START','BURST_COMPLETE','EMBRYO_CADENCE_CHANGED','POWER_RAMP_STEP'] - .forEach(ev => ClientEventBus.on(ev, _debouncedReload)); -``` -(Adapt to the real `ExperimentOverview` method names for load/render + `this` binding — confirm from the file; if `loadStrategy` isn't promise-returning, wrap appropriately. Don't double-register if `init` can run twice — guard with a flag.) +### Task 4: Live refresh +**Files:** Modify `experiment-overview.js` (`init` — subscribe + debounced refetch, mirroring `experiment-strip.js`). Verify: `node --check` + harness (simulate an event → refetch). +- [ ] **Step 1:** in `init`, after the initial live fetch, register `ClientEventBus.on(...)` for `TEMP_PROTOCOL_STARTED`,`TEMP_PROTOCOL_COMPLETED`,`TEMPERATURE_SETPOINT_CHANGED`,`BURST_START`,`BURST_COMPLETE`,`EMBRYO_CADENCE_CHANGED`,`POWER_RAMP_STEP`, each → a debounced (~500ms) refetch+render. Guard against double-registration; skip in scenario mode. - [ ] **Step 2:** `node --check` (exit 0). -- [ ] **Step 3: harness** — in the Task-3 harness, dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch is triggered (e.g. fetch call count increments after the debounce). Screenshot/log. -- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh of the Operations tab` +- [ ] **Step 3: harness** — dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch fires after the debounce. +- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh` --- ## Self-Review -- Rename → Task 1; burst phase enabler → Task 2; tactic band render → Task 3; live refresh → Task 4. ✓ -- Open confirmations: real `build_strategy_snapshot` layout (Task 2), the `_renderSwimlanes` insertion point + chip helper (Task 3), the real `ExperimentOverview` load/render method names + init-guard (Task 4). -- Type consistency: `temp_protocol {start,end,target_setpoint_c}` + `setpoint_changes [{t,to}]` + burst `phase` across Tasks 2/3/4. +- Discard swimlane → Task 1; renderer+scenarios → Task 2 (audit fixes baked in); backend transform → Task 3; live refresh → Task 4. ✓ +- Open confirmations: the real snapshot shape (Task 3, from C's snapshot test); the `experiment-overview.js` Overview render seam to replace (Task 2, read the harness for the target renderer); the css file for experiment styles (Task 1/2). +- Type consistency: the operation model fields are identical across the fixtures (Task 2), the transform (Task 3), and the renderer (Task 2). diff --git a/docs/superpowers/specs/2026-06-28-operations-tab-design.md b/docs/superpowers/specs/2026-06-28-operations-tab-design.md index 26a1307a..6d57416e 100644 --- a/docs/superpowers/specs/2026-06-28-operations-tab-design.md +++ b/docs/superpowers/specs/2026-06-28-operations-tab-design.md @@ -1,84 +1,105 @@ -# Design: Operations tab — observable tactics (sub-project D) - -Status: design decided 2026-06-28 (autonomous, from recon). -Base branch: off `feature/temp-change-tactic` (C) — D renders the `temp_protocol`/`setpoint_changes` -snapshot fields C adds, and the protocol events C emits. - -## 0. Roadmap context -Track 2. The user's ask: "when at the beginning of an experiment, tactics have been decided, it -should reflect and be communicated in the experiments tab… tactics should be observable — what and -how tactics are used in operations." Today's Experiment tab is embryo/cadence-centric and has **no -named-tactic concept**, no temperature/setpoint representation, and **does not auto-refresh** -(fetches once per tab click). D makes the composed temp-change tactic observable and live. - -## 1. Overview -Three changes, all small, no new endpoints (reuse `GET /api/experiments/{session}/strategy`): -1. **Rename** the tab "Experiment" → "Operations" (label-only). -2. **Surface the tactic** in the swimlane SVG: a session-level band above the embryo rows showing - the named protocol pill (spanning its start→complete), setpoint-change markers, and burst - grouping/labels (before/during/after). -3. **Live refresh**: subscribe the tab to the relevant EventBus events (already relayed to the - browser) + debounced re-fetch, so it updates during a run instead of only on tab click. - -## 2. Architecture - -### 2.1 Rename (label-only) -`index.html:119` nav button text and `:393` `

` text → "Operations". -Leave all ids (`data-tab="experiment"`, `#experiment-content`, `#experiment-overview-root`) and -`TABS.EXPERIMENT` (`app.js`) unchanged — routing/JS untouched. - -### 2.2 Burst phase in the event (1-line backend enabler) -The driver sets `b._phase` ("before"/"during"/"after") on each `BurstAcquisition`, but -`BURST_START` does not emit it, so the timeline can't distinguish phases. In -`gently/app/orchestration/exclusive.py` `BurstAcquisition.run`, include `"phase": -getattr(self, "_phase", None)` in the `BURST_START` event data. Then `strategy_snapshot._replay_timeline`'s -`burst_started` handler records `phase` on the burst phase dict (next to `frames`/`hz`). (C Task 6 -already adds the `temp_protocol` band + `setpoint_changes`; this adds per-burst phase labeling.) - -### 2.3 Frontend — the tactic band (`experiment-overview.js`) -Consume the snapshot's new fields (`temp_protocol`: `{start, end, target_setpoint_c, ...}` and -`setpoint_changes`: `[{at, to}]`, from C Task 6) and render, in `_renderSwimlanes` (line 332), a -**session-level band inserted above the per-embryo rows** (the SVG currently starts embryo rows at -`TOP_AXIS_H` — insert one band-height there and shift rows down): -- a **tactic pill** spanning the protocol window: label `"Temp-change burst → {target_setpoint_c}°C"` - (tactic name is a frontend constant keyed off the `temp_protocol` field — events carry no name). -- **setpoint markers** at each `setpoint_changes[].at`: a vertical line + a `"→ N°C"` chip, reusing - the existing cadence/power-chip drawing idiom (lines ~728-748). -- **burst phase tint/labels**: the per-embryo burst blocks (lines ~558-575) already render; tint or - badge those whose `phase` is before/during/after (from 2.2). Empty state unchanged when no tactic. - -### 2.4 Frontend — live refresh (`experiment-overview.js`) -In `ExperimentOverview.init`, add `ClientEventBus.on(...)` subscriptions (mirroring -`experiment-strip.js:23`) for: `TEMP_PROTOCOL_STARTED`, `TEMP_PROTOCOL_COMPLETED`, -`TEMPERATURE_SETPOINT_CHANGED`, `BURST_START`, `BURST_COMPLETE`, `EMBRYO_CADENCE_CHANGED`, -`POWER_RAMP_STEP` — each calls a **debounced** (~500 ms) `loadStrategy()`+`render()`. The websocket -relay (`server.py:386`) already emits all of these by `.name`, so no backend change. Keep the -existing now-ticker. +# Design: Operations tab — the operation spine (sub-project D, redesigned) + +Status: redesigned 2026-06-28 after a from-scratch design analysis + audit (user directive: +"start from no assumptions of legacy design… rethink completely new"). Supersedes the earlier +swimlane-band approach (discarded). +Base branch: `feature/operations-tab` (off C). Keep: the Experiment→Operations rename and the +burst-`phase` event. **Discard: the swimlane tactic-band rendering.** + +## 0. The directive + +Operations must present **tactics in three states — planned ("queued"), in use, and used** — so a +scientist reads at a glance what the agent decided, what it's doing now, and what's next. It must be +**testable against realistic experiments** and render **different tactic cases** (monitor, burst, +temp-change protocol, transmission, …). It is **mission-control for an autonomous instrument**, not +a timeline and not a SaaS dashboard. + +## 1. The model — a generic tactic plan + +Operations renders one data structure, the **operation view**: + +``` +operation = { title, session, tactics: [ tactic ] } +tactic = { + seq, state: 'used'|'active'|'queued', name, kind, + target?, # e.g. "→ 32.0 °C" + summary?, # collapsed line (used/queued): "22 min · ended on signal" + desc?, # one-line sub-description + trigger?, # queued: the condition that starts it, e.g. "when temp locks" + live?: { # active (or any with live data) + readouts: [{ label, value, sub?, bar? }], # temp gauge, current burst, signal, cadence… + phases?: [{ name, state:'done'|'active'|'todo', count, pips:[...] }] # before/during/after (protocols) + } +} +``` + +This single model renders **any** tactic kind: a phased protocol (temp-change → phases + temp +gauge), a reactive monitor (expression-onset → readouts, no phases), a one-shot (transmission burst +→ readouts), or a purely queued plan (all tactics `state:'queued'`). + +## 2. Architecture (four units) + +### 2.1 Backend transform — `build_operation_view(snapshot)` +A new function (in `gently/ui/web/strategy_snapshot.py` or a sibling) that maps the existing +strategy snapshot into the operation model: the per-embryo `temp_protocol` band + `setpoint_changes` ++ phased bursts (from C) become a temp-change **tactic** with before/during/after **phases**; active +`monitoring_modes` become monitor **tactics** with **readouts** (cadence, power, signal); completed +bursts/phases become **used** tactics; not-yet-started planned items become **queued** tactics. +Served via the existing `/api/experiments/{session}/strategy` route (extend its payload with +`operation`) or a new `GET /api/operations/{session}` — reuse `/strategy` to avoid a new route. + +### 2.2 The renderer — rewrite `experiment-overview.js` +Replace the swimlane Overview with the **operation spine** (data-driven from §1): a vertical thread +of tactic nodes; **used** = solid green dot + collapsed one-line summary; **in use** = amber node, +the *whole left column amber* (dot + seq + "IN USE"), card bloomed open with live internals; **queued** += blue dashed dot + dimmed card + trigger condition. The spine connector is **state-colored** +(green used → amber active → blue-dashed queued); the first queued node is marked **next**. (The +Rules view can remain as-is or be folded in later — out of scope here.) + +**Audit fixes baked in** (from the design audit): +1. Queued/decided-plan state reads as a *cocked* instrument: colored blue queued thread + "next" marker. +2. The **active phase dominates** the stepper; **"awaiting lock" is the headline** of the active phase. +3. **Flatten** the active card — no card-in-card; values sit on the panel face, separated by a hairline. +4. Active row's **entire left column amber**; colored left edge per state (green/amber/blue). +5. Copy: **"queued"** (not "going to be used"); replace "first/second/last" with the **trigger**; + keep mono instrument values + domain copy ("ended on signal", "awaiting lock"); show rate-of-change + deltas ("+14% over 6 min") and promote the decision-driving readout per tactic kind. + +### 2.3 Scenario test mode (the "test it for real experiments" requirement) +The renderer accepts an operation model from either the live endpoint **or** a **scenario fixture**. +A small dev affordance — `?scenario=` URL param (and/or a dev-only scenario ``) — loads -a named fixture from a **scenario library** (`gently/ui/web/static/js/operations-scenarios.js`): -`temp_strain`, `expression_onset`, `pre_hatching`, `transmission_survey`, `decided_plan`, `idle`. -This makes Operations developable/verifiable **offline against any tactic case**, with no rig. The -fixtures are the prototyped harness fixtures, promoted into the repo as test data + a Chrome-MCP -audit target. (Scenario mode is dev-gated; live mode is default.) - -### 2.4 Live refresh -The tab subscribes (via `ClientEventBus.on`, mirroring `experiment-strip.js`) to -`TEMP_PROTOCOL_STARTED/COMPLETED`, `TEMPERATURE_SETPOINT_CHANGED`, `BURST_START/COMPLETE`, -`EMBRYO_CADENCE_CHANGED`, `POWER_RAMP_STEP`, each triggering a debounced (~500 ms) refetch+render. The -websocket relay already emits all of these by `.name`; no backend change. +This single typed object expresses a regular timelapse (standing), an async per-embryo run (standing +with per-embryo cadence — *real* in the orchestrator), a reactive monitor incl. **hatching detection** +(reactive), a temp-change protocol (scripted, phased), an exclusive burst, and arbitrary `custom` +tactics — because the **agent declares each one's kind/structure**. + +## 2. Architecture (producer → model → consumer, all reusing existing paths) + +### 2.1 Producer — the `operation_plan` typed tool (agent) +A new agent tool `declare_operation_plan(...)` (or `update_operation_plan`) in `gently/app/tools/`, +schema auto-generated by the `@tool` decorator (or a forced literal schema like `notebook_ask.ASK_TOOL`). +The agent calls it at experiment planning and **patches it on each tactic transition** (begin / end / +phase-change). Prompt guidance (in `harness/prompts/templates.py`, beside the monitoring-mode decision +table) tells the agent to keep its Operation Plan current as it operates. + +### 2.2 Model — store the plan in `FileContextStore` +A new domain beside `session_intents`/`active/`: `agent/operation_plans/{session_id}.yaml`. Add +`set_operation_plan` / `get_operation_plan` to `FileContextStore` and fire the existing +`CONTEXT_UPDATED` (the store's `_notify_*` already broadcasts to `/ws`). The plan is an agent-authored +typed artifact exactly like campaigns/plan-items already are. + +### 2.3 Consumer — Operations renders plan ⊕ live binding +- Route `GET /api/operation_plan/{session_id}` (mirror `routes/context.py`) returns the plan. +- The renderer (the **operation spine**, generalized): one node per tactic in used/active/queued state. + The active node blooms open and **binds live telemetry** by `live_bind` keys — temperature gauge, + current burst, phase progress, cadence — sourced from the orchestrator `get_status` / + `strategy_snapshot` / the tactic events C emits. Per-embryo cadence (standing/async) shows as a + compact strip in the standing-tactic node. Reactive monitors render watch+reaction+status; scripted + protocols render the before/during/after stepper. +- Live refresh: subscribe to `CONTEXT_UPDATED` (plan changed) + the tactic telemetry events + (`TEMP_PROTOCOL_*`, `BURST_*`, `EMBRYO_CADENCE_CHANGED`, `POWER_RAMP_STEP`, `TEMPERATURE_SETPOINT_CHANGED`) + → debounced refetch+render. (Mirrors `experiment-strip.js` / `context-surface.js`.) +- **Audit fixes** (from the design audit) baked into the renderer: queued reads as a cocked instrument + (blue thread + "next" marker), the active phase/status is the headline, flatten the card (no + card-in-card), the active row's whole left column is amber + colored left edge per state, copy = + "queued", keep mono instrument values + domain copy. + +### 2.4 Scenario test mode (your "test it for real experiments" + "different tactic cases") +A repo scenario library of **Operation Plan fixtures** (the prototyped six cases, now as plan objects): +temp-strain, expression-onset, pre-hatching/hatching-detect, transmission survey, decided-plan, idle — +plus async-multi-embryo. `?scenario=` (dev-gated) renders a fixture with no agent/fetch, so +Operations is developable/auditable offline against any tactic case. The fixtures double as the test +corpus + a Chrome-MCP audit target. ## 3. Data flow -events → EventBus → websocket → `ClientEventBus` → debounced refetch → `/strategy` (now with -`operation`) → `build_operation_view` → spine renderer. In scenario mode: `?scenario=` → fixture → -same renderer (no fetch). - -## 4. Error/empty -- No active operation / no tactics → calm **idle** state ("When the agent begins operating by tactic, - the plan and live progress appear here"), not a dead screen. -- Snapshot lacks `operation` (older session) → renderer falls back to a minimal "no tactic data" note. -- Unknown tactic `kind` → renders generically from `name`/`summary`/`readouts` (the model is kind-agnostic). - -## 5. Testing -- Backend: `build_operation_view` over a hand-written snapshot → asserts the tactic list states + - the temp-protocol tactic's phases + a queued tactic's trigger. -- Frontend: `node --check`; the **scenario library** doubles as the test corpus — a Chrome-MCP audit - across all six fixtures (the harness, promoted); UI audit per the audit fixes. Live rig verification deferred. -- Scenario mode itself is the regression harness: any tactic case can be loaded and visually checked. - -## 6. Out of scope -- A general user-/agent-authored persisted tactic *library* (G) — D presents tactics; G lets users - define/save them. -- The Rules view rework; multi-embryo concurrent-operation layout (single active operation for now, - though the model + spine extend to a stack of operations later). +agent → `declare_operation_plan` (forced typed) → `FileContextStore` → `CONTEXT_UPDATED` → `/ws` → +Operations refetch → `/api/operation_plan` (the plan) **⊕** live telemetry (`get_status` / +`strategy_snapshot` / events) bound by `live_bind` → spine renderer. Scenario mode: `?scenario=` → +fixture plan → same renderer. + +## 4. Why agent-authored over backend reconstruction +`strategy_snapshot.py` already reconstructs the picture by replaying `timeline.jsonl` and re-instantiating +modes — but every new tactic kind needs new reconstruction heuristics, and it can only ever *infer* +intent. The agent *is* the composer; it should *declare* its tactics (authoritative, open-ontology, +self-describing). The backend keeps providing **live values** (what it's good at), not tactic identity. + +## 5. Convergence with G (tactics library) +Once tactics are typed, agent-authored objects, **G is "save/reuse these typed tactics"** and D is +"render the active plan of them." This spec is the shared substrate for both — settling it unblocks +both sub-projects. + +## 6. Testing +- Store: `set/get_operation_plan` round-trip + `CONTEXT_UPDATED` fired. +- Tool: the forced `declare_operation_plan` returns a schema-valid plan (mirror the notebook_ask test). +- Route: `/api/operation_plan/{session}` returns the stored plan; 404/empty handled. +- Renderer: `node --check` + Chrome-MCP audit across the scenario fixtures (all tactic kinds render). +- Live binding: a unit test that telemetry maps onto a tactic's `live_bind` keys. +- Rig-deferred: the real agent emitting a live plan during an experiment. + +## 7. Out of scope (v3) +- Full multi-embryo concurrent lanes (v1 = one standing mode + layered tactics + a per-embryo strip). +- G's authoring/save UI (this spec defines the tactic object G will reuse). +- Migrating away from `strategy_snapshot` for the legacy swimlane consumers (Operations is the new view). From 9d0c7d88d5013a4dd72445d7729f9b65cd327996 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:37:23 +0530 Subject: [PATCH 206/314] =?UTF-8?q?docs(operations):=20v3=20plan=20?= =?UTF-8?q?=E2=80=94=20agent-authored=20Operation=20Plan=20(5=20tasks,=20b?= =?UTF-8?q?ackend-first)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-28-operations-tab.md | 86 +++++++------------ 1 file changed, 33 insertions(+), 53 deletions(-) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index ab1768b2..e6a5c347 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -1,70 +1,50 @@ -# Operations — operation spine (D, redesigned) Implementation Plan +# Operations — agent-authored Operation Plan (D v3) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. -**Goal:** Replace the legacy swimlane with a data-driven **operation-spine** Operations view that shows tactics planned/in-use/used, renders any tactic case from a generic model, and is testable offline via scenario fixtures. +**Goal:** The agent emits a typed Operation Plan (its tactics, planned/active/done); Operations renders it ⊕ live telemetry. Built backend-first (the user-directed, low-rework part), then the renderer. -**Architecture:** A generic *operation* model (tactics with state + optional live readouts/phases); a rewritten `experiment-overview.js` overview = the spine renderer (audit fixes baked in); a repo scenario library (6 fixtures) + `?scenario=` dev mode; a backend `build_operation_view(snapshot)` transform served on `/strategy`; live event-driven refresh. +**Architecture:** A typed `OperationPlan` in `FileContextStore` (new domain), written by a forced-tool agent call, served on a route, rendered by a data-driven operation-spine generalized for tactic kinds, with live-telemetry binding + scenario test mode. -**Reference (the validated prototype — read it for the exact renderer, CSS, model, and fixtures):** -`/tmp/claude-1000/-home-dna-lab-projects-gently/3192c9a4-bc99-4c0a-9cbd-e094de730bcf/scratchpad/opsdesign/harness.html` +**Tech Stack:** Python (FileContextStore YAML, the `@tool`/forced-`tool_choice` pattern, EventBus `CONTEXT_UPDATED`), FastAPI route, vanilla-JS + SVG renderer, pytest. ## Global Constraints -- Keep the Experiment→Operations rename (done) + the burst-`phase` event (done). DISCARD the swimlane band. -- Operation model: `{title, session, tactics:[{seq, state:'used'|'active'|'queued', name, kind, target?, summary?, desc?, trigger?, live?:{readouts:[{label,value,sub?,bar?}], phases?:[{name,state:'done'|'active'|'todo',count,pips:[...]}]}}]}`. -- Audit fixes (baked into the renderer): (1) queued spine is colored blue + first queued marked "next" (decided-plan must read as a cocked instrument, not empty); (2) active phase dominates + "awaiting lock"-style status is the phase headline; (3) FLATTEN — no card-in-card, values on the panel face + hairline dividers; (4) active row's whole left column amber + colored left edge per state (green/amber/blue); (5) copy "queued" (not "going to be used"), replace "first/second/last" with `trigger`. Keep mono instrument values + domain copy. -- Renderer must handle ALL six fixtures (phased protocol, readout monitor, one-shot, all-queued, idle) without error. -- No new endpoint: extend `/api/experiments/{session}/strategy` payload with an `operation` object. -- Frontend has no JS unit harness → `node --check` + Chrome-MCP audit across fixtures. +- Source of truth = the agent's typed plan (NOT a backend reconstruction). Live telemetry binds onto declared tactics; the agent declares tactic identity, the system supplies live values. +- Plan schema (see spec §1): `{session_id,title,goal,tactics:[{id,name,kind,state,scope,rationale,structure,live_bind,relations}],updated_at,updated_reason}`. `kind∈{standing_timelapse,reactive_monitor,scripted_protocol,exclusive_burst,oneshot,custom}`, `state∈{planned,active,done}`. No round-robin. +- Forced typed output mirrors `gently/harness/memory/notebook_ask.py` (ASK_TOOL + `tool_choice={'type':'tool',...}` → validated `block.input`) OR the `@tool` auto-schema (`harness/tools/registry.py`). +- Store mirrors `session_intents`/`active/` domains; fire the existing `CONTEXT_UPDATED`. +- Route mirrors `gently/ui/web/routes/context.py` (`/api/context`). +- Renderer = the validated operation-spine (harness `scratchpad/opsdesign/harness.html`) with the audit fixes (queued reads cocked + "next" marker; active phase/status is the headline; flatten — no card-in-card; whole-left-column amber + colored left edge per state; copy "queued"; mono values). Generalize for the tactic kinds (standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phases). +- Frontend: no JS harness → `node --check` + Chrome-MCP audit across scenario fixtures. - Git hygiene: stage only your files by explicit path; never `git add -A`. --- -### Task 1: Discard the swimlane band -**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (+ its css) — revert the tactic-band rendering added in commit 2a3d8b0, returning the Overview to its pre-band baseline (the spine replaces it in Task 2). Keep everything else (Rules view, modes, etc.). -- [ ] **Step 1:** `git revert --no-commit 2a3d8b0` (or manually remove the band code + css if revert conflicts), keeping the rename (Task-1) and burst-phase (Task-2) commits intact. Confirm `git diff` shows only the band removal. -- [ ] **Step 2:** `node --check gently/ui/web/static/js/experiment-overview.js` (exit 0). -- [ ] **Step 3: commit** — `git add gently/ui/web/static/js/experiment-overview.js && git commit -m "feat(operations): discard swimlane tactic-band (replaced by operation spine)"` +### Task 1: OperationPlan model + FileContextStore domain +**Files:** Create/extend `gently/harness/memory/model.py` (an `OperationPlan`/`Tactic` dataclass or a documented dict schema); Modify `gently/harness/memory/file_store.py` (`set_operation_plan(session_id, plan)` / `get_operation_plan(session_id)`, YAML under `agent/operation_plans/{session_id}.yaml`, fire `CONTEXT_UPDATED`). Test: `tests/test_operation_plan_store.py`. +- [ ] Confirm the real `FileContextStore` domain pattern from `set_session_intent`/`create_session_intent` (~file_store.py:758) + the `_notify_*`/`CONTEXT_UPDATED` emit. Mirror it. +- [ ] TDD: set→get round-trip preserves the tactics list + states; `CONTEXT_UPDATED` fired on set. `pytest tests/test_operation_plan_store.py -v`; `pytest -q` clean. Commit `feat(operations): OperationPlan store domain in FileContextStore`. ---- - -### Task 2: Scenario library + the operation-spine renderer (the core) -**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (the 6 fixtures, promoted from the harness); Modify `gently/ui/web/static/js/experiment-overview.js` (replace the Overview render path with the data-driven spine renderer from the harness, audit fixes applied) + the experiment css (spine/node/phase styles from the harness). Verify: `node --check` + Chrome-MCP audit. - -**Interfaces:** Produces `OperationView.render(rootEl, operationModel)` (or fold into `ExperimentOverview`); consumes the operation model (§ Global Constraints) from the live snapshot (Task 3) or a scenario fixture; `OPERATIONS_SCENARIOS` (the fixture map) + a `?scenario=` loader (dev mode). - -- [ ] **Step 1: port the fixtures** — create `operations-scenarios.js` exposing `window.OPERATIONS_SCENARIOS = {temp_strain, expression_onset, pre_hatching, transmission_survey, decided_plan, idle}` — copy the fixture objects from the harness `SCENARIOS` (the `.op` values), as the repo test corpus. Apply the copy fixes: `state:'queued'` (not 'planned' label), add `trigger` fields ("when temp locks", "after #1", etc.) replacing "first/second/last". -- [ ] **Step 2: the renderer** — port the harness renderer (`renderOperation`/`renderTactic`/`renderReadout`/`renderPhase`) into `experiment-overview.js`, replacing the swimlane Overview path, WITH the audit fixes: state-colored spine connector (green/amber/blue-dashed), whole-left-column amber for active, colored left edge per state, flattened active card (remove inner card backgrounds; hairline divider between readouts and phases), active phase dominant + status headline, first-queued "next" marker, `STATE_LABEL.queued='queued'`. Keep mono labels. -- [ ] **Step 3: scenario dev mode** — on init, if `new URLSearchParams(location.search).get('scenario')` matches a fixture, render that fixture (no fetch); else fetch the live operation (Task 3). Guard so scenario mode is opt-in via the param. -- [ ] **Step 4: `node --check`** both JS files (exit 0). -- [ ] **Step 5: Chrome-MCP audit** — serve the static files, load `?scenario=temp_strain`, `expression_onset`, `decided_plan`, `idle`; screenshot each; verify the audit fixes (queued reads cocked, active dominates, flattened, amber left column, "queued" copy) and that all six render without error; fix flaws. -- [ ] **Step 6: commit** — `feat(operations): operation-spine renderer + scenario library (data-driven, 6 cases)` - ---- +### Task 2: `declare_operation_plan` agent tool (forced typed output) +**Files:** Create `gently/app/tools/operation_plan_tools.py` (+ register in `tools/__init__`). Test: `tests/test_operation_plan_tool.py`. +- [ ] Confirm the `@tool` decorator + the forced-tool pattern (registry.py auto-schema, or a literal schema like notebook_ask.ASK_TOOL). The tool accepts the typed plan (tactics list) and writes it via `store.set_operation_plan`. Resolve the store from context (mirror existing memory tools). +- [ ] TDD: calling the tool with a plan persists it (get returns it) + returns a confirmation; missing store → error. `pytest tests/test_operation_plan_tool.py -v`; `pytest -q` clean. Commit `feat(operations): declare_operation_plan typed agent tool`. -### Task 3: Backend transform `build_operation_view` -**Files:** Modify `gently/ui/web/strategy_snapshot.py` (add `build_operation_view(snapshot)` mapping the snapshot → operation model; have `build_strategy_snapshot` include `operation` in its return, OR add it in the route). Test: `tests/test_operation_view.py`. +### Task 3: Route `GET /api/operation_plan/{session_id}` +**Files:** Create `gently/ui/web/routes/operation_plan.py` (+ register in `routes/__init__.py`). Test: `tests/test_operation_plan_route.py`. +- [ ] Mirror `routes/context.py` / `routes/temperature.py` `_resolve_session` + `server.context_store`/`gently_store` resolution. Return the stored plan; 404/empty handled; `session=current` resolves newest. +- [ ] TDD (TestClient + mock store): returns the plan; empty when none. `pytest tests/test_operation_plan_route.py -v`; `pytest -q` clean. Commit `feat(operations): GET /api/operation_plan/{session} route`. -**Interfaces:** Produces `build_operation_view(snapshot: dict) -> dict` (the operation model); `/api/experiments/{session}/strategy` response gains `operation`. +### Task 4: The operation-spine renderer + scenario library (frontend) +**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (plan fixtures: temp_strain, expression_onset, hatching_detect, transmission_survey, decided_plan, async_multi, idle); Rewrite the Overview path in `gently/ui/web/static/js/experiment-overview.js` to the data-driven operation-spine (generalized per tactic kind, audit fixes); port CSS to `experiment.css`. Reference: `scratchpad/opsdesign/harness.html`. +- [ ] Port the renderer (renderOperation/renderTactic/renderReadout/renderPhase) + add per-kind rendering: standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phase stepper; exclusive/oneshot→compact. Audit fixes baked in. `?scenario=` dev mode loads a fixture. +- [ ] `node --check` both JS; build a Chrome-MCP harness at `scratchpad/opsv3/` (real repo files) for the controller to audit across fixtures. Commit `feat(operations): operation-spine renderer + plan scenario library (data-driven)`. -- [ ] **Step 1: failing test** — feed a snapshot dict (one embryo with a `temp_protocol` band {start,end,target_setpoint_c}, `setpoint_changes`, phased bursts, and an active monitoring mode) to `build_operation_view`; assert the returned `operation.tactics` includes: a `used` monitor tactic, an `active` temp-change tactic whose `live.phases` reflect before/during/after, and the `state`s are correct. (Confirm the real snapshot shape from `tests/test_temp_protocol_snapshot.py`.) -- [ ] **Step 2: run, FAIL.** -- [ ] **Step 3: implement** `build_operation_view`: derive tactics from the snapshot — completed monitoring modes/bursts → `used`; the active `temp_protocol`/monitoring mode → `active` (with readouts from temp + current burst, phases from the protocol's before/during/after burst counts); planned/queued items → `queued`. Map fields into the §model. Include `operation` in the snapshot/route payload. -- [ ] **Step 4: run, PASS;** `pytest -q` clean. -- [ ] **Step 5: commit** — `feat(operations): build_operation_view transform on the strategy snapshot` - ---- - -### Task 4: Live refresh -**Files:** Modify `experiment-overview.js` (`init` — subscribe + debounced refetch, mirroring `experiment-strip.js`). Verify: `node --check` + harness (simulate an event → refetch). -- [ ] **Step 1:** in `init`, after the initial live fetch, register `ClientEventBus.on(...)` for `TEMP_PROTOCOL_STARTED`,`TEMP_PROTOCOL_COMPLETED`,`TEMPERATURE_SETPOINT_CHANGED`,`BURST_START`,`BURST_COMPLETE`,`EMBRYO_CADENCE_CHANGED`,`POWER_RAMP_STEP`, each → a debounced (~500ms) refetch+render. Guard against double-registration; skip in scenario mode. -- [ ] **Step 2:** `node --check` (exit 0). -- [ ] **Step 3: harness** — dispatch a `ClientEventBus.emit('TEMP_PROTOCOL_STARTED', {...})` and confirm a (stubbed) refetch fires after the debounce. -- [ ] **Step 4: commit** — `feat(operations): live event-driven refresh` - ---- +### Task 5: Live binding + refresh +**Files:** Modify `experiment-overview.js` (fetch `/api/operation_plan`, bind live telemetry from `/strategy`/get_status onto tactics by `live_bind`, subscribe `CONTEXT_UPDATED` + tactic events → debounced refetch). +- [ ] Bind temperature/current-burst/cadence/signal onto declared tactics' readouts/progress; live refresh on plan-change + telemetry events (debounced). `node --check`; harness check. Commit `feat(operations): live telemetry binding + event-driven refresh`. ## Self-Review -- Discard swimlane → Task 1; renderer+scenarios → Task 2 (audit fixes baked in); backend transform → Task 3; live refresh → Task 4. ✓ -- Open confirmations: the real snapshot shape (Task 3, from C's snapshot test); the `experiment-overview.js` Overview render seam to replace (Task 2, read the harness for the target renderer); the css file for experiment styles (Task 1/2). -- Type consistency: the operation model fields are identical across the fixtures (Task 2), the transform (Task 3), and the renderer (Task 2). +- Store→Task1; tool→Task2; route→Task3; renderer+scenarios→Task4 (audit fixes); live binding→Task5. ✓ +- Open confirmations: FileContextStore domain pattern (T1), the forced-tool/@tool pattern (T2), the route store-resolution (T3), the Overview render seam + harness renderer (T4), the live telemetry sources (T5). +- Type consistency: the plan/tactic schema is identical across store (T1), tool (T2), route (T3), fixtures+renderer (T4), binding (T5). From 192fe97e5fe084dfa296b5e728e84d29306ebaeb Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:41:21 +0530 Subject: [PATCH 207/314] feat(operations): OperationPlan store domain in FileContextStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add set_operation_plan / get_operation_plan to FileContextStore under agent/operation_plans/{session_id}.yaml, mirroring the session_intents domain (atomic _write_yaml, parse-cached _read_yaml). set fires _notify_context_change('operation_plan') → CONTEXT_UPDATED so the Operations UI live-refreshes. 10 tests: round-trip fidelity (tactics list, states, nested structure, overwrite, independent sessions, missing→None) + CONTEXT_UPDATED fired on set (kind='operation_plan') and silent on get. --- gently/harness/memory/file_store.py | 19 +++ tests/test_operation_plan_store.py | 173 ++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 tests/test_operation_plan_store.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index ececeba2..0e831c12 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -811,6 +811,25 @@ def complete_session_intent(self, session_id: str, actual_summary: str): data["completed_at"] = self._now() self._write_yaml(path, data) + # ================================================================== + # Operation Plans + # ================================================================== + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + """Persist the agent-authored Operation Plan for a session. + + The plan dict is stored verbatim (agent is the source of truth). + Fires CONTEXT_UPDATED so the Operations UI refreshes live. + """ + path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" + self._write_yaml(path, plan) + self._notify_context_change("operation_plan") + + def get_operation_plan(self, session_id: str) -> dict | None: + """Return the stored Operation Plan for a session, or None if absent.""" + path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" + return self._read_yaml(path) + # ================================================================== # Session <-> Campaign (many-to-many) # ================================================================== diff --git a/tests/test_operation_plan_store.py b/tests/test_operation_plan_store.py new file mode 100644 index 00000000..c656e410 --- /dev/null +++ b/tests/test_operation_plan_store.py @@ -0,0 +1,173 @@ +""" +FileContextStore: OperationPlan domain — set/get round-trip + CONTEXT_UPDATED event. + +Domain: agent/operation_plans/{session_id}.yaml +Methods: set_operation_plan / get_operation_plan +""" + +from gently.core.event_bus import EventType, on + + +# --------------------------------------------------------------------------- +# Fixture helpers +# --------------------------------------------------------------------------- + +_PLAN = { + "session_id": "sess_001", + "title": "Temperature-strain protocol", + "goal": "Characterise onset timing under 25 °C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": { + "cadence_s": 120, + "per_embryo": [ + {"embryo_id": "e1", "cadence_phase": "normal", "interval_s": 120} + ], + }, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Temp ramp monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Detect onset event triggered by ramp", + "structure": { + "watch": "temperature > 24.5", + "reaction": "burst_capture", + "status": "armed", + }, + "live_bind": ["temperature", "signal"], + "relations": {"after": ["t1"]}, + }, + { + "id": "t3", + "name": "Post-ramp survey", + "kind": "oneshot", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["e1", "e2"]}, + "rationale": "Confirm recovery after ramp completion", + "structure": {"note": "single high-res z-stack per embryo"}, + "live_bind": [], + "relations": {"after": ["t2"]}, + }, + ], + "updated_at": "2026-06-28T10:00:00", + "updated_reason": "initial plan", +} + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestOperationPlanRoundTrip: + def test_set_then_get_returns_plan(self, file_context_store): + """set_operation_plan followed by get_operation_plan returns the full dict.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + assert result is not None + assert result["session_id"] == "sess_001" + assert result["title"] == "Temperature-strain protocol" + + def test_tactics_list_preserved(self, file_context_store): + """All three tactics are returned with their ids and kinds intact.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + tactics = result["tactics"] + assert len(tactics) == 3 + ids = [t["id"] for t in tactics] + assert ids == ["t1", "t2", "t3"] + kinds = [t["kind"] for t in tactics] + assert kinds == ["standing_timelapse", "reactive_monitor", "oneshot"] + + def test_tactic_states_preserved(self, file_context_store): + """State values (done / active / planned) survive the YAML round-trip.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + states = {t["id"]: t["state"] for t in result["tactics"]} + assert states == {"t1": "done", "t2": "active", "t3": "planned"} + + def test_nested_structure_preserved(self, file_context_store): + """Nested fields (scope, structure, live_bind, relations) survive intact.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + result = cs.get_operation_plan("sess_001") + t2 = next(t for t in result["tactics"] if t["id"] == "t2") + assert t2["structure"]["status"] == "armed" + assert "temperature" in t2["live_bind"] + assert t2["relations"]["after"] == ["t1"] + + def test_get_missing_returns_none(self, file_context_store): + """get_operation_plan returns None when no plan exists for the session.""" + result = file_context_store.get_operation_plan("no_such_session") + assert result is None + + def test_overwrite_replaces_plan(self, file_context_store): + """A second set_operation_plan replaces the first.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + updated = dict(_PLAN, title="Revised plan", updated_reason="tactic t2 fired") + cs.set_operation_plan("sess_001", updated) + result = cs.get_operation_plan("sess_001") + assert result["title"] == "Revised plan" + assert result["updated_reason"] == "tactic t2 fired" + + def test_independent_sessions(self, file_context_store): + """Plans for different session_ids are stored independently.""" + cs = file_context_store + plan_a = dict(_PLAN, session_id="sess_A", title="Plan A") + plan_b = dict(_PLAN, session_id="sess_B", title="Plan B") + cs.set_operation_plan("sess_A", plan_a) + cs.set_operation_plan("sess_B", plan_b) + assert cs.get_operation_plan("sess_A")["title"] == "Plan A" + assert cs.get_operation_plan("sess_B")["title"] == "Plan B" + + +class TestOperationPlanContextUpdatedEvent: + def test_set_fires_context_updated(self, file_context_store): + """set_operation_plan emits CONTEXT_UPDATED on the global event bus.""" + cs = file_context_store + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.set_operation_plan("sess_001", _PLAN) + finally: + unsub() + assert len(seen) >= 1 + + def test_context_updated_kind_is_operation_plan(self, file_context_store): + """The CONTEXT_UPDATED event carries kind='operation_plan'.""" + cs = file_context_store + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.set_operation_plan("sess_001", _PLAN) + finally: + unsub() + kinds = [(e.data or {}).get("kind") for e in seen] + assert "operation_plan" in kinds + + def test_get_does_not_fire_event(self, file_context_store): + """get_operation_plan is read-only and must not emit CONTEXT_UPDATED.""" + cs = file_context_store + cs.set_operation_plan("sess_001", _PLAN) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.get_operation_plan("sess_001") + finally: + unsub() + assert len(seen) == 0 From 1472dd086d5c8c61edad6a87c603690140cf2318 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:51:39 +0530 Subject: [PATCH 208/314] feat(operations): declare_operation_plan typed agent tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the declare_operation_plan @tool for the agent to author its typed Operation Plan (tactics: planned/active/done) per experiment session. - gently/app/tools/operation_plan_tools.py: @tool decorator, signature (title, goal, tactics, updated_reason, context), _validate_tactics helper (clamps unknown kinds to 'custom', rejects invalid states), stamps updated_at, resolves session_id + context_store via agent in context, calls store.set_operation_plan(session_id, plan). - gently/app/tools/__init__.py: registers operation_plan_tools module. - tests/test_operation_plan_tool.py: 15 TDD tests — happy path round-trip, tactics preserved, unknown-kind clamping, three error paths (no agent, no store, no session), two validation-error paths, registry discoverability, schema required-fields check. --- gently/app/tools/__init__.py | 2 + gently/app/tools/operation_plan_tools.py | 195 +++++++++++ tests/test_operation_plan_tool.py | 424 +++++++++++++++++++++++ 3 files changed, 621 insertions(+) create mode 100644 gently/app/tools/operation_plan_tools.py create mode 100644 tests/test_operation_plan_tool.py diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index 16d7aebb..b3b31d1b 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -38,6 +38,7 @@ led_tools, light_source_tools, memory_tools, + operation_plan_tools, plan_execution_tools, resolution_tools, session_tools, @@ -85,6 +86,7 @@ def register_all_tools(): "led_tools", "light_source_tools", "memory_tools", + "operation_plan_tools", "plan_execution_tools", "resolution_tools", "session_tools", diff --git a/gently/app/tools/operation_plan_tools.py b/gently/app/tools/operation_plan_tools.py new file mode 100644 index 00000000..07d51835 --- /dev/null +++ b/gently/app/tools/operation_plan_tools.py @@ -0,0 +1,195 @@ +""" +Operation Plan tool — lets the agent declare/update its typed Operation Plan. + +The agent calls this at experiment planning time to register the tactics it +intends to run, and calls it again on each tactic transition (planned → +active → done). The plan is stored in FileContextStore and fires +CONTEXT_UPDATED so the Operations UI refreshes live. +""" + +import logging +from datetime import datetime, timezone + +from gently.harness.tools.helpers import require_agent +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + +logger = logging.getLogger(__name__) + +# Valid kind / state values per §1 of the spec +_VALID_KINDS = frozenset( + { + "standing_timelapse", + "reactive_monitor", + "scripted_protocol", + "exclusive_burst", + "oneshot", + "custom", + } +) +_VALID_STATES = frozenset({"planned", "active", "done"}) + + +def _validate_tactics(tactics: list) -> list[dict]: + """Validate and normalise a tactics list. + + Each tactic must have id, name, kind, state. + Unknown kinds are clamped to 'custom'. + Invalid states raise ValueError. + """ + validated = [] + for i, t in enumerate(tactics): + if not isinstance(t, dict): + raise ValueError(f"Tactic at index {i} must be a dict, got {type(t).__name__}") + for required_field in ("id", "name", "kind", "state"): + if not t.get(required_field): + raise ValueError(f"Tactic at index {i} missing required field '{required_field}'") + tactic = dict(t) + kind = tactic["kind"] + if kind not in _VALID_KINDS: + logger.warning( + "Tactic '%s' has unknown kind '%s' — clamped to 'custom'", + tactic.get("id"), + kind, + ) + tactic["kind"] = "custom" + state = tactic["state"] + if state not in _VALID_STATES: + raise ValueError( + f"Tactic '{tactic.get('id')}' has invalid state '{state}'. " + f"Must be one of: {sorted(_VALID_STATES)}" + ) + validated.append(tactic) + return validated + + +@tool( + name="declare_operation_plan", + description=( + "Declare or update the Operation Plan for the current experiment session. " + "Call this at experiment planning time to register the tactics you intend to " + "run, and call it again whenever a tactic's state changes (planned → active → " + "done) or a new tactic is added. " + "kind ∈ {standing_timelapse, reactive_monitor, scripted_protocol, " + "exclusive_burst, oneshot, custom}. " + "state ∈ {planned, active, done}. " + "Unknown kinds are clamped to 'custom'." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Declare my experiment plan: baseline timelapse + onset monitor", + tool_input={ + "title": "Expression-onset survey", + "goal": "Catch GFP onset under 25 C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } + ], + "updated_reason": "experiment started", + }, + ), + ToolExample( + user_query="Update the plan: baseline is done, onset monitor is now active", + tool_input={ + "title": "Expression-onset survey", + "goal": "Catch GFP onset under 25 C ramp", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Onset monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Watch for GFP signal crossing threshold", + "structure": { + "watch": "gfp_signal > 0.3", + "reaction": "burst_capture", + "status": "armed", + }, + "live_bind": ["signal", "current_burst"], + "relations": {"after": ["t1"]}, + }, + ], + "updated_reason": "t1 done, t2 now active", + }, + ), + ], +) +async def declare_operation_plan( + title: str, + goal: str, + tactics: list, + updated_reason: str = "", + context: dict | None = None, +) -> str: + """Declare or update the typed Operation Plan for this session. + + Parameters + ---------- + title : str + Short title for this experiment's plan. + goal : str + The agent's framing of what this run is trying to achieve. + tactics : list + List of tactic dicts. Each must have id, name, kind, state. + updated_reason : str + Why the plan is being updated (e.g. 't2 transitioned to active'). + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot persist Operation Plan" + + session_id = getattr(agent, "session_id", None) + if not session_id: + return "Error: No active session — cannot persist Operation Plan" + + try: + validated_tactics = _validate_tactics(tactics) + except ValueError as e: + return f"Error: {e}" + + plan = { + "session_id": session_id, + "title": title, + "goal": goal, + "tactics": validated_tactics, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_reason": updated_reason, + } + + cs.set_operation_plan(session_id, plan) + + n = len(validated_tactics) + counts = {s: sum(1 for t in validated_tactics if t["state"] == s) for s in _VALID_STATES} + parts = [f"{s}:{c}" for s, c in counts.items() if c] + states_summary = ", ".join(parts) + return ( + f"Operation Plan stored for session {session_id}: " + f"'{title}' — {n} tactic{'s' if n != 1 else ''} ({states_summary})" + ) diff --git a/tests/test_operation_plan_tool.py b/tests/test_operation_plan_tool.py new file mode 100644 index 00000000..ad6804d6 --- /dev/null +++ b/tests/test_operation_plan_tool.py @@ -0,0 +1,424 @@ +""" +Tests for the declare_operation_plan agent tool (Task 2). + +TDD — three concerns: + 1. Happy path: calling the tool persists the plan; store returns it. + 2. Error paths: missing store, missing session → error string, no write. + 3. Registration: tool is discoverable in the global registry after import. +""" + +import pytest + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Minimal in-memory stand-in for FileContextStore.""" + + def __init__(self): + self._plans: dict[str, dict] = {} + self.calls: list[tuple] = [] + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + self._plans[session_id] = plan + self.calls.append(("set", session_id, plan)) + + def get_operation_plan(self, session_id: str) -> dict | None: + return self._plans.get(session_id) + + +class FakeAgent: + """Minimal fake agent — carries context_store and session_id.""" + + def __init__(self, *, context_store=None, session_id: str | None = "sess_test_01"): + self.context_store = context_store + self.session_id = session_id + + +def _make_context(*, with_store=True, with_session=True): + store = FakeContextStore() if with_store else None + sid = "sess_test_01" if with_session else None + agent = FakeAgent(context_store=store, session_id=sid) + return {"agent": agent} + + +# --------------------------------------------------------------------------- +# Minimal tactic fixture +# --------------------------------------------------------------------------- + +_TACTICS_MINIMAL = [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } +] + +_TACTICS_MULTI = [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "done", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Onset monitor", + "kind": "reactive_monitor", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Detect onset crossing threshold", + "structure": {"watch": "gfp_signal > 0.3", "reaction": "burst_capture", "status": "armed"}, + "live_bind": ["signal", "current_burst"], + "relations": {"after": ["t1"]}, + }, + { + "id": "t3", + "name": "Final survey", + "kind": "oneshot", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["e1", "e2"]}, + "rationale": "Confirm recovery after ramp", + "structure": {"note": "single high-res z-stack"}, + "live_bind": [], + "relations": {"after": ["t2"]}, + }, +] + + +# --------------------------------------------------------------------------- +# Happy-path tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_plan_is_persisted_to_store(): + """Calling the tool with a valid plan stores it; get_operation_plan returns it.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + result = await declare_operation_plan( + title="Expression-onset survey", + goal="Catch GFP onset under 25 C ramp", + tactics=_TACTICS_MINIMAL, + updated_reason="experiment started", + context=ctx, + ) + + store = ctx["agent"].context_store + stored = store.get_operation_plan("sess_test_01") + assert stored is not None, "Plan should have been persisted" + assert stored["title"] == "Expression-onset survey" + assert stored["goal"] == "Catch GFP onset under 25 C ramp" + assert stored["session_id"] == "sess_test_01" + assert "Error" not in result + + +@pytest.mark.asyncio +async def test_confirmation_string_contains_session_and_title(): + """The return value identifies the session and plan title.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + result = await declare_operation_plan( + title="Temperature-strain protocol", + goal="Characterise onset timing under 25 C ramp", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "sess_test_01" in result + assert "Temperature-strain protocol" in result + + +@pytest.mark.asyncio +async def test_tactics_list_preserved_in_store(): + """All tactics are stored with correct ids, kinds, and states.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Multi-tactic plan", + goal="Three-phase experiment", + tactics=_TACTICS_MULTI, + updated_reason="t1 done, t2 active", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored is not None + tactics = stored["tactics"] + assert len(tactics) == 3 + assert [t["id"] for t in tactics] == ["t1", "t2", "t3"] + assert [t["state"] for t in tactics] == ["done", "active", "planned"] + assert [t["kind"] for t in tactics] == ["standing_timelapse", "reactive_monitor", "oneshot"] + + +@pytest.mark.asyncio +async def test_updated_at_is_stamped(): + """The plan stored in the store has a non-empty updated_at timestamp.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored is not None + assert stored.get("updated_at"), "updated_at should be stamped" + + +@pytest.mark.asyncio +async def test_updated_reason_stored(): + """updated_reason is carried into the stored plan.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + updated_reason="tactic t1 transitioned to active", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["updated_reason"] == "tactic t1 transitioned to active" + + +@pytest.mark.asyncio +async def test_set_called_exactly_once(): + """set_operation_plan is called exactly once per tool invocation.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + store = ctx["agent"].context_store + set_calls = [c for c in store.calls if c[0] == "set"] + assert len(set_calls) == 1 + + +@pytest.mark.asyncio +async def test_overwrite_replaces_plan(): + """A second call replaces the first plan in the store.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + await declare_operation_plan( + title="Initial plan", + goal="First framing", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + await declare_operation_plan( + title="Revised plan", + goal="Updated framing", + tactics=_TACTICS_MULTI, + updated_reason="added two tactics", + context=ctx, + ) + + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["title"] == "Revised plan" + assert len(stored["tactics"]) == 3 + + +# --------------------------------------------------------------------------- +# Unknown kind → clamped to 'custom' +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_unknown_kind_clamped_to_custom(): + """A tactic with an unrecognised kind is silently clamped to 'custom'.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + tactics = [ + { + "id": "t1", + "name": "Novel tactic", + "kind": "future_kind_v99", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Something new", + "structure": {"note": "TBD"}, + "live_bind": [], + "relations": {}, + } + ] + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=tactics, + context=ctx, + ) + + assert "Error" not in result + stored = ctx["agent"].context_store.get_operation_plan("sess_test_01") + assert stored["tactics"][0]["kind"] == "custom" + + +# --------------------------------------------------------------------------- +# Error paths — no write must occur +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_no_agent_returns_error_no_write(): + """No agent in context → error string, store is never called.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + # No agent key at all + ctx: dict = {} + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "error" in result.lower() or "Error" in result + + +@pytest.mark.asyncio +async def test_missing_store_returns_error_no_write(): + """Agent has no context_store → error string, nothing persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context(with_store=False) + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store is None # store was never created + + +@pytest.mark.asyncio +async def test_missing_session_id_returns_error_no_write(): + """Agent has no session_id → error string, store.set is never called.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context(with_session=False) + result = await declare_operation_plan( + title="Any plan", + goal="Any goal", + tactics=_TACTICS_MINIMAL, + context=ctx, + ) + + assert "Error" in result + store = ctx["agent"].context_store + assert store.calls == [], "set_operation_plan must not be called without a session_id" + + +# --------------------------------------------------------------------------- +# Validation errors — no write on invalid tactics +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_missing_tactic_id_returns_error_no_write(): + """A tactic missing 'id' causes an error; nothing is persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + bad_tactics = [{"name": "No id", "kind": "oneshot", "state": "planned"}] + result = await declare_operation_plan( + title="Bad plan", + goal="Any goal", + tactics=bad_tactics, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store.calls == [] + + +@pytest.mark.asyncio +async def test_invalid_state_returns_error_no_write(): + """A tactic with an invalid state causes an error; nothing is persisted.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + ctx = _make_context() + bad_tactics = [ + { + "id": "t1", + "name": "Bad state", + "kind": "oneshot", + "state": "running", # not a valid state + } + ] + result = await declare_operation_plan( + title="Bad plan", + goal="Any goal", + tactics=bad_tactics, + context=ctx, + ) + + assert "Error" in result + assert ctx["agent"].context_store.calls == [] + + +# --------------------------------------------------------------------------- +# Registration test +# --------------------------------------------------------------------------- + + +def test_tool_is_registered_in_global_registry(): + """declare_operation_plan is discoverable via the global tool registry after import.""" + import gently.app.tools # noqa: F401 — triggers registration side effects + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + names = [t.name for t in registry.list_all()] + assert "declare_operation_plan" in names, ( + f"Tool not found in registry. Registered tools: {names}" + ) + + +def test_tool_schema_has_required_fields(): + """The auto-generated Claude schema has the expected required fields.""" + import gently.app.tools # noqa: F401 + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + tool_def = registry.get("declare_operation_plan") + assert tool_def is not None + + schema = tool_def.to_claude_schema() + assert schema["name"] == "declare_operation_plan" + required = schema["input_schema"]["required"] + assert "title" in required + assert "goal" in required + assert "tactics" in required + # updated_reason has a default, so it must NOT be in required + assert "updated_reason" not in required From f0d09bd0621d26b67b8e924c16e7298538542ae8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:52:46 +0530 Subject: [PATCH 209/314] docs(operations): add execution-linkage tasks (transition_tactic, tool start-edges, OperationPlanUpdater) --- .../plans/2026-06-28-operations-tab.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index e6a5c347..208d642d 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -48,3 +48,18 @@ - Store→Task1; tool→Task2; route→Task3; renderer+scenarios→Task4 (audit fixes); live binding→Task5. ✓ - Open confirmations: FileContextStore domain pattern (T1), the forced-tool/@tool pattern (T2), the route store-resolution (T3), the Overview render seam + harness renderer (T4), the live telemetry sources (T5). - Type consistency: the plan/tactic schema is identical across store (T1), tool (T2), route (T3), fixtures+renderer (T4), binding (T5). + +--- +## Execution-linkage tasks (added after recon — close the planning→execution loop) + +### Task 6: `transition_tactic` store helper +**Files:** Modify `gently/harness/memory/file_store.py` (add `transition_tactic(session_id, tactic_id, state=None, **bind)` next to `set/get_operation_plan` ~:818 — read the plan, find the tactic by `id`, set `state` and merge `bind` into its `live`/`structure`, write back, fire `CONTEXT_UPDATED`; no-op if plan/tactic absent). Test: `tests/test_transition_tactic.py`. +- [ ] TDD: declare a plan, transition a tactic planned→active with a `request_id` bind → get shows the new state + bound value; unknown tactic_id → no-op (no crash). Commit `feat(operations): transition_tactic store helper`. + +### Task 7: tactic_id threading + start-edge marking in execution tools +**Files:** Modify `gently/app/tools/timelapse_tools.py` (`enable_monitoring_mode`, `queue_burst`, stop/pause), `gently/app/tools/temperature_protocol_tools.py`, and the burst/protocol event payloads (`exclusive.py` BURST_*, `temperature_protocol.py` TEMP_PROTOCOL_*) to carry an optional `tactic_id`. On execute, the tool calls `cs.transition_tactic(session, tactic_id, 'active')`; on stop/pause → 'done'/'paused'. Test: `tests/test_tool_tactic_linkage.py`. +- [ ] Add optional `tactic_id` param; thread into event `data`; flip the plan tactic active on execute (guard: only if a plan + tactic_id exist). TDD with a fake context store capturing transitions. Commit `feat(operations): link execution tools to plan tactics via tactic_id`. + +### Task 8: `OperationPlanUpdater` service (completion edges via the bus) +**Files:** Create `gently/app/operation_plan_updater.py` (a `Service` modeled on `gently/app/temperature_sampler.py` / `TimelineManager`); wire in `gently/app/agent.py` beside the temperature sampler (~:838-849). Test: `tests/test_operation_plan_updater.py`. +- [ ] Subscribe `BURST_COMPLETE`, `TEMP_PROTOCOL_COMPLETED`, `EMBRYO_CADENCE_CHANGED`, `TRIGGER_FIRED`; on each, resolve the tactic (by `tactic_id` in payload, else by kind+embryo) and `cs.transition_tactic(session, tactic_id, 'done', **bind)` binding live values (request_id/mp4_path/setpoint/cadence). Mirror the sampler's start/stop lifecycle + session_id getter. TDD against a fake bus + store. Commit `feat(operations): OperationPlanUpdater — execution events transition plan tactics`. From a75d23ff3abb89fa6673dfe065a9200047a91486 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:57:05 +0530 Subject: [PATCH 210/314] feat(operations): GET /api/operation_plan/{session} route Adds read-only route returning the agent-authored Operation Plan from FileContextStore.get_operation_plan(). "current" resolves via gently_store.list_sessions()[0]. Returns {available, plan, session_id}; graceful unavailable when store is absent or plan not yet written. Registered in routes/__init__.py alongside other route factories. --- gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/operation_plan.py | 40 ++++++++++++ tests/test_operation_plan_route.py | 87 ++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 gently/ui/web/routes/operation_plan.py create mode 100644 tests/test_operation_plan_route.py diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 66e8842c..6cd54a7a 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -13,6 +13,7 @@ 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 .operation_plan import create_router as create_operation_plan_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 @@ -39,6 +40,7 @@ def register_all_routes(server): create_context_router, create_notebook_router, create_temperature_router, + create_operation_plan_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/operation_plan.py b/gently/ui/web/routes/operation_plan.py new file mode 100644 index 00000000..9e0d8c82 --- /dev/null +++ b/gently/ui/web/routes/operation_plan.py @@ -0,0 +1,40 @@ +"""Operation Plan route. + +Returns the agent-authored Operation Plan for a session. +The plan is stored in FileContextStore (``server.context_store``) keyed by +session_id. ``session_id="current"`` resolves to the newest session via the +FileStore (``server.gently_store``). +""" + +from fastapi import APIRouter, HTTPException + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str) -> str: + if session_id == "current": + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + return session_id + + @router.get("/api/operation_plan/{session_id}") + async def get_operation_plan(session_id: str): + real_id = _resolve_session(session_id) + cs = getattr(server, "context_store", None) + if cs is None: + return {"session_id": real_id, "available": False, "plan": None} + try: + plan = cs.get_operation_plan(real_id) + except Exception: + plan = None + if plan is None: + return {"session_id": real_id, "available": False, "plan": None} + return {"session_id": real_id, "available": True, "plan": plan} + + return router diff --git a/tests/test_operation_plan_route.py b/tests/test_operation_plan_route.py new file mode 100644 index 00000000..c7c45d59 --- /dev/null +++ b/tests/test_operation_plan_route.py @@ -0,0 +1,87 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.operation_plan import create_router + +_SAMPLE_PLAN = { + "tactics": [ + {"id": "t1", "description": "Start acquisition", "status": "done"}, + {"id": "t2", "description": "Monitor drift", "status": "active"}, + ] +} + + +def _server(plan, sessions=(("sess-1",),), context_store_available=True): + gently_store = MagicMock() + gently_store.list_sessions.return_value = [{"session_id": sid} for (sid,) in sessions] + + context_store = MagicMock() if context_store_available else None + if context_store is not None: + context_store.get_operation_plan.return_value = plan + + srv = MagicMock() + srv.gently_store = gently_store + srv.context_store = context_store + return srv, gently_store, context_store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_returns_stored_plan(): + srv, _, cs = _server(_SAMPLE_PLAN) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is True + assert body["session_id"] == "sess-1" + assert body["plan"]["tactics"][0]["id"] == "t1" + cs.get_operation_plan.assert_called_with("sess-1") + + +def test_returns_unavailable_when_no_plan(): + srv, _, cs = _server(None) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False + assert body["plan"] is None + + +def test_current_resolves_newest(): + srv, gently_store, cs = _server(_SAMPLE_PLAN, sessions=(("newest-session",),)) + r = _client(srv).get("/api/operation_plan/current") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "newest-session" + cs.get_operation_plan.assert_called_with("newest-session") + + +def test_current_no_sessions_returns_404(): + srv, gently_store, cs = _server(_SAMPLE_PLAN, sessions=()) + r = _client(srv).get("/api/operation_plan/current") + assert r.status_code == 404 + + +def test_context_store_unavailable_returns_unavailable(): + srv, _, _ = _server(_SAMPLE_PLAN, context_store_available=False) + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False + assert body["plan"] is None + + +def test_context_store_exception_returns_unavailable(): + srv, _, cs = _server(_SAMPLE_PLAN) + cs.get_operation_plan.side_effect = RuntimeError("disk error") + r = _client(srv).get("/api/operation_plan/sess-1") + assert r.status_code == 200 + body = r.json() + assert body["available"] is False From 84f5f17af21404e07118ad18438e993fe54016e7 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 17:58:41 +0530 Subject: [PATCH 211/314] =?UTF-8?q?docs(operations):=20add=20plan-item?= =?UTF-8?q?=E2=86=94operation=20linkage=20tasks=20(tactical=20outline=20+?= =?UTF-8?q?=20goal/seeding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-06-28-operations-tab.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md index 208d642d..46625492 100644 --- a/docs/superpowers/plans/2026-06-28-operations-tab.md +++ b/docs/superpowers/plans/2026-06-28-operations-tab.md @@ -63,3 +63,14 @@ ### Task 8: `OperationPlanUpdater` service (completion edges via the bus) **Files:** Create `gently/app/operation_plan_updater.py` (a `Service` modeled on `gently/app/temperature_sampler.py` / `TimelineManager`); wire in `gently/app/agent.py` beside the temperature sampler (~:838-849). Test: `tests/test_operation_plan_updater.py`. - [ ] Subscribe `BURST_COMPLETE`, `TEMP_PROTOCOL_COMPLETED`, `EMBRYO_CADENCE_CHANGED`, `TRIGGER_FIRED`; on each, resolve the tactic (by `tactic_id` in payload, else by kind+embryo) and `cs.transition_tactic(session, tactic_id, 'done', **bind)` binding live values (request_id/mp4_path/setpoint/cadence). Mirror the sampler's start/stop lifecycle + session_id getter. TDD against a fake bus + store. Commit `feat(operations): OperationPlanUpdater — execution events transition plan tactics`. + +--- +## Plan-item ↔ operation linkage tasks (added — tactics planned at plan time) + +### Task 9: PlanItem/ImagingSpec tactical outline +**Files:** Modify `gently/harness/memory/model.py` (`ImagingSpec` ~:200 / `PlanItem` ~:265 — add optional `tactics: list[dict]` outline field: each entry a lightweight tactic `{kind, name, target?, scope?, structure?}`). Ensure the plan-mode planning tools (`gently/harness/plan_mode/tools/planning.py` create_plan_item/update_plan_item) accept/persist it. Test: extend the plan-item model/store tests. +- [ ] Confirm the real ImagingSpec/PlanItem dataclass + how planning tools set the spec. Add the optional `tactics` outline (default empty), persisted in the campaign plan YAML. TDD: a plan item round-trips its tactics outline. Commit `feat(operations): plan-item tactical outline (plan tactics with the imaging spec)`. + +### Task 10: Operation Plan goal/plan_item linkage + seeding +**Files:** Modify `gently/app/tools/operation_plan_tools.py` (or a small seeding helper / the agent): resolve the current session's `plan_item_id`/`campaign_id`/goal from the `session_intent` (`get_current_session_intent` ~file_store.py:788) → set them on the Operation Plan top-level; when a session linked to a plan item with a `tactics` outline begins (or on first declare), SEED the Operation Plan's `planned` tactics from the outline. Test: `tests/test_operation_plan_seeding.py`. +- [ ] Confirm the session_intent→plan_item linkage accessors. On declare/seed, populate `plan_item_id`/`campaign_id`/`goal` from the linked plan item and seed `planned` tactics from its outline (idempotent — don't clobber tactics already active/done). TDD with a fake store: a session linked to a plan item with an outline produces a seeded Operation Plan. Commit `feat(operations): seed Operation Plan goal + planned tactics from the linked plan item`. From 66a40d132c58c3bd6a2079f4e674036ef1311174 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:02:33 +0530 Subject: [PATCH 212/314] feat(operations): transition_tactic store helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FileContextStore.transition_tactic(session_id, tactic_id, state=None, **bind) — reads the plan via get_operation_plan, finds the tactic by id, flips its state, merges bind kwargs into tactic['live'] (creating the dict if absent), stamps updated_at/updated_reason, and writes back via set_operation_plan so CONTEXT_UPDATED fires exactly once. Returns True on success, False (no-op) when plan or tactic is absent. 18 TDD tests all pass. --- gently/harness/memory/file_store.py | 35 +++++ tests/test_transition_tactic.py | 230 ++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 tests/test_transition_tactic.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 0e831c12..98763cf3 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -830,6 +830,41 @@ def get_operation_plan(self, session_id: str) -> dict | None: path = self.agent_dir / "operation_plans" / f"{session_id}.yaml" return self._read_yaml(path) + def transition_tactic( + self, session_id: str, tactic_id: str, state: str | None = None, **bind + ) -> bool: + """Atomically update a tactic's state and/or bind live values onto it. + + Reads the plan via get_operation_plan, locates the tactic by id, + sets its state (if provided), merges bind kwargs into its live dict + (creating it if absent), stamps updated_at/updated_reason, and writes + back via set_operation_plan so CONTEXT_UPDATED fires exactly once. + + Returns True on success, False if the plan is absent or the tactic id + is not found (no-op, no crash). + """ + plan = self.get_operation_plan(session_id) + if plan is None: + return False + + tactics = plan.get("tactics", []) + tactic = next((t for t in tactics if t.get("id") == tactic_id), None) + if tactic is None: + return False + + if state is not None: + tactic["state"] = state + + if bind: + live = tactic.setdefault("live", {}) + live.update(bind) + + plan["updated_at"] = self._now() + plan["updated_reason"] = f"tactic {tactic_id} transitioned" + + self.set_operation_plan(session_id, plan) + return True + # ================================================================== # Session <-> Campaign (many-to-many) # ================================================================== diff --git a/tests/test_transition_tactic.py b/tests/test_transition_tactic.py new file mode 100644 index 00000000..5b72648a --- /dev/null +++ b/tests/test_transition_tactic.py @@ -0,0 +1,230 @@ +""" +FileContextStore.transition_tactic — TDD tests. + +Verifies: state flip, live bind merge, updated_at stamp, +True/False return values, no-op on missing plan or tactic. +""" + +import pytest +from gently.core.event_bus import EventType, on + + +# --------------------------------------------------------------------------- +# Plan fixture shared across tests +# --------------------------------------------------------------------------- + +_PLAN = { + "session_id": "sess_tt", + "title": "Transition-tactic test plan", + "goal": "Verify atomic tactic transitions", + "tactics": [ + { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120}, + "live_bind": ["cadence"], + "relations": {}, + }, + { + "id": "t2", + "name": "Temp ramp monitor", + "kind": "reactive_monitor", + "state": "planned", + "scope": {"mode": "global"}, + "rationale": "Detect onset event", + "structure": {"watch": "temperature > 24.5"}, + "live_bind": ["temperature"], + "relations": {"after": ["t1"]}, + }, + ], + "updated_at": "2026-06-28T00:00:00", + "updated_reason": "initial plan", +} + + +def _fresh_plan(): + """Deep-copy the fixture so mutations don't bleed between tests.""" + import copy + return copy.deepcopy(_PLAN) + + +# --------------------------------------------------------------------------- +# Happy-path tests +# --------------------------------------------------------------------------- + + +class TestTransitionTacticHappyPath: + def test_returns_true_on_success(self, file_context_store): + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + result = cs.transition_tactic("sess_tt", "t1", state="active") + assert result is True + + def test_state_updated(self, file_context_store): + """State flips from planned to active after transition.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "active" + + def test_bind_written_to_live(self, file_context_store): + """Bind kwargs land in tactic['live'].""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["live"]["request_id"] == "b1" + + def test_state_and_bind_together(self, file_context_store): + """State flip and live bind work together in one call.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1", cadence=90) + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "active" + assert t1["live"]["request_id"] == "b1" + assert t1["live"]["cadence"] == 90 + + def test_bind_merges_into_existing_live(self, file_context_store): + """Subsequent bind calls merge, not replace, existing live values.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", request_id="b1") + cs.transition_tactic("sess_tt", "t1", mp4_path="/data/t1.mp4") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["live"]["request_id"] == "b1" + assert t1["live"]["mp4_path"] == "/data/t1.mp4" + + def test_bind_creates_live_dict_when_absent(self, file_context_store): + """live dict is created if the tactic didn't have one.""" + cs = file_context_store + plan = _fresh_plan() + # Ensure no live key exists + assert "live" not in plan["tactics"][0] + cs.set_operation_plan("sess_tt", plan) + cs.transition_tactic("sess_tt", "t1", setpoint=25.0) + t1 = cs.get_operation_plan("sess_tt")["tactics"][0] + assert t1["live"]["setpoint"] == 25.0 + + def test_state_only_no_bind(self, file_context_store): + """state-only call with no bind kwargs works and adds no live key.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="done") + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "done" + assert "live" not in t1 + + def test_bind_only_no_state_change(self, file_context_store): + """bind-only call (no state arg) preserves existing state.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", cadence=60) + plan = cs.get_operation_plan("sess_tt") + t1 = next(t for t in plan["tactics"] if t["id"] == "t1") + assert t1["state"] == "planned" # unchanged + assert t1["live"]["cadence"] == 60 + + def test_other_tactic_unaffected(self, file_context_store): + """Transitioning t1 does not alter t2.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active", request_id="b1") + plan = cs.get_operation_plan("sess_tt") + t2 = next(t for t in plan["tactics"] if t["id"] == "t2") + assert t2["state"] == "planned" + assert "live" not in t2 + + def test_updated_at_stamped(self, file_context_store): + """updated_at is refreshed after a successful transition.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + assert plan["updated_at"] != "2026-06-28T00:00:00" + + def test_updated_reason_stamped(self, file_context_store): + """updated_reason reflects which tactic was transitioned.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + cs.transition_tactic("sess_tt", "t1", state="active") + plan = cs.get_operation_plan("sess_tt") + assert "t1" in plan["updated_reason"] + + def test_fires_context_updated_event(self, file_context_store): + """transition_tactic fires CONTEXT_UPDATED (via set_operation_plan).""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.transition_tactic("sess_tt", "t1", state="active") + finally: + unsub() + assert len(seen) >= 1 + + +# --------------------------------------------------------------------------- +# No-op / guard tests +# --------------------------------------------------------------------------- + + +class TestTransitionTacticNoOp: + def test_absent_plan_returns_false(self, file_context_store): + """Returns False and does not crash when no plan exists.""" + result = file_context_store.transition_tactic("no_such_session", "t1", state="active") + assert result is False + + def test_absent_plan_no_side_effects(self, file_context_store): + """Absent-plan call creates no operation_plan file.""" + cs = file_context_store + cs.transition_tactic("ghost_session", "t1", state="active") + assert cs.get_operation_plan("ghost_session") is None + + def test_unknown_tactic_id_returns_false(self, file_context_store): + """Returns False when the tactic id is not present in the plan.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + result = cs.transition_tactic("sess_tt", "no_such_tactic", state="active") + assert result is False + + def test_unknown_tactic_plan_unchanged(self, file_context_store): + """Plan is not mutated when the tactic id is not found.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + original_updated_at = cs.get_operation_plan("sess_tt")["updated_at"] + cs.transition_tactic("sess_tt", "no_such_tactic", state="active") + plan = cs.get_operation_plan("sess_tt") + assert plan["updated_at"] == original_updated_at + + def test_no_event_on_absent_plan(self, file_context_store): + """CONTEXT_UPDATED must not fire when the plan is absent.""" + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + file_context_store.transition_tactic("ghost", "t1", state="active") + finally: + unsub() + assert len(seen) == 0 + + def test_no_event_on_unknown_tactic(self, file_context_store): + """CONTEXT_UPDATED must not fire when the tactic id is not found.""" + cs = file_context_store + cs.set_operation_plan("sess_tt", _fresh_plan()) + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + cs.transition_tactic("sess_tt", "no_such", state="active") + finally: + unsub() + assert len(seen) == 0 From 224d8651c13876b09dabf6dc9aaeb51c9195e731 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:14:38 +0530 Subject: [PATCH 213/314] feat(operations): link execution tools to plan tactics via tactic_id Add optional tactic_id param to enable_monitoring_mode, queue_burst, stop_timelapse, pause_timelapse (timelapse_tools.py) and run_temp_change_burst_protocol_tool (temperature_protocol_tools.py). On execute the tool calls cs.transition_tactic(session, tactic_id, 'active'/'done'/'paused') via the same guarded getattr pattern as declare_operation_plan. tactic_id is threaded into BurstAcquisition and the orchestration layer so BURST_START/BURST_COMPLETE and TEMP_PROTOCOL_STARTED/TEMP_PROTOCOL_COMPLETED event data carry it for Task 8's OperationPlanUpdater. All params default None; existing callers are unaffected. --- gently/app/orchestration/exclusive.py | 4 + .../app/orchestration/temperature_protocol.py | 8 +- gently/app/orchestration/timelapse.py | 2 + .../app/tools/temperature_protocol_tools.py | 10 + gently/app/tools/timelapse_tools.py | 44 +- tests/test_tool_tactic_linkage.py | 555 ++++++++++++++++++ 6 files changed, 616 insertions(+), 7 deletions(-) create mode 100644 tests/test_tool_tactic_linkage.py diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index 03917b0c..7dc3583f 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -102,6 +102,7 @@ def __init__( request_id: str | None = None, temperature_provider=None, laser_config: str | None = None, + tactic_id: str | None = None, ): super().__init__(target_embryo_id=target_embryo_id, request_id=request_id) self.frames = frames @@ -109,6 +110,7 @@ def __init__( self.num_slices = num_slices self._temperature_provider = temperature_provider self._laser_config = laser_config + self._tactic_id = tactic_id async def run(self, orchestrator) -> ExclusiveResult: from gently.core import EventType @@ -132,6 +134,7 @@ async def run(self, orchestrator) -> ExclusiveResult: "frames": self.frames, "mode": self.mode, "phase": getattr(self, "_phase", None), + "tactic_id": self._tactic_id, }, ) @@ -234,6 +237,7 @@ async def run(self, orchestrator) -> ExclusiveResult: "duration_s": duration_s, "sustained_hz": sustained_hz, "mp4_path": mp4_path, + "tactic_id": self._tactic_id, }, ) diff --git a/gently/app/orchestration/temperature_protocol.py b/gently/app/orchestration/temperature_protocol.py index 60058133..115234f2 100644 --- a/gently/app/orchestration/temperature_protocol.py +++ b/gently/app/orchestration/temperature_protocol.py @@ -21,7 +21,7 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. 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): + 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: @@ -40,7 +40,8 @@ async def one_burst(phase): 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}) + "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) @@ -65,5 +66,6 @@ async def one_burst(phase): 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}) + {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error, + "tactic_id": tactic_id}) return {"locked": locked, "cancelled": cancelled, "error": error} diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index c640f926..0645d77f 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -2062,6 +2062,7 @@ def queue_burst( num_slices: int = 1, force: bool = False, laser_config: str | None = None, + tactic_id: str | None = None, ) -> str: """Queue a burst acquisition for ``embryo_id``. @@ -2102,6 +2103,7 @@ def queue_burst( num_slices=num_slices, temperature_provider=self._temperature_provider, laser_config=laser_config, + tactic_id=tactic_id, ) self._exclusive_queue.append(op) logger.info( diff --git a/gently/app/tools/temperature_protocol_tools.py b/gently/app/tools/temperature_protocol_tools.py index 678b08b8..3e79055f 100644 --- a/gently/app/tools/temperature_protocol_tools.py +++ b/gently/app/tools/temperature_protocol_tools.py @@ -43,6 +43,7 @@ async def run_temp_change_burst_protocol_tool( frames: int = 60, bursts_before: int = 1, bursts_after: int = 1, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Launch the temp-change burst driver as a background asyncio task. @@ -83,6 +84,14 @@ async def run_temp_change_burst_protocol_tool( "(would contend for the RunEngine). Stop the timelapse first." ) + # Flip the matching plan tactic to active before launching the background driver + # (guarded no-op when tactic_id or context store are absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + asyncio.create_task( _driver( orchestrator, @@ -91,6 +100,7 @@ async def run_temp_change_burst_protocol_tool( frames=frames, bursts_before=bursts_before, bursts_after=bursts_after, + tactic_id=tactic_id, ) ) diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index d5ec3d76..9e96cbe5 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -267,7 +267,11 @@ async def stop_timelapse_embryo( category=ToolCategory.EXPERIMENT, requires_microscope=True, ) -async def stop_timelapse(reason: str = "user_request", context: dict | None = None) -> str: +async def stop_timelapse( + reason: str = "user_request", + tactic_id: str | None = None, + context: dict | None = None, +) -> str: """Stop entire timelapse""" agent, err = require_agent(context) if err: @@ -279,6 +283,12 @@ async def stop_timelapse(reason: str = "user_request", context: dict | None = No try: result = await orchestrator.stop(reason) + # Mark the linked plan tactic done (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "done") return result except Exception as e: return f"Error stopping timelapse: {str(e)}" @@ -290,7 +300,10 @@ async def stop_timelapse(reason: str = "user_request", context: dict | None = No category=ToolCategory.EXPERIMENT, requires_microscope=True, ) -async def pause_timelapse(context: dict | None = None) -> str: +async def pause_timelapse( + tactic_id: str | None = None, + context: dict | None = None, +) -> str: """Pause timelapse""" agent, err = require_agent(context) if err: @@ -302,6 +315,12 @@ async def pause_timelapse(context: dict | None = None) -> str: try: result = await orchestrator.pause() + # Mark the linked plan tactic paused (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "paused") return result except Exception as e: return f"Error pausing timelapse: {str(e)}" @@ -1024,6 +1043,7 @@ def get_photodose_status(context: dict | None = None) -> str: ) def enable_monitoring_mode( mode_name: str, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Install a named reactive monitoring mode on the orchestrator.""" @@ -1036,7 +1056,14 @@ def enable_monitoring_mode( return err try: - return orchestrator.enable_monitoring_mode(mode_name) + result = orchestrator.enable_monitoring_mode(mode_name) + # Flip the matching plan tactic to active (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + return result except Exception as e: return f"Error enabling monitoring mode '{mode_name}': {str(e)}" @@ -1153,6 +1180,7 @@ def queue_burst( mode: str = "1hz", num_slices: int = 1, force: bool = False, + tactic_id: str | None = None, context: dict | None = None, ) -> str: """Queue an exclusive burst acquisition for one embryo.""" @@ -1165,12 +1193,20 @@ def queue_burst( return err try: - return orchestrator.queue_burst( + result = orchestrator.queue_burst( embryo_id=embryo_id, frames=frames, mode=mode, num_slices=num_slices, force=force, + tactic_id=tactic_id, ) + # Flip the matching plan tactic to active on enqueue (guarded no-op when absent). + if tactic_id: + cs = getattr(agent, "context_store", None) + session = getattr(agent, "session_id", None) + if cs and session: + cs.transition_tactic(session, tactic_id, "active") + return result except Exception as e: return f"Error queueing burst for {embryo_id}: {str(e)}" diff --git a/tests/test_tool_tactic_linkage.py b/tests/test_tool_tactic_linkage.py new file mode 100644 index 00000000..be245093 --- /dev/null +++ b/tests/test_tool_tactic_linkage.py @@ -0,0 +1,555 @@ +""" +Task 7: tactic_id threading + start-edge marking in execution tools. + +Verifies: + 1. enable_monitoring_mode with tactic_id → transition_tactic("active") called. + 2. enable_monitoring_mode without tactic_id → no transition call. + 3. queue_burst (tool) with tactic_id → transition_tactic("active") called. + 4. queue_burst (tool) without tactic_id → no transition call. + 5. stop_timelapse with tactic_id → transition_tactic("done") called. + 6. pause_timelapse with tactic_id → transition_tactic("paused") called. + 7. BurstAcquisition(tactic_id=...) stores _tactic_id and includes it in BURST_START data. + 8. BurstAcquisition(tactic_id=...) includes tactic_id in BURST_COMPLETE data. + 9. run_temp_change_burst_protocol_tool with tactic_id → transition_tactic("active") called. + 10. run_temp_change_burst_protocol_tool without tactic_id → no transition call. + 11. TEMP_PROTOCOL_STARTED and TEMP_PROTOCOL_COMPLETED carry tactic_id. +""" + +import asyncio +import pytest + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Capture calls to transition_tactic.""" + + def __init__(self): + self.transitions: list[tuple] = [] + + def transition_tactic(self, session_id: str, tactic_id: str, state: str = None, **bind): + self.transitions.append((session_id, tactic_id, state)) + return True + + +class FakeOrchestrator: + """Minimal orchestrator for tool-layer tests.""" + + def __init__(self): + self.monitoring_modes_enabled: list[str] = [] + self.bursts_queued: list[dict] = [] + self.stopped = False + self.paused = False + + def enable_monitoring_mode(self, name: str, **kwargs) -> str: + self.monitoring_modes_enabled.append(name) + return f"Activated monitoring mode '{name}'" + + def queue_burst( + self, + embryo_id: str, + *, + frames: int = 60, + mode: str = "1hz", + num_slices: int = 1, + force: bool = False, + laser_config=None, + tactic_id=None, + ) -> str: + self.bursts_queued.append({"embryo_id": embryo_id, "tactic_id": tactic_id}) + return f"Burst queued for {embryo_id}" + + async def stop(self, reason: str = "user_request") -> str: + self.stopped = True + return "Timelapse stopped." + + async def pause(self) -> str: + self.paused = True + return "Timelapse paused." + + +class FakeAgent: + """Carries context_store, session_id, and timelapse_orchestrator.""" + + def __init__(self, *, cs=None, session_id="sess_t7", orchestrator=None): + self.context_store = cs + self.session_id = session_id + self.timelapse_orchestrator = orchestrator + + +class FakeMicroscope: + """Satisfies the requires_microscope client check in the tool registry.""" + pass + + +def _make_agent(with_cs=True, with_orchestrator=True): + cs = FakeContextStore() if with_cs else None + orch = FakeOrchestrator() if with_orchestrator else None + agent = FakeAgent(cs=cs, orchestrator=orch) + return agent, cs, orch + + +def _ctx(agent, with_client=False): + ctx = {"agent": agent} + if with_client: + ctx["client"] = FakeMicroscope() + return ctx + + +# --------------------------------------------------------------------------- +# enable_monitoring_mode — tool-layer tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_with_tactic_id_calls_transition(): + """enable_monitoring_mode with tactic_id flips the tactic to active.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, cs, _orch = _make_agent() + await enable_monitoring_mode( + mode_name="expression_monitoring", tactic_id="t1", context=_ctx(agent) + ) + + assert cs is not None + assert any( + t == ("sess_t7", "t1", "active") for t in cs.transitions + ), f"Expected transition ('sess_t7', 't1', 'active'), got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_without_tactic_id_no_transition(): + """enable_monitoring_mode without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, cs, _orch = _make_agent() + await enable_monitoring_mode(mode_name="expression_monitoring", context=_ctx(agent)) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_enable_monitoring_mode_no_cs_no_crash(): + """Missing context store must not crash — the transition is a guarded no-op.""" + from gently.app.tools.timelapse_tools import enable_monitoring_mode + + agent, _cs, _orch = _make_agent(with_cs=False) + result = await enable_monitoring_mode( + mode_name="expression_monitoring", tactic_id="t1", context=_ctx(agent) + ) + # No exception; result is the mode activation string + assert "expression_monitoring" in result + + +# --------------------------------------------------------------------------- +# queue_burst — tool-layer tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_queue_burst_with_tactic_id_calls_transition(): + """queue_burst (tool) with tactic_id flips the tactic to active.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, cs, _orch = _make_agent() + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert any( + t == ("sess_t7", "t2", "active") for t in cs.transitions + ), f"Expected transition ('sess_t7', 't2', 'active'), got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_queue_burst_without_tactic_id_no_transition(): + """queue_burst (tool) without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, cs, _orch = _make_agent() + await queue_burst(embryo_id="emb1", context=_ctx(agent, with_client=True)) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_queue_burst_passes_tactic_id_to_orchestrator(): + """queue_burst (tool) passes tactic_id into orchestrator.queue_burst.""" + from gently.app.tools.timelapse_tools import queue_burst + + agent, _cs, orch = _make_agent() + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert orch.bursts_queued, "orchestrator.queue_burst must have been called" + assert orch.bursts_queued[0]["tactic_id"] == "t2" + + +# --------------------------------------------------------------------------- +# stop_timelapse — mark done +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stop_timelapse_with_tactic_id_calls_done(): + """stop_timelapse with tactic_id flips the tactic to done.""" + from gently.app.tools.timelapse_tools import stop_timelapse + + agent, cs, _orch = _make_agent() + await stop_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True)) + + assert any( + t == ("sess_t7", "t1", "done") for t in cs.transitions + ), f"Expected transition to done, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_stop_timelapse_without_tactic_id_no_transition(): + """stop_timelapse without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import stop_timelapse + + agent, cs, _orch = _make_agent() + await stop_timelapse(context=_ctx(agent, with_client=True)) + + assert cs.transitions == [] + + +# --------------------------------------------------------------------------- +# pause_timelapse — mark paused +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_pause_timelapse_with_tactic_id_calls_paused(): + """pause_timelapse with tactic_id flips the tactic to paused.""" + from gently.app.tools.timelapse_tools import pause_timelapse + + agent, cs, _orch = _make_agent() + await pause_timelapse(tactic_id="t1", context=_ctx(agent, with_client=True)) + + assert any( + t == ("sess_t7", "t1", "paused") for t in cs.transitions + ), f"Expected transition to paused, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_pause_timelapse_without_tactic_id_no_transition(): + """pause_timelapse without tactic_id must not call transition_tactic.""" + from gently.app.tools.timelapse_tools import pause_timelapse + + agent, cs, _orch = _make_agent() + await pause_timelapse(context=_ctx(agent, with_client=True)) + + assert cs.transitions == [] + + +# --------------------------------------------------------------------------- +# BurstAcquisition — tactic_id stored and appears in event data (unit-level) +# --------------------------------------------------------------------------- + + +def test_burst_acquisition_stores_tactic_id(): + """BurstAcquisition stores tactic_id as _tactic_id.""" + from gently.app.orchestration.exclusive import BurstAcquisition + + b = BurstAcquisition("emb1", frames=10, tactic_id="t3") + assert b._tactic_id == "t3" + + +def test_burst_acquisition_default_tactic_id_none(): + """BurstAcquisition._tactic_id defaults to None (backward compat).""" + from gently.app.orchestration.exclusive import BurstAcquisition + + b = BurstAcquisition("emb1", frames=10) + assert b._tactic_id is None + + +def test_burst_start_event_data_contains_tactic_id(): + """The BURST_START event data dict includes tactic_id.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[dict] = [] + + class FakeOrc: + _embryo_states = {} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=5, tactic_id="t3") + orch = FakeOrc() + # Trigger just the BURST_START emission by running the coroutine start; since the + # embryo is absent, run() returns an error result after emitting nothing — + # that's the correct path. Instead, directly test what run() would emit. + # We replicate the exact emit call from BurstAcquisition.run to verify the data key. + # Safer: run the coroutine to the first yield so BURST_START fires. + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(orch)) + finally: + loop.close() + + # When embryo is absent run() returns early (no BURST_START). Confirm _tactic_id + # is wired up and would appear in the event — inspect the attribute directly. + assert b._tactic_id == "t3" + + +def test_burst_start_event_data_dict_includes_tactic_id_key(): + """The BURST_START event dict produced by BurstAcquisition.run includes 'tactic_id'.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def move_to_position(self, x, y): ... + async def acquire_burst(self, **kw): + return {"success": True, "frames": []} + + class FakeEmbryo: + calibration = {} + stage_position = {} + exposure_ms = 10.0 + laser_power_488_pct = None + + class FakeOrc: + client = FakeClient() + _embryo_states = {"emb1": FakeEmbryo()} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=2, tactic_id="t3") + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(FakeOrc())) + finally: + loop.close() + + burst_starts = [(et, d) for et, d in emitted if et == EventType.BURST_START] + assert burst_starts, f"BURST_START not emitted; all events: {[et for et,_ in emitted]}" + _et, data = burst_starts[0] + assert "tactic_id" in data, f"tactic_id not in BURST_START data: {data}" + assert data["tactic_id"] == "t3" + + +def test_burst_complete_event_data_includes_tactic_id(): + """The BURST_COMPLETE event dict produced by BurstAcquisition.run includes 'tactic_id'.""" + from gently.app.orchestration.exclusive import BurstAcquisition + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def move_to_position(self, x, y): ... + async def acquire_burst(self, **kw): + return {"success": True, "frames": []} + + class FakeEmbryo: + calibration = {} + stage_position = {} + exposure_ms = 10.0 + laser_power_488_pct = None + + class FakeOrc: + client = FakeClient() + _embryo_states = {"emb1": FakeEmbryo()} + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + b = BurstAcquisition("emb1", frames=2, tactic_id="t3") + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(b.run(FakeOrc())) + finally: + loop.close() + + burst_completes = [(et, d) for et, d in emitted if et == EventType.BURST_COMPLETE] + assert burst_completes, f"BURST_COMPLETE not emitted; all events: {[et for et,_ in emitted]}" + _et, data = burst_completes[0] + assert "tactic_id" in data, f"tactic_id not in BURST_COMPLETE data: {data}" + assert data["tactic_id"] == "t3" + + +# --------------------------------------------------------------------------- +# temperature_protocol_tools — tactic transitions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_temp_protocol_tool_with_tactic_id_calls_transition(monkeypatch): + """run_temp_change_burst_protocol_tool with tactic_id marks tactic active.""" + from gently.app.tools import temperature_protocol_tools as tpt + + # Patch asyncio.create_task to a no-op + monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close() or None) + + class FakeOrch: + _status = None + + class FakeClient: + pass + + cs = FakeContextStore() + agent = FakeAgent(cs=cs, orchestrator=FakeOrch()) + agent.timelapse_orchestrator = FakeOrch() + + ctx = { + "agent": agent, + "client": FakeClient(), + } + + await tpt.run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + tactic_id="t4", + context=ctx, + ) + + assert any( + t == ("sess_t7", "t4", "active") for t in cs.transitions + ), f"Expected active transition, got {cs.transitions}" + + +@pytest.mark.asyncio +async def test_temp_protocol_tool_without_tactic_id_no_transition(monkeypatch): + """run_temp_change_burst_protocol_tool without tactic_id must not call transition.""" + from gently.app.tools import temperature_protocol_tools as tpt + + monkeypatch.setattr(asyncio, "create_task", lambda coro: coro.close() or None) + + class FakeOrch: + _status = None + + class FakeClient: + pass + + cs = FakeContextStore() + agent = FakeAgent(cs=cs, orchestrator=FakeOrch()) + agent.timelapse_orchestrator = FakeOrch() + + ctx = { + "agent": agent, + "client": FakeClient(), + } + + await tpt.run_temp_change_burst_protocol_tool( + embryo_id="emb1", + target_setpoint_c=25.0, + context=ctx, + ) + + assert cs.transitions == [], f"Expected no transitions, got {cs.transitions}" + + +# --------------------------------------------------------------------------- +# temperature_protocol orchestration — tactic_id in event payloads +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_temp_protocol_started_event_carries_tactic_id(): + """TEMP_PROTOCOL_STARTED event data includes tactic_id.""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): pass + async def set_led(self, s): pass + async def set_temperature(self, t): pass + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + bursts_run: list[str] = [] + + async def fake_burst_runner(b): + bursts_run.append(b._phase) + + await run_temp_change_burst_protocol( + FakeOrc(), "emb1", 25.0, + bursts_before=0, bursts_after=0, + burst_runner=fake_burst_runner, + tactic_id="t5", + ) + + started = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_STARTED] + assert started, "TEMP_PROTOCOL_STARTED not emitted" + assert started[0][1].get("tactic_id") == "t5" + + +@pytest.mark.asyncio +async def test_temp_protocol_completed_event_carries_tactic_id(): + """TEMP_PROTOCOL_COMPLETED event data includes tactic_id.""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): pass + async def set_led(self, s): pass + async def set_temperature(self, t): pass + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + await run_temp_change_burst_protocol( + FakeOrc(), "emb1", 25.0, + bursts_before=0, bursts_after=0, + burst_runner=lambda b: asyncio.sleep(0), + tactic_id="t5", + ) + + completed = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_COMPLETED] + assert completed, "TEMP_PROTOCOL_COMPLETED not emitted" + assert completed[0][1].get("tactic_id") == "t5" + + +@pytest.mark.asyncio +async def test_temp_protocol_tactic_id_none_when_absent(): + """TEMP_PROTOCOL_* events include tactic_id=None when not supplied (backward compat).""" + from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol + from gently.core import EventType + + emitted: list[tuple] = [] + + class FakeClient: + async def set_laser_config(self, cfg): pass + async def set_led(self, s): pass + async def set_temperature(self, t): pass + async def get_temperature(self): + return {"state": "LOCKED"} + + class FakeOrc: + client = FakeClient() + + def _emit_event(self, event_type, data): + emitted.append((event_type, data)) + + await run_temp_change_burst_protocol( + FakeOrc(), "emb1", 25.0, + bursts_before=0, bursts_after=0, + burst_runner=lambda b: asyncio.sleep(0), + ) + + started = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_STARTED] + completed = [(et, d) for et, d in emitted if et == EventType.TEMP_PROTOCOL_COMPLETED] + assert started and "tactic_id" in started[0][1] + assert started[0][1]["tactic_id"] is None + assert completed and "tactic_id" in completed[0][1] + assert completed[0][1]["tactic_id"] is None From c445f6ca7a190af2c5e2c40b16dd915da2687922 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:14:42 +0530 Subject: [PATCH 214/314] =?UTF-8?q?docs(sdd):=20Task=207=20report=20?= =?UTF-8?q?=E2=80=94=20tactic=5Fid=20threading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .superpowers/sdd/dv3-task-7-report.md | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .superpowers/sdd/dv3-task-7-report.md diff --git a/.superpowers/sdd/dv3-task-7-report.md b/.superpowers/sdd/dv3-task-7-report.md new file mode 100644 index 00000000..033112fe --- /dev/null +++ b/.superpowers/sdd/dv3-task-7-report.md @@ -0,0 +1,85 @@ +# Task 7 Report: tactic_id threading + start-edge marking in execution tools + +## Call sites changed + +### `gently/app/orchestration/exclusive.py` + +**`BurstAcquisition.__init__`** (line ~95) +- Added `tactic_id: str | None = None` kwarg; stored as `self._tactic_id`. +- `BURST_START` event data (line ~127): added `"tactic_id": self._tactic_id`. +- `BURST_COMPLETE` event data (line ~229): added `"tactic_id": self._tactic_id`. + +### `gently/app/orchestration/temperature_protocol.py` + +**`run_temp_change_burst_protocol`** signature (line 22) +- Added `tactic_id=None` to the keyword-only params. +- `TEMP_PROTOCOL_STARTED` event data (line ~41): added `"tactic_id": tactic_id`. +- `TEMP_PROTOCOL_COMPLETED` event data (line ~67): added `"tactic_id": tactic_id`. + +### `gently/app/orchestration/timelapse.py` + +**`queue_burst`** method (line ~2056) +- Added `tactic_id: str | None = None` to signature. +- Passes `tactic_id=tactic_id` into `BurstAcquisition(...)` constructor (line ~2098). + +### `gently/app/tools/timelapse_tools.py` + +**`enable_monitoring_mode`** (line ~1025) +- Added `tactic_id: str | None = None`. +- On success: resolves `cs = getattr(agent, "context_store", None)`, `session = getattr(agent, "session_id", None)`; calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). + +**`queue_burst`** tool (line ~1150) +- Added `tactic_id: str | None = None`. +- Passes `tactic_id=tactic_id` to `orchestrator.queue_burst(...)`. +- After successful enqueue: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). + +**`stop_timelapse`** (line ~270) +- Added `tactic_id: str | None = None`. +- After successful stop: calls `cs.transition_tactic(session, tactic_id, "done")` (guarded). + +**`pause_timelapse`** (line ~293) +- Added `tactic_id: str | None = None`. +- After successful pause: calls `cs.transition_tactic(session, tactic_id, "paused")` (guarded). + +### `gently/app/tools/temperature_protocol_tools.py` + +**`run_temp_change_burst_protocol_tool`** (line 40) +- Added `tactic_id: str | None = None`. +- Before `asyncio.create_task`: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). +- Passes `tactic_id=tactic_id` into `_driver(...)` (the `run_temp_change_burst_protocol` coroutine). + +## How the transition + event threading work + +1. **Start edge (tool → store)**: Tool layer resolves the context store via `getattr(agent, "context_store", None)` and session via `getattr(agent, "session_id", None)` — the same pattern as `declare_operation_plan`. If both are present and `tactic_id` is truthy, `transition_tactic` flips the plan tactic to `"active"`. This happens synchronously (before the background driver spawns for the temp protocol, after enqueue for bursts). + +2. **Event threading (orchestration → bus)**: The `tactic_id` is included in `BURST_START`, `BURST_COMPLETE`, `TEMP_PROTOCOL_STARTED`, and `TEMP_PROTOCOL_COMPLETED` event `data` dicts. Task 8's `OperationPlanUpdater` subscribes to these events and can resolve the tactic by `data["tactic_id"]` to flip it to `"done"`. + +3. **Backward compatibility**: All `tactic_id` params default to `None`. Every transition call is guarded: `if tactic_id: cs = ...; if cs and session: cs.transition_tactic(...)`. When `tactic_id` is absent, no store access occurs and the tool behaves identically to before. + +## TDD evidence + +`tests/test_tool_tactic_linkage.py` — 20 tests, all pass. + +Coverage: +- `enable_monitoring_mode` with/without tactic_id (+ no-cs guard) +- `queue_burst` (tool) with/without tactic_id; tactic_id forwarded to orchestrator +- `stop_timelapse` with/without tactic_id +- `pause_timelapse` with/without tactic_id +- `BurstAcquisition._tactic_id` stored; `BURST_START` and `BURST_COMPLETE` events contain `tactic_id` +- `run_temp_change_burst_protocol_tool` with/without tactic_id +- `TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` carry tactic_id (including `None` backward-compat) + +Full suite (excluding 3 pre-existing broken collection targets): **638 passed, 19 skipped** — same count as before Task 7; no new failures. + +## Files changed + +- `gently/app/orchestration/exclusive.py` +- `gently/app/orchestration/temperature_protocol.py` +- `gently/app/orchestration/timelapse.py` +- `gently/app/tools/timelapse_tools.py` +- `gently/app/tools/temperature_protocol_tools.py` +- `tests/test_tool_tactic_linkage.py` (new) + +## Concerns + +None. All changes are purely additive (new optional kwarg, guarded call). The `@tool` decorator wraps sync functions as async coroutines — tests call them with keyword args and `await`; microscope-required tools need a `"client"` in the test context dict (FakeMicroscope provided). From 442265f761cbcaa9ac9d14dd302e486a43a444e8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:25:06 +0530 Subject: [PATCH 215/314] =?UTF-8?q?feat(operations):=20OperationPlanUpdate?= =?UTF-8?q?r=20=E2=80=94=20execution=20events=20transition=20plan=20tactic?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bus-subscriber Service that listens for BURST_COMPLETE, TEMP_PROTOCOL_COMPLETED, EMBRYO_CADENCE_CHANGED, and TRIGGER_FIRED; reads tactic_id from each payload and calls context_store.transition_tactic to advance state (done) or bind live values. Wired into agent.py beside the temperature sampler. 8/8 TDD tests pass. --- gently/app/agent.py | 21 ++ gently/app/operation_plan_updater.py | 141 +++++++++++++ tests/test_operation_plan_updater.py | 286 +++++++++++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 gently/app/operation_plan_updater.py create mode 100644 tests/test_operation_plan_updater.py diff --git a/gently/app/agent.py b/gently/app/agent.py index 02ec700a..b02b227c 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -212,6 +212,8 @@ def __init__( self.device_state_monitor = None # Session-scoped temperature sampler — polls device layer, persists readings. self.temperature_sampler = None + # Bus-subscriber that transitions plan tactics when execution events fire. + self.operation_plan_updater = None # 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 @@ -848,6 +850,19 @@ async def start_viz_server( logger.warning(f"Failed to start temperature sampler: {e}") self.temperature_sampler = None + if self.operation_plan_updater is None and self.context_store is not None: + try: + from .operation_plan_updater import OperationPlanUpdater + + self.operation_plan_updater = OperationPlanUpdater( + self.context_store, lambda: self.session_id + ) + await self.operation_plan_updater.start() + logger.info("Operation-plan updater started") + except Exception as e: + logger.warning(f"Failed to start operation-plan updater: {e}") + self.operation_plan_updater = None + # Construct the bottom-camera monitor — but don't start it. Streaming # is opt-in and waits for an explicit operator action via the # /api/devices/bottom_camera/stream/start route. @@ -897,6 +912,12 @@ async def stop_viz_server(self): except Exception: logger.exception("Failed to stop temperature sampler") self.temperature_sampler = None + if self.operation_plan_updater is not None: + try: + await self.operation_plan_updater.stop() + except Exception: + logger.exception("Failed to stop operation-plan updater") + self.operation_plan_updater = None if self.viz_server is not None: await self.viz_server.stop() self.viz_server = None diff --git a/gently/app/operation_plan_updater.py b/gently/app/operation_plan_updater.py new file mode 100644 index 00000000..d2e2b9e4 --- /dev/null +++ b/gently/app/operation_plan_updater.py @@ -0,0 +1,141 @@ +"""Operation-plan updater — subscribes to execution lifecycle events and +transitions plan tactics to their completed state. + +Modeled on `gently/app/temperature_sampler.py` (Service lifecycle) and +`gently/harness/session/timeline.py` TimelineManager.start (bus-subscribe +pattern). + +When a tactic's execution completes — burst captured, temperature protocol +finished, trigger fired — this service reads `tactic_id` from the event +payload and calls `context_store.transition_tactic(session_id, tactic_id, +state, **bind)` to stamp live values and advance the tactic state. Missing +`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 gently.core.service import Service +from gently.core.event_bus import get_event_bus, EventType + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Event → (state, bind-keys) mapping +# --------------------------------------------------------------------------- +# state=None means bind-only (no state transition). +_EVENT_ACTIONS: dict[EventType, tuple[str | None, list[str]]] = { + EventType.BURST_COMPLETE: ( + "done", + ["request_id", "mp4_path", "sustained_hz", "frames_captured"], + ), + EventType.TEMP_PROTOCOL_COMPLETED: ( + "done", + ["locked", "cancelled", "error"], + ), + EventType.EMBRYO_CADENCE_CHANGED: ( + None, # bind-only; standing tactic stays active + ["embryo_id", "old_phase", "new_phase", "old_interval_s", "new_interval_s", "next_due_at"], + ), + EventType.TRIGGER_FIRED: ( + None, # bind-only; record last firing time + ["embryo_id", "rule_name", "rule_kind", "trigger_detector", "trigger_stage"], + ), +} + + +class OperationPlanUpdater(Service): + """Subscribe to execution events; transition plan tactics accordingly.""" + + def __init__(self, context_store, session_id_getter: Callable[[], str | None]): + super().__init__(name="operation-plan-updater", service_type="monitor") + self._context_store = context_store + self._session_id_getter = session_id_getter + self._unsubscribers: list[Callable[[], None]] = [] + + # ------------------------------------------------------------------ + # Service lifecycle + # ------------------------------------------------------------------ + + async def on_start(self) -> None: + bus = get_event_bus() + for event_type in _EVENT_ACTIONS: + unsub = bus.subscribe(event_type, self._on_event) + self._unsubscribers.append(unsub) + logger.info( + "OperationPlanUpdater started — subscribed to %d event types", + len(_EVENT_ACTIONS), + ) + + async def on_stop(self) -> None: + for unsub in self._unsubscribers: + try: + unsub() + except Exception as exc: + logger.warning("OperationPlanUpdater unsubscribe error: %s", exc) + self._unsubscribers.clear() + logger.info("OperationPlanUpdater stopped") + + # ------------------------------------------------------------------ + # Event handler + # ------------------------------------------------------------------ + + def _on_event(self, event) -> None: + """Handle an execution lifecycle event — guard everything; never raise.""" + try: + self._handle(event) + except Exception as exc: + logger.warning( + "OperationPlanUpdater: unhandled error in _on_event (%s): %s", + event.event_type, + exc, + ) + + def _handle(self, event) -> None: + data = event.data or {} + tactic_id = data.get("tactic_id") + session_id = self._session_id_getter() + + action = _EVENT_ACTIONS.get(event.event_type) + if action is None: + return # unknown event type — ignore + + state, bind_keys = action + + # For bind-only events with no tactic_id there is nothing to attach to. + if not tactic_id: + logger.debug( + "OperationPlanUpdater: no tactic_id in %s payload — skip", + event.event_type, + ) + return + + if not session_id: + logger.debug( + "OperationPlanUpdater: no active session for %s — skip", + event.event_type, + ) + return + + bind = {k: data[k] for k in bind_keys if k in data} + + # Add a last_fired marker for TRIGGER_FIRED so operators can see it. + if event.event_type is EventType.TRIGGER_FIRED: + bind["last_fired"] = event.timestamp + + ok = self._context_store.transition_tactic(session_id, tactic_id, state, **bind) + if ok: + logger.info( + "OperationPlanUpdater: tactic %s → %s (event=%s)", + tactic_id, + state or "bind-only", + event.event_type, + ) + else: + logger.debug( + "OperationPlanUpdater: transition_tactic no-op for tactic=%s session=%s", + tactic_id, + session_id, + ) diff --git a/tests/test_operation_plan_updater.py b/tests/test_operation_plan_updater.py new file mode 100644 index 00000000..0fe50d7e --- /dev/null +++ b/tests/test_operation_plan_updater.py @@ -0,0 +1,286 @@ +"""TDD: OperationPlanUpdater — execution events transition plan tactics. + +Covers: +- BURST_COMPLETE with tactic_id → state=done, bind values recorded +- TEMP_PROTOCOL_COMPLETED with tactic_id → state=done, bind values recorded +- EMBRYO_CADENCE_CHANGED with tactic_id → bind-only (no state change) +- TRIGGER_FIRED with tactic_id → bind-only + last_fired timestamp +- Event without tactic_id → skip (no transition_tactic call) +- Handler exception doesn't propagate to the caller +- on_stop unsubscribes (no further calls after stop) +""" +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from gently.core.event_bus import EventBus, EventType +from gently.app.operation_plan_updater import OperationPlanUpdater + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class FakeContextStore: + """Records all transition_tactic calls.""" + + def __init__(self): + self.calls: list[dict] = [] + + def transition_tactic( + self, session_id: str, tactic_id: str, state: str | None = None, **bind + ) -> bool: + self.calls.append( + {"session_id": session_id, "tactic_id": tactic_id, "state": state, **bind} + ) + return True + + +def make_event(event_type: EventType, data: dict): + """Build a minimal Event-like object that _on_event expects.""" + evt = MagicMock() + evt.event_type = event_type + evt.data = data + evt.timestamp = "2026-06-28T00:00:00+00:00" + return evt + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def bus(): + """A fresh EventBus for each test (avoids cross-test leakage).""" + return EventBus() + + +@pytest.fixture +def store(): + return FakeContextStore() + + +@pytest.fixture +def session_id(): + return "sess_abc" + + +@pytest.fixture +def updater(store, session_id): + return OperationPlanUpdater(store, lambda: session_id) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_burst_complete_transitions_tactic_to_done(updater, store, bus, monkeypatch): + """BURST_COMPLETE fires → tactic transitions to done with bind values.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={ + "tactic_id": "t1", + "embryo_id": "e1", + "request_id": "req-001", + "mp4_path": "/data/burst.mp4", + "sustained_hz": 2.5, + "frames_captured": 50, + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t1" + assert call["state"] == "done" + assert call["request_id"] == "req-001" + assert call["mp4_path"] == "/data/burst.mp4" + assert call["sustained_hz"] == 2.5 + assert call["frames_captured"] == 50 + + await updater.stop() + + +@pytest.mark.asyncio +async def test_temp_protocol_completed_transitions_to_done(updater, store, bus, monkeypatch): + """TEMP_PROTOCOL_COMPLETED fires → tactic done with locked/cancelled/error.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.TEMP_PROTOCOL_COMPLETED, + data={ + "tactic_id": "t2", + "embryo_id": "e1", + "locked": True, + "cancelled": False, + "error": None, + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t2" + assert call["state"] == "done" + assert call["locked"] is True + assert call["cancelled"] is False + + await updater.stop() + + +@pytest.mark.asyncio +async def test_embryo_cadence_changed_bind_only(updater, store, bus, monkeypatch): + """EMBRYO_CADENCE_CHANGED → bind-only (state=None), cadence values bound.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.EMBRYO_CADENCE_CHANGED, + data={ + "tactic_id": "t3", + "embryo_id": "e1", + "old_phase": "normal", + "new_phase": "dense", + "old_interval_s": 120, + "new_interval_s": 30, + "next_due_at": "2026-06-28T01:00:00+00:00", + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t3" + assert call["state"] is None # bind-only + assert call["new_phase"] == "dense" + assert call["new_interval_s"] == 30 + + await updater.stop() + + +@pytest.mark.asyncio +async def test_trigger_fired_bind_only_with_last_fired(updater, store, bus, monkeypatch): + """TRIGGER_FIRED → bind-only with last_fired timestamp injected.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.TRIGGER_FIRED, + data={ + "tactic_id": "t4", + "embryo_id": "e1", + "rule_name": "dense_on_signal", + "rule_kind": "interval", + }, + source="test", + ) + + assert len(store.calls) == 1 + call = store.calls[0] + assert call["tactic_id"] == "t4" + assert call["state"] is None + assert "last_fired" in call # timestamp injected by updater + + await updater.stop() + + +@pytest.mark.asyncio +async def test_event_without_tactic_id_is_skipped(updater, store, bus, monkeypatch): + """An event with no tactic_id must not call transition_tactic.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + await updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={ + "embryo_id": "e1", + "request_id": "req-999", + # deliberately no tactic_id + }, + source="test", + ) + + assert store.calls == [] + + await updater.stop() + + +@pytest.mark.asyncio +async def test_handler_exception_does_not_propagate(updater, bus, monkeypatch): + """An exception inside _handle must be swallowed — bus caller not affected.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + + class BrokenStore: + def transition_tactic(self, *a, **kw): + raise RuntimeError("storage exploded") + + broken_updater = OperationPlanUpdater(BrokenStore(), lambda: "sess_xyz") + await broken_updater.start() + + # Should not raise even though the store raises. + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t1", "request_id": "r1"}, + source="test", + ) + + await broken_updater.stop() + + +@pytest.mark.asyncio +async def test_no_session_skips_transition(store, bus, monkeypatch): + """If session_id_getter returns None, no transition_tactic call is made.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + no_session_updater = OperationPlanUpdater(store, lambda: None) + await no_session_updater.start() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t1", "request_id": "r1"}, + source="test", + ) + + assert store.calls == [] + + await no_session_updater.stop() + + +@pytest.mark.asyncio +async def test_stop_unsubscribes_from_bus(store, bus, monkeypatch): + """After stop(), further bus events produce no transition_tactic calls.""" + monkeypatch.setattr( + "gently.app.operation_plan_updater.get_event_bus", lambda: bus + ) + up = OperationPlanUpdater(store, lambda: "sess_z") + await up.start() + await up.stop() + + bus.publish( + event_type=EventType.BURST_COMPLETE, + data={"tactic_id": "t9", "request_id": "r9"}, + source="test", + ) + + assert store.calls == [] From 152837e340af14d7c0efd8a536177ead0b8d36c8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:33:16 +0530 Subject: [PATCH 216/314] feat(operations): plan-item tactical outline (plan tactics with the imaging spec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `tactics: list[dict]` to ImagingSpec — each entry is a lightweight tactic descriptor {kind, name, target?, scope?, structure?} planned alongside the imaging spec and later seeding the session's Operation Plan. Persists through both the YAML FileContextStore and SQLite ContextStore paths with zero store/tool changes. --- gently/harness/memory/model.py | 8 ++ tests/test_plan_item_tactics.py | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/test_plan_item_tactics.py diff --git a/gently/harness/memory/model.py b/gently/harness/memory/model.py index e9e6532d..55bff965 100644 --- a/gently/harness/memory/model.py +++ b/gently/harness/memory/model.py @@ -245,6 +245,14 @@ class ImagingSpec: # Lets the UI tag each value with where it came from and what to confirm. provenance: dict[str, dict[str, str]] = field(default_factory=dict) + # Tactical outline — intended Operations for this imaging session. + # Each entry is a lightweight tactic descriptor: + # {kind, name, target?, scope?, structure?} + # kind ∈ standing_timelapse | reactive_monitor | scripted_protocol | + # exclusive_burst | oneshot | custom + # Populated at plan time; later seeds the session's Operation Plan. + tactics: list[dict] = field(default_factory=list) + @dataclass class BenchSpec: diff --git a/tests/test_plan_item_tactics.py b/tests/test_plan_item_tactics.py new file mode 100644 index 00000000..b1cd91da --- /dev/null +++ b/tests/test_plan_item_tactics.py @@ -0,0 +1,137 @@ +""" +Task 9 — ImagingSpec tactical outline. + +Verifies that `tactics` round-trips through the FileContextStore YAML path +and through the SQLite-backed ContextStore path. +""" + +import dataclasses + +import pytest + +from gently.harness.memory.model import ImagingSpec, PlanItem, PlanItemType + +TACTICS_OUTLINE = [ + { + "kind": "standing_timelapse", + "name": "baseline timelapse", + "scope": "all embryos", + }, + { + "kind": "reactive_monitor", + "name": "comma-stage speedup", + "target": "comma", + "structure": {"interval_s": 60}, + }, +] + + +# --------------------------------------------------------------------------- +# 1. Model-level: field exists and defaults to empty list +# --------------------------------------------------------------------------- + + +class TestImagingSpecField: + def test_tactics_field_exists(self): + spec = ImagingSpec() + assert hasattr(spec, "tactics") + + def test_tactics_default_empty_list(self): + spec = ImagingSpec() + assert spec.tactics == [] + + def test_tactics_accepts_outline(self): + spec = ImagingSpec(strain="N2", tactics=TACTICS_OUTLINE) + assert spec.tactics == TACTICS_OUTLINE + + def test_tactics_field_in_dataclass_fields(self): + field_names = {f.name for f in dataclasses.fields(ImagingSpec)} + assert "tactics" in field_names + + +# --------------------------------------------------------------------------- +# 2. FileContextStore (YAML) round-trip +# --------------------------------------------------------------------------- + + +class TestFileStoreTactics: + def test_tactics_survive_create_and_get(self, file_context_store): + """Tactics stored in spec persist through create_plan_item → get_plan_item.""" + cid = file_context_store.create_campaign(description="Tactics test") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="Tactic-bearing item", + spec={ + "strain": "OH904", + "interval_s": 180, + "tactics": TACTICS_OUTLINE, + }, + ) + item = file_context_store.get_plan_item(iid) + assert item is not None + assert item.imaging_spec is not None + assert item.imaging_spec.tactics == TACTICS_OUTLINE + + def test_tactics_survive_update(self, file_context_store): + """Tactics set via update_plan_item also persist.""" + cid = file_context_store.create_campaign(description="Update tactics") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="No tactics yet", + spec={"strain": "N2"}, + ) + # Initially empty + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == [] + + # Add tactics via update + new_spec = {"strain": "N2", "tactics": TACTICS_OUTLINE[:1]} + file_context_store.update_plan_item(iid, spec=new_spec) + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == TACTICS_OUTLINE[:1] + + def test_existing_items_without_tactics_default_empty(self, file_context_store): + """Items stored without a tactics key default to [] on read-back.""" + cid = file_context_store.create_campaign(description="Legacy item") + iid = file_context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="Legacy (no tactics key)", + spec={"strain": "N2", "num_slices": 80}, + ) + # Manually strip tactics from the persisted YAML to simulate a pre-tactics record + loc = file_context_store._find_plan_item_location(iid) + campaign_id_found, items, idx = loc + raw = items[idx] + if isinstance(raw.get("spec"), dict): + raw["spec"].pop("tactics", None) + file_context_store._write_plan_items(campaign_id_found, items) + + item = file_context_store.get_plan_item(iid) + assert item.imaging_spec.tactics == [] + + +# --------------------------------------------------------------------------- +# 3. SQLite ContextStore round-trip +# --------------------------------------------------------------------------- + + +class TestContextStoreTactics: + def test_tactics_survive_sqlite_round_trip(self, context_store): + """Tactics stored in spec dict survive through the SQLite ContextStore.""" + cid = context_store.create_campaign(description="SQLite tactics test") + iid = context_store.create_plan_item( + campaign_id=cid, + type="imaging", + title="SQLite tactic item", + spec={ + "strain": "OH904", + "tactics": TACTICS_OUTLINE, + }, + ) + item = context_store.get_plan_item(iid) + assert item is not None + assert item.imaging_spec is not None + assert item.imaging_spec.tactics == TACTICS_OUTLINE From 16a85a39e92a73f9f44a4d708d91dc164726f514 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:41:17 +0530 Subject: [PATCH 217/314] feat(operations): seed Operation Plan goal + planned tactics from the linked plan item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds seed_operation_plan_from_plan_item(context_store, session_id) in gently/app/tools/operation_plan_seed.py. Resolution path: session_id → get_campaign_ids_for_session → get_plan_items per campaign → find PlanItem where session_id ∈ plan_item.session_ids AND imaging_spec.tactics is non-empty → get_campaign for goal text. Idempotent: existing plan with active/done tactics is never clobbered; plans with only planned tactics get missing outline entries merged in. 19 new tests in tests/test_operation_plan_seeding.py (all passing). Hook point: agent.py ~455-463 after attach_session_to_plan (not yet wired). --- gently/app/tools/operation_plan_seed.py | 182 +++++++++++ tests/test_operation_plan_seeding.py | 410 ++++++++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 gently/app/tools/operation_plan_seed.py create mode 100644 tests/test_operation_plan_seeding.py diff --git a/gently/app/tools/operation_plan_seed.py b/gently/app/tools/operation_plan_seed.py new file mode 100644 index 00000000..3878e704 --- /dev/null +++ b/gently/app/tools/operation_plan_seed.py @@ -0,0 +1,182 @@ +""" +Seed an Operation Plan from the plan item linked to a session. + +Called at session start (or lazily on first declare/render) to pre-populate +the plan's goal + planned tactics from the ImagingSpec.tactics outline of +the linked plan item. + +Hook point +---------- +gently/app/agent.py ~455-463, after ``attach_session_to_plan`` has linked +the session to its campaign. At that point call:: + + from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item + seed_operation_plan_from_plan_item(agent.context_store, agent.session_id) + +Resolution path (multi-hop) +---------------------------- +1. ``context_store.get_campaign_ids_for_session(session_id)`` + → list of campaign IDs from the session_intent YAML +2. For each campaign_id → ``context_store.get_plan_items(campaign_id=cid)`` + → PlanItem list +3. Find the first PlanItem where ``session_id`` is in ``plan_item.session_ids`` + (or ``plan_item.session_id == session_id``) AND + ``plan_item.imaging_spec.tactics`` is non-empty. +4. ``context_store.get_campaign(plan_item.campaign_id)`` for the goal text. +""" + +import logging +import uuid +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _resolve_plan_item_with_tactics(context_store, session_id: str): + """Locate the first plan item linked to *session_id* that has a tactics outline. + + Returns ``(plan_item, campaign)`` or ``(None, None)`` if nothing matches. + """ + campaign_ids = context_store.get_campaign_ids_for_session(session_id) + if not campaign_ids: + return None, None + + for cid in campaign_ids: + 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 [] + ) + 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 + if not item.imaging_spec or not item.imaging_spec.tactics: + continue + campaign = context_store.get_campaign(cid) + return item, campaign + + return None, None + + +def _outline_entry_to_tactic(entry: dict, idx: int) -> dict: + """Convert a lightweight ImagingSpec.tactics outline entry to a full planned tactic.""" + kind = entry.get("kind") or "custom" + name = entry.get("name") or f"Tactic {idx + 1}" + tactic_id = f"seed_{uuid.uuid4().hex[:8]}" + return { + "id": tactic_id, + "name": name, + "kind": kind, + "state": "planned", + "target": entry.get("target"), + "scope": entry.get("scope"), + "structure": entry.get("structure"), + "rationale": None, + "live_bind": [], + "relations": {}, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def seed_operation_plan_from_plan_item(context_store, session_id: str) -> dict | None: + """Seed the Operation Plan for *session_id* from its linked plan item's outline. + + Idempotency rules + ----------------- + * **No existing plan** — seed fully; write to store; return plan. + * **Plan exists with ≥1 active or done tactic** — do NOT overwrite; return ``None``. + * **Plan exists with only planned tactics** — add any outline entries whose + *name* is not already present; if nothing new, return ``None``; otherwise + write the merged plan and return it. + + Parameters + ---------- + context_store: + A ``FileContextStore`` instance. + session_id: + The active session ID. + + Returns + ------- + dict | None + The seeded (or merged) plan dict if something was written, else ``None``. + """ + item, campaign = _resolve_plan_item_with_tactics(context_store, session_id) + if item is None: + return None + + outline: list[dict] = item.imaging_spec.tactics # type: ignore[union-attr] + if not outline: + return None + + # --- Idempotency: check existing plan --- + existing = context_store.get_operation_plan(session_id) + if existing: + existing_tactics: list[dict] = existing.get("tactics") or [] + # Guard: at least one active/done tactic — do not clobber + has_live = any(t.get("state") in ("active", "done") for t in existing_tactics) + if has_live: + logger.debug( + "seed_operation_plan: session %s already has live tactics — skipping", + session_id, + ) + return None + + # Plan exists with only planned tactics — add missing outline entries by name + existing_names = {t.get("name") for t in existing_tactics} + new_tactics = list(existing_tactics) + added = False + for i, entry in enumerate(outline): + if entry.get("name") not in existing_names: + new_tactics.append(_outline_entry_to_tactic(entry, i)) + added = True + if not added: + return None + + plan = dict(existing) + plan["tactics"] = new_tactics + plan["updated_at"] = datetime.now(timezone.utc).isoformat() + plan["updated_reason"] = "seeded: added planned tactics from plan-item outline" + context_store.set_operation_plan(session_id, plan) + logger.info( + "seed_operation_plan: added %d planned tactic(s) to existing plan for session %s", + sum(1 for t in new_tactics if t not in existing_tactics), + session_id, + ) + return plan + + # --- No existing plan: seed from scratch --- + goal = "" + if campaign: + goal = campaign.target or campaign.description or "" + + tactics = [_outline_entry_to_tactic(entry, i) for i, entry in enumerate(outline)] + + plan = { + "session_id": session_id, + "title": item.title, + "goal": goal, + "plan_item_id": item.id, + "campaign_id": item.campaign_id, + "tactics": tactics, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_reason": "seeded from plan-item tactical outline", + } + context_store.set_operation_plan(session_id, plan) + logger.info( + "seed_operation_plan: seeded %d planned tactic(s) for session %s from plan item %s", + len(tactics), + session_id, + item.id, + ) + return plan diff --git a/tests/test_operation_plan_seeding.py b/tests/test_operation_plan_seeding.py new file mode 100644 index 00000000..abe36911 --- /dev/null +++ b/tests/test_operation_plan_seeding.py @@ -0,0 +1,410 @@ +""" +TDD: seed_operation_plan_from_plan_item + +Resolution path under test: + session_id + → get_campaign_ids_for_session (reads session_intents/{session_id}.yaml) + → get_plan_items(campaign_id=cid) + → find PlanItem where session_id ∈ plan_item.session_ids + AND plan_item.imaging_spec.tactics is non-empty + → get_campaign(plan_item.campaign_id) for goal text + → build + write Operation Plan via set_operation_plan + +Scenarios +--------- +1. Two outline tactics → seeded plan with 2 ``planned`` tactics +2. Plan-level fields (goal, plan_item_id, campaign_id) populated from item/campaign +3. Idempotent — existing plan with active tactic: not clobbered, returns None +4. Idempotent — existing plan with done tactic: not clobbered, returns None +5. No campaign linkage → returns None, no write +6. Plan item linked but imaging_spec.tactics is empty → returns None, no write +""" + +import pytest + +from gently.app.tools.operation_plan_seed import seed_operation_plan_from_plan_item + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_campaign_and_item_with_tactics( + cs, + session_id: str, + *, + target: str = "50 hatching events", +): + """Create a campaign + imaging plan item with 2 tactics, linked to *session_id*.""" + cid = cs.create_campaign( + description="Capture hatching events in WT", + shorthand="hatching-wt", + target=target, + ) + spec = { + "strain": "N2", + "num_embryos": 4, + "tactics": [ + {"kind": "standing_timelapse", "name": "Baseline timelapse"}, + { + "kind": "reactive_monitor", + "name": "Hatching monitor", + "target": "embryo_hatching", + "scope": {"mode": "global"}, + }, + ], + } + item_id = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="WT hatching pilot", + spec=spec, + ) + cs.link_session_campaign(session_id, cid) + cs.link_plan_item_session(item_id, session_id) + return cid, item_id + + +# --------------------------------------------------------------------------- +# Basic seeding +# --------------------------------------------------------------------------- + + +class TestSeedBasic: + def test_returns_plan_dict(self, file_context_store): + """seed returns a non-None dict when a linked plan item with tactics exists.""" + cs = file_context_store + sid = "seed_basic_01" + cs.create_session_intent(sid, planned_intent="hatching pilot") + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan is not None + assert isinstance(plan, dict) + + def test_two_outline_entries_produce_two_tactics(self, file_context_store): + """An outline with 2 entries produces exactly 2 tactics.""" + cs = file_context_store + sid = "seed_basic_02" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert len(plan["tactics"]) == 2 + + def test_all_seeded_tactics_are_planned(self, file_context_store): + """Every seeded tactic starts in state='planned'.""" + cs = file_context_store + sid = "seed_basic_03" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert all(t["state"] == "planned" for t in plan["tactics"]) + + def test_tactic_kind_and_name_from_outline(self, file_context_store): + """Tactics carry the kind and name from the outline entries.""" + cs = file_context_store + sid = "seed_basic_04" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + names = [t["name"] for t in plan["tactics"]] + kinds = [t["kind"] for t in plan["tactics"]] + assert "Baseline timelapse" in names + assert "Hatching monitor" in names + assert "standing_timelapse" in kinds + assert "reactive_monitor" in kinds + + def test_tactic_ids_generated_and_distinct(self, file_context_store): + """Each tactic has a non-empty unique id.""" + cs = file_context_store + sid = "seed_basic_05" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + ids = [t["id"] for t in plan["tactics"]] + assert all(ids) + assert len(set(ids)) == len(ids) + + def test_plan_persisted_in_store(self, file_context_store): + """The seeded plan is retrievable via get_operation_plan.""" + cs = file_context_store + sid = "seed_basic_06" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + seed_operation_plan_from_plan_item(cs, sid) + + stored = cs.get_operation_plan(sid) + assert stored is not None + assert len(stored["tactics"]) == 2 + + +# --------------------------------------------------------------------------- +# Plan-level metadata +# --------------------------------------------------------------------------- + + +class TestSeedMetadata: + def test_goal_from_campaign_target(self, file_context_store): + """goal field is taken from campaign.target.""" + cs = file_context_store + sid = "seed_meta_01" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid, target="50 hatching events") + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["goal"] == "50 hatching events" + + def test_goal_falls_back_to_campaign_description(self, file_context_store): + """When campaign.target is None, goal falls back to campaign.description.""" + cs = file_context_store + sid = "seed_meta_02" + cs.create_session_intent(sid) + cid = cs.create_campaign(description="Characterise WT hatching") + spec = {"tactics": [{"kind": "standing_timelapse", "name": "Timelapse"}]} + item_id = cs.create_plan_item(campaign_id=cid, type="imaging", title="Pilot", spec=spec) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["goal"] == "Characterise WT hatching" + + def test_plan_item_id_set(self, file_context_store): + """plan_item_id on the plan matches the linked plan item's id.""" + cs = file_context_store + sid = "seed_meta_03" + cs.create_session_intent(sid) + cid, item_id = _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["plan_item_id"] == item_id + + def test_campaign_id_set(self, file_context_store): + """campaign_id on the plan matches the campaign.""" + cs = file_context_store + sid = "seed_meta_04" + cs.create_session_intent(sid) + cid, _ = _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["campaign_id"] == cid + + def test_title_from_plan_item(self, file_context_store): + """Plan title comes from the plan item's title.""" + cs = file_context_store + sid = "seed_meta_05" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + plan = seed_operation_plan_from_plan_item(cs, sid) + + assert plan["title"] == "WT hatching pilot" + + +# --------------------------------------------------------------------------- +# Idempotency +# --------------------------------------------------------------------------- + + +class TestIdempotency: + def test_active_tactic_not_clobbered(self, file_context_store): + """If the plan has an active tactic, seed returns None and doesn't overwrite.""" + cs = file_context_store + sid = "seed_idem_01" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + existing = { + "session_id": sid, + "title": "Agent-declared plan", + "goal": "live goal", + "tactics": [ + { + "id": "t_live", + "name": "Live tactic", + "kind": "standing_timelapse", + "state": "active", + } + ], + "updated_at": "2026-06-28T10:00:00Z", + "updated_reason": "agent declared", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + stored = cs.get_operation_plan(sid) + assert stored["title"] == "Agent-declared plan" + assert stored["goal"] == "live goal" + + def test_done_tactic_not_clobbered(self, file_context_store): + """If the plan has a done tactic, seed returns None and doesn't overwrite.""" + cs = file_context_store + sid = "seed_idem_02" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + existing = { + "session_id": sid, + "title": "Completed plan", + "goal": "done goal", + "tactics": [ + { + "id": "t_done", + "name": "Done tactic", + "kind": "oneshot", + "state": "done", + } + ], + "updated_at": "2026-06-28T10:00:00Z", + "updated_reason": "agent declared", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + stored = cs.get_operation_plan(sid) + assert stored["title"] == "Completed plan" + + def test_all_planned_existing_plan_adds_missing_tactics(self, file_context_store): + """If existing plan has only planned tactics, new outline entries are added.""" + cs = file_context_store + sid = "seed_idem_03" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + # Pre-seed with only the first tactic (by name) + existing = { + "session_id": sid, + "title": "Partial plan", + "goal": "partial goal", + "tactics": [ + { + "id": "t_existing", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "planned", + } + ], + "updated_at": "2026-06-28T09:00:00Z", + "updated_reason": "partial seed", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + # The second outline entry ("Hatching monitor") should have been added + assert result is not None + assert len(result["tactics"]) == 2 + names = [t["name"] for t in result["tactics"]] + assert "Hatching monitor" in names + + def test_all_tactics_already_present_returns_none(self, file_context_store): + """If all outline tactics are already in the plan (by name), returns None.""" + cs = file_context_store + sid = "seed_idem_04" + cs.create_session_intent(sid) + _make_campaign_and_item_with_tactics(cs, sid) + + # Pre-seed with both outline names already present + existing = { + "session_id": sid, + "title": "Full plan", + "goal": "full goal", + "tactics": [ + {"id": "t1", "name": "Baseline timelapse", "kind": "standing_timelapse", "state": "planned"}, + {"id": "t2", "name": "Hatching monitor", "kind": "reactive_monitor", "state": "planned"}, + ], + "updated_at": "2026-06-28T09:00:00Z", + "updated_reason": "already seeded", + } + cs.set_operation_plan(sid, existing) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + # Plan unchanged + stored = cs.get_operation_plan(sid) + assert len(stored["tactics"]) == 2 + + +# --------------------------------------------------------------------------- +# No linkage +# --------------------------------------------------------------------------- + + +class TestNoLinkage: + def test_no_campaign_linkage_returns_none(self, file_context_store): + """Session with no campaign linkage → None, nothing written.""" + cs = file_context_store + sid = "seed_nolink_01" + cs.create_session_intent(sid, planned_intent="standalone") + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + assert cs.get_operation_plan(sid) is None + + def test_no_session_intent_returns_none(self, file_context_store): + """Session with no session_intent YAML → None.""" + cs = file_context_store + + result = seed_operation_plan_from_plan_item(cs, "ghost_session") + + assert result is None + + def test_plan_item_without_tactics_returns_none(self, file_context_store): + """Plan item linked to session but imaging_spec.tactics is empty → None.""" + cs = file_context_store + sid = "seed_notactics_01" + cs.create_session_intent(sid) + + cid = cs.create_campaign(description="Campaign — no tactics in spec") + item_id = cs.create_plan_item( + campaign_id=cid, + type="imaging", + title="Item without tactics", + spec={"strain": "N2"}, # no "tactics" key → empty list on ImagingSpec + ) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None + assert cs.get_operation_plan(sid) is None + + def test_non_imaging_plan_item_returns_none(self, file_context_store): + """Non-imaging plan item (no imaging_spec) → None.""" + cs = file_context_store + sid = "seed_bench_01" + cs.create_session_intent(sid) + + cid = cs.create_campaign(description="Bench work campaign") + item_id = cs.create_plan_item( + campaign_id=cid, + type="bench", + title="Prep samples", + ) + cs.link_session_campaign(sid, cid) + cs.link_plan_item_session(item_id, sid) + + result = seed_operation_plan_from_plan_item(cs, sid) + + assert result is None From 5d16f34fe97e0293b919c566e21c0f559724d4ee Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 18:58:07 +0530 Subject: [PATCH 218/314] feat(operations): operation-spine renderer + plan scenario library (data-driven) Replace the swimlane Overview with a typed Operation Plan spine renderer. The agent declares tactics (kind/state/structure/live_bind); the renderer draws done/active/planned nodes with per-kind structure (scripted_protocol phase stepper, standing_timelapse per-embryo cadence strip, reactive_monitor armed/watching/fired badge, exclusive_burst/oneshot compact). Audit fixes baked in: colored left edge per state, active card flattened (no card-in-card, values on panel face + hairline rule), active phase is the headline (larger/bold amber), queued = "next" badge + cocked blue dot, copy "queued" not "going to be used", mono instrument values. Adds window.OPERATIONS_SCENARIOS (7 plan fixtures: temp_strain, expression_onset, hatching_detect, transmission_survey, decided_plan, async_multi, idle). Scenario dev mode: ?scenario= renders a fixture with no fetch. Loads scenarios.js before experiment-overview.js in index.html. Rules view and monitoring-mode logic untouched. --- gently/ui/web/static/css/experiment.css | 440 ++++++++++++++++++ .../ui/web/static/js/experiment-overview.js | 258 +++++++++- .../ui/web/static/js/operations-scenarios.js | 371 +++++++++++++++ gently/ui/web/templates/index.html | 1 + 4 files changed, 1059 insertions(+), 11 deletions(-) create mode 100644 gently/ui/web/static/js/operations-scenarios.js diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 9b343378..16f6ba73 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -634,3 +634,443 @@ transform: rotate(45deg); flex-shrink: 0; } + +/* ============================================================ + Operation Spine — data-driven tactic plan renderer + All classes namespaced ops- to avoid collision. + ============================================================ */ + +:root { + --ops-done: #34d399; + --ops-active: #f5a623; + --ops-plan: #5aa9e6; + --ops-live: #22d3ee; + --ops-mono: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.ops-wrap { + max-width: 760px; + margin: 0 auto; + padding: 8px 4px 48px; +} + +.ops-crumb { + font-family: var(--ops-mono); + font-size: 11px; + letter-spacing: 0.16em; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 6px; +} + +.ops-title { + font-size: 22px; + font-weight: 600; + margin: 0 0 4px; + letter-spacing: -0.01em; + color: var(--text); +} + +.ops-meta { + font-family: var(--ops-mono); + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.ops-legend { + display: flex; + gap: 16px; + font-family: var(--ops-mono); + font-size: 10.5px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 14px 0 20px; +} + +.ops-legend i { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; + margin-right: 6px; + vertical-align: 1px; +} + +.ops-empty { + font-family: var(--ops-mono); + color: var(--text-muted); + font-size: 13px; + border: 1px dashed var(--border); + border-radius: 12px; + padding: 30px; + text-align: center; + margin-top: 18px; +} + +/* ---- Spine ------------------------------------------------- */ + +.ops-spine { + position: relative; + margin-left: 14px; + padding-left: 30px; + border-left: 2px solid var(--border); +} + +/* Tactic node */ +.ops-node { + position: relative; + margin-bottom: 14px; +} + +/* Timeline dot — overlaps the spine border-left */ +.ops-node::before { + content: ""; + position: absolute; + left: -39px; + top: 4px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--bg-card, #161b22); + border: 2px solid var(--border); +} + +.ops-node.done::before { + border-color: var(--ops-done); + background: var(--ops-done); +} + +.ops-node.active::before { + border-color: var(--ops-active); + background: var(--ops-active); + box-shadow: 0 0 0 5px rgba(245, 166, 35, 0.14); +} + +/* AUDIT: queued = cocked instrument — dashed blue dot (not empty) */ +.ops-node.planned::before { + border-color: var(--ops-plan); + border-style: dashed; + background: var(--bg-card, #161b22); +} + +/* ---- Stage label: "01 · in use" ----------------------------- */ + +.ops-stagelab { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +/* AUDIT: active row's WHOLE LEFT COLUMN amber (dot + seq + "IN USE") */ +.ops-node.active .ops-stagelab { color: var(--ops-active); } +.ops-node.planned .ops-stagelab { color: var(--ops-plan); } + +/* AUDIT: "next" badge on first queued tactic — cocked-instrument marker */ +.ops-next-badge { + display: inline-block; + padding: 1px 7px; + border-radius: 9px; + background: rgba(90, 169, 230, 0.15); + border: 1px solid rgba(90, 169, 230, 0.45); + color: var(--ops-plan); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* ---- Card --------------------------------------------------- */ + +.ops-card { + background: var(--bg-card, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 12px; + padding: 13px 16px; +} + +/* AUDIT: colored LEFT EDGE per state */ +/* done — green left edge, transparent bg */ +.ops-node.done .ops-card { + background: transparent; + border-color: rgba(48, 54, 61, 0.5); + padding: 9px 16px; + border-left: 3px solid var(--ops-done); +} + +/* active — amber left edge + tinted gradient + FLATTENED (no card-in-card) */ +.ops-node.active .ops-card { + border-color: rgba(245, 166, 35, 0.3); + border-left: 3px solid var(--ops-active); + background: linear-gradient(180deg, rgba(245, 166, 35, 0.05), transparent), + var(--bg-card, #161b22); +} + +/* planned — blue left edge, dashed border */ +.ops-node.planned .ops-card { + background: transparent; + border-style: dashed; + border-left: 3px solid var(--ops-plan); + border-left-style: solid; + opacity: 0.85; +} + +/* Hairline rule between card header and live readouts (flattened layout) */ +.ops-rule { + border: none; + border-top: 1px solid rgba(245, 166, 35, 0.18); + margin: 10px 0; +} + +/* ---- Card contents ----------------------------------------- */ + +.ops-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.ops-tname { + font-size: 14.5px; + font-weight: 600; + color: var(--text); +} + +.ops-node.done .ops-tname { + font-weight: 500; + color: var(--text-muted); +} + +.ops-target { + font-family: var(--ops-mono); + font-size: 12.5px; + color: var(--ops-active); + font-weight: 600; +} + +.ops-tsum { + font-family: var(--ops-mono); + font-size: 11.5px; + color: var(--text-muted); + margin-left: auto; +} + +.ops-desc { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + opacity: 0.75; + margin: 4px 0 0; + line-height: 1.4; +} + +/* ---- Gauge strip — AUDIT: flat on panel face, no card-in-card */ + +.ops-live-strip { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.ops-gauge { + flex: 1; + min-width: 140px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 9px; + padding: 9px 11px; +} + +.ops-gl { + font-family: var(--ops-mono); + font-size: 9.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); +} + +/* AUDIT: mono instrument values */ +.ops-gv { + font-family: var(--ops-mono); + font-size: 18px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: var(--text); + margin-top: 2px; +} + +.ops-set { color: var(--ops-active); font-size: 13px; } +.ops-u { color: var(--text-muted); font-size: 12px; } + +.ops-gsub { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + margin-top: 6px; +} + +.ops-tempbar { + height: 5px; + border-radius: 3px; + background: var(--bg-hover, #21262d); + margin-top: 8px; + overflow: hidden; +} + +.ops-tempbar > i { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--ops-live), var(--ops-active)); +} + +/* ---- Scripted protocol — phase stepper --------------------- */ + +.ops-phases { + display: flex; + gap: 7px; + flex-wrap: wrap; + margin-top: 8px; +} + +.ops-ph { + flex: 1; + min-width: 110px; + border: 1px solid var(--border, #30363d); + border-radius: 8px; + padding: 9px 10px; + background: var(--bg-hover, #21262d); +} + +.ops-ph.done { border-color: rgba(52, 211, 153, 0.35); background: rgba(52, 211, 153, 0.05); } +.ops-ph.active { border-color: rgba(245, 166, 35, 0.45); background: rgba(245, 166, 35, 0.08); } +.ops-ph.todo { opacity: 0.6; } + +.ops-pht { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + display: flex; + gap: 6px; + align-items: center; + color: var(--text-muted); +} + +/* AUDIT: ACTIVE phase is the HEADLINE — largest in its phase */ +.ops-ph.active .ops-pht { + font-size: 12px; + font-weight: 700; + color: var(--ops-active); +} + +.ops-pi { + width: 15px; + height: 15px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 9px; + font-weight: 700; + flex-shrink: 0; +} + +.ops-ph.done .ops-pi { background: var(--ops-done); color: #062b1d; } +.ops-ph.active .ops-pi { background: var(--ops-active); color: #3a2607; } +.ops-ph.todo .ops-pi { background: var(--bg-hover, #21262d); color: var(--text-muted); border: 1px solid var(--border); } + +.ops-phc { + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +/* AUDIT: active phase count also headline-sized */ +.ops-ph.active .ops-phc { font-size: 13px; font-weight: 600; color: var(--ops-active); } + +.ops-pips { display: flex; gap: 3px; margin-top: 7px; flex-wrap: wrap; } + +.ops-pip { width: 12px; height: 7px; border-radius: 2px; background: var(--bg-hover, #21262d); } +.ops-pip.before { background: var(--ops-plan); } +.ops-pip.during { background: var(--ops-active); } +.ops-pip.after { background: var(--ops-done); } +.ops-pip.pending { border: 1px dashed var(--border); background: transparent; } + +/* ---- Per-embryo cadence strip — standing_timelapse --------- */ + +.ops-cadence-strip { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px 10px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 8px; +} + +.ops-cadence-embryo { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--ops-mono); + font-size: 11.5px; +} + +.ops-cadence-id { + color: var(--text); + font-weight: 600; + min-width: 32px; +} + +.ops-cadence-phase { + padding: 2px 8px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.ops-cadence-phase.normal { background: rgba(139, 148, 158, 0.2); color: var(--text-muted); } +.ops-cadence-phase.fast { background: rgba(251, 146, 60, 0.2); color: var(--accent-orange, #fb923c); } +.ops-cadence-phase.burst { background: rgba(239, 68, 68, 0.2); color: #f87171; } +.ops-cadence-phase.paused { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); } + +.ops-cadence-val { color: var(--text-muted); margin-left: auto; } + +/* ---- Reactive monitor — armed/watching/fired badge --------- */ + +.ops-monitor-status { + display: inline-block; + margin-top: 8px; + padding: 3px 10px; + border-radius: 9px; + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.ops-monitor-armed { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); border: 1px solid rgba(90, 169, 230, 0.35); } +.ops-monitor-watching { background: rgba(245, 166, 35, 0.12); color: var(--ops-active); border: 1px solid rgba(245, 166, 35, 0.3); } +.ops-monitor-fired { background: rgba(52, 211, 153, 0.12); color: var(--ops-done); border: 1px solid rgba(52, 211, 153, 0.3); } + +/* Planned kind-detail snippets (cadence note, watch hint) */ +.ops-cadence-note, +.ops-monitor-watch { + display: inline-block; + margin-top: 6px; + font-family: var(--ops-mono); + font-size: 11px; + color: var(--text-muted); + opacity: 0.75; +} diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 9e7fc221..c46eba9c 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -12,13 +12,38 @@ const ExperimentOverview = { initialized: false, expandedMode: null, activeView: 'overview', // 'overview' | 'rules' - activeStrategy: null, // last fetched/loaded snapshot - isLive: false, // true when activeStrategy came from the API + activeStrategy: null, // last fetched strategy snapshot (rules view) + activePlan: null, // last fetched/loaded operation plan (overview spine) + isLive: false, // true when data came from the API + scenarioMode: false, // true when ?scenario= is active async init() { console.log('[ExperimentOverview] init() called, view=', this.activeView); - const strategy = await this.loadStrategy(); + + // Scenario dev mode: ?scenario= renders a fixture with no fetch. + // Guard against double-registration when the tab is clicked repeatedly. + const scenarioParam = new URLSearchParams(location.search).get('scenario'); + if (scenarioParam && window.OPERATIONS_SCENARIOS && + Object.prototype.hasOwnProperty.call(window.OPERATIONS_SCENARIOS, scenarioParam)) { + this.scenarioMode = true; + this.activePlan = window.OPERATIONS_SCENARIOS[scenarioParam]; + this.activeStrategy = null; + this.isLive = false; + this.render(null); + this.initialized = true; + return; + } + + this.scenarioMode = false; + // Fetch plan (overview) and strategy (rules) in parallel so tab-switching + // between the two views doesn't require a second round-trip. + const [plan, strategy] = await Promise.all([ + this.loadPlan(), + this.loadStrategy() + ]); + this.activePlan = plan; this.activeStrategy = strategy; + this.isLive = plan !== null || strategy !== null; this.render(strategy); this.initialized = true; }, @@ -32,15 +57,30 @@ const ExperimentOverview = { // No active experiment / not ready yet — show the empty state, // never stubbed data. console.warn('[ExperimentOverview] strategy fetch returned', resp.status); - this.isLive = false; return null; } const data = await resp.json(); - this.isLive = true; return data; } catch (e) { console.warn('[ExperimentOverview] strategy fetch error:', e); - this.isLive = false; + return null; + } + }, + + // Fetch the agent-authored Operation Plan for the current session. + // Returns the plan object (plan.tactics etc.) or null when unavailable. + async loadPlan() { + try { + const resp = await fetch('/api/operation_plan/current', { cache: 'no-store' }); + if (!resp.ok) { + console.warn('[ExperimentOverview] plan fetch returned', resp.status); + return null; + } + const data = await resp.json(); + if (!data.available) return null; + return data.plan || null; + } catch (e) { + console.warn('[ExperimentOverview] plan fetch error:', e); return null; } }, @@ -65,10 +105,12 @@ const ExperimentOverview = { } // Tear down any prior ticker before we blow away the SVG it pointed at. this._stopNowTicker(); - if (!s) { - // No active experiment — a calm empty state, never stubbed data. + // Rules view requires the strategy snapshot; show an empty state when absent. + // Overview view uses this.activePlan — the null/empty case is handled inside + // _renderOperationSpine (it renders the idle state). + if (this.activeView === 'rules' && !s) { root.innerHTML = '
' + - 'No active experiment — the imaging tactics (cadence, reactive rules) will appear here once a run is live.
'; + 'No active experiment — rules and monitoring modes will appear here once a run is live.

'; return; } try { @@ -76,8 +118,9 @@ const ExperimentOverview = { if (this.activeView === 'rules') { this._renderRulesView(root, s); } else { - this._renderOverviewView(root, s); - this._startNowTicker(); + // Operation spine — data-driven tactic plan renderer. + // The swimlane view is retired; this renders this.activePlan. + this._renderOperationSpine(root, this.activePlan); } console.log('[ExperimentOverview] rendered OK, view=', this.activeView); } catch (err) { @@ -1085,6 +1128,199 @@ const ExperimentOverview = { return g; }, + // ================================================================= + // Operation Spine — data-driven plan renderer (replaces swimlanes) + // ================================================================= + + // Minimal HTML escaper — values in readouts may contain trusted HTML + // (e.g. 32.0°C) so they are rendered with + // innerHTML; all other user/model strings go through _opsESC. + _opsESC(s) { + return String(s == null ? '' : s) + .replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + }, + + // Entry point: render the operation spine into `root`. + // plan = the plan object (tactics array) or null for idle/unavailable. + _renderOperationSpine(root, plan) { + const ESC = this._opsESC.bind(this); + + if (!plan || !Array.isArray(plan.tactics) || plan.tactics.length === 0) { + root.innerHTML = ` +
+
Operations
+

No operation running

+
When the agent begins operating by tactic, the plan and live progress appear here.
+
Idle — no tactics planned or in use.
+
`; + return; + } + + const tactics = plan.tactics; + const hasActive = tactics.some(t => t.state === 'active'); + // Index of the first queued (planned) tactic — gets the "next" badge. + const firstPlannedIdx = tactics.findIndex(t => t.state === 'planned'); + + const spineNodes = tactics + .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC)) + .join(''); + + root.innerHTML = ` +
+
Operations · ${hasActive ? 'live' : 'idle'}
+

${ESC(plan.title || '')}

+
${ESC(plan.session_id || '')}${plan.goal ? ' · ' + ESC(plan.goal) : ''}
+
+ done + in use + queued +
+
${spineNodes}
+
`; + }, + + // Render a single tactic node. + _renderOpsTactic(t, idx, firstPlannedIdx, ESC) { + const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued' }; + const seq = String(t.seq || idx + 1).padStart(2, '0'); + const stateLabel = STATE_LABEL[t.state] || t.state; + // First queued tactic gets a "next" badge — COCKED instrument marker. + const isFirstQueued = t.state === 'planned' && idx === firstPlannedIdx; + const nextBadge = isFirstQueued + ? 'next' + : ''; + + const live = t.live || {}; + const target = live.target || ''; + const summary = live.summary || ''; + const desc = live.desc || ''; + + // Header row: name · target · summary + let inner = ` +
+ ${ESC(t.name)} + ${target ? `${ESC(target)}` : ''} + ${summary ? `${ESC(summary)}` : ''} +
+ ${desc ? `
${ESC(desc)}
` : ''}`; + + // AUDIT: FLATTEN the active card — readouts on the panel face, separated by + // a hairline rule. No nested card-in-card boxes. + if (t.state === 'active' && live.readouts && live.readouts.length) { + inner += `
+
+ ${live.readouts.map(r => this._renderOpsReadout(r, ESC)).join('')} +
`; + } + + // Kind-specific structure for the active state. + if (t.state === 'active') { + inner += this._renderOpsKindActive(t, live, ESC); + } else if (t.state === 'planned') { + inner += this._renderOpsKindPlanned(t, ESC); + } + + return ` +
+
${seq} · ${stateLabel}${nextBadge ? ' ' + nextBadge : ''}
+
${inner}
+
`; + }, + + // Render a readout gauge. `r.value` may contain trusted HTML (span markup). + _renderOpsReadout(r, ESC) { + return `
+
${ESC(r.label)}
+
${r.value}
+ ${r.bar != null + ? `
` + : ''} + ${r.sub ? `
${ESC(r.sub)}
` : ''} +
`; + }, + + // Render one phase in the scripted_protocol stepper. + // AUDIT: the active phase is the HEADLINE — CSS makes it larger. + _renderOpsPhase(p, ESC) { + const pips = (p.pips || []) + .map(k => ``) + .join(''); + const ic = p.state === 'done' ? '✓' + : p.state === 'active' ? '▶' + : (p.icon || '·'); + return `
+
${ic}${ESC(p.name)}
+
${ESC(p.count || '')}
+ ${pips ? `
${pips}
` : ''} +
`; + }, + + // Kind-specific structure for ACTIVE tactics. + _renderOpsKindActive(t, live, ESC) { + if (!t.kind) return ''; + + // scripted_protocol → before/during/after phase stepper. + // Prefer live.phases (may carry pip/count state); fall back to structure.phases. + if (t.kind === 'scripted_protocol') { + const phases = live.phases || (t.structure && t.structure.phases) || []; + if (!phases.length) return ''; + return `
+ ${phases.map(p => this._renderOpsPhase(p, ESC)).join('')} +
`; + } + + // standing_timelapse → compact per-embryo cadence strip. + if (t.kind === 'standing_timelapse') { + const perEmbryo = t.structure && t.structure.per_embryo; + if (!perEmbryo || !perEmbryo.length) return ''; + const rows = perEmbryo.map(e => { + const intervalStr = e.interval_s != null ? `${ESC(e.interval_s)}s` : '—'; + return `
+ ${ESC(e.embryo_id)} + ${ESC(e.cadence_phase)} + ${intervalStr} +
`; + }).join(''); + return `
${rows}
`; + } + + // reactive_monitor → armed/watching/fired status badge. + if (t.kind === 'reactive_monitor') { + const st = (t.structure && t.structure.status) || 'armed'; + return `
${ESC(st)}
`; + } + + // exclusive_burst / oneshot / custom — readouts only (already rendered above). + return ''; + }, + + // Kind-specific structure for PLANNED (queued) tactics — compact hints. + _renderOpsKindPlanned(t, ESC) { + if (!t.kind) return ''; + + if (t.kind === 'scripted_protocol') { + const phases = (t.structure && t.structure.phases) || []; + if (!phases.length) return ''; + return `
+ ${phases.map(p => this._renderOpsPhase(p, ESC)).join('')} +
`; + } + + if (t.kind === 'standing_timelapse' && t.structure && t.structure.cadence_s) { + return `
cadence · ${ESC(t.structure.cadence_s)}s
`; + } + + if (t.kind === 'reactive_monitor' && t.structure && t.structure.watch) { + return `
watch · ${ESC(t.structure.watch)}
`; + } + + if ((t.kind === 'oneshot' || t.kind === 'custom') && t.structure && t.structure.note) { + return `
${ESC(t.structure.note)}
`; + } + + return ''; + }, + // ----------------------------------------------------------------- // Rules table — full subtab view, grouped by rule kind // ----------------------------------------------------------------- diff --git a/gently/ui/web/static/js/operations-scenarios.js b/gently/ui/web/static/js/operations-scenarios.js new file mode 100644 index 00000000..9a4f74e7 --- /dev/null +++ b/gently/ui/web/static/js/operations-scenarios.js @@ -0,0 +1,371 @@ +/** + * Operation Plan scenario fixtures — development and Chrome-MCP audit targets. + * + * Each entry is a plan object matching the real API schema returned by + * GET /api/operation_plan/{session_id} → { available, plan } + * The `.plan` is what gets passed to the renderer. Active tactics carry a + * `live` field (readouts + phases) that the API route merges from live + * telemetry; here they are baked into the fixture. + * + * Scenario dev mode: load via ?scenario= — ExperimentOverview reads + * window.OPERATIONS_SCENARIOS[name] and skips all fetches. + * + * Scenarios: + * temp_strain — scripted_protocol active (temp-change burst protocol) + * expression_onset — reactive_monitor active (reporter rising) + * hatching_detect — reactive_monitor active (watch=hatching, status=watching) + * transmission_survey — exclusive_burst active (brightfield only) + * decided_plan — all planned, nothing run yet + * async_multi — standing_timelapse per-embryo cadence + layered reactive_monitor + * idle — null (no operation running) + */ +window.OPERATIONS_SCENARIOS = { + + /* ------------------------------------------------------------------ */ + temp_strain: { + session_id: '20260628_1432_tempstrain_a', + title: 'Temperature-strain run · E01', + goal: 'Acquire volumes before, during, and after a +4 °C step to 32 °C; capture reporter response to heat stress.', + tactics: [ + { + id: 'ts-1', seq: 1, + name: 'Monitor — low cadence', + kind: 'standing_timelapse', state: 'done', + scope: { mode: 'global' }, + rationale: 'Baseline acquisition before thermal perturbation.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: '22 min · ended on signal' }, + relations: {} + }, + { + id: 'ts-2', seq: 2, + name: 'Transmission burst — baseline', + kind: 'exclusive_burst', state: 'done', + scope: { mode: 'global' }, + rationale: 'Brightfield snapshot before setpoint change.', + structure: { frames: 3, mode: 'brightfield' }, + live_bind: [], + live: { summary: '3 bursts · brightfield' }, + relations: {} + }, + { + id: 'ts-3', seq: 3, + name: 'Temp-change burst protocol', + kind: 'scripted_protocol', state: 'active', + scope: { mode: 'global' }, + rationale: 'Systematic volume capture before, during ramp, and after thermal lock. Laser off during ramp to limit phototoxicity.', + structure: { + phases: [ + { name: 'before', state: 'done', count: '1/1 done' }, + { name: 'during', state: 'active', count: '2 · awaiting lock' }, + { name: 'after', state: 'todo', count: '0/1' } + ] + }, + live_bind: ['temperature', 'current_burst'], + live: { + target: '→ 32.0 °C', + summary: 'started 3m ago', + desc: 'bursts before · setpoint change · bursts through ramp · bursts after lock — laser off', + readouts: [ + { + label: 'stage temp', + value: '29.432.0°C', + bar: 62 + }, + { + label: 'current burst', + value: '#3 during', + sub: '60f · 1Hz · brightfield' + } + ], + phases: [ + { name: 'before', state: 'done', count: '1/1 done', pips: ['before'] }, + { name: 'during', state: 'active', count: '2 · awaiting lock', pips: ['during', 'during', 'pending'] }, + { name: 'after', state: 'todo', count: '0/1', pips: ['pending'] } + ] + }, + relations: {} + }, + { + id: 'ts-4', seq: 4, + name: 'Recovery monitor — low cadence', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Resume gentle monitoring once temperature settles.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { + summary: 'queued · 30 min after lock', + desc: 'resume gentle monitoring once temperature settles' + }, + relations: { after: ['ts-3'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + expression_onset: { + session_id: '20260628_0915_onset_b', + title: 'Reporter-onset watch · E04', + goal: 'Detect first appearance of the fluorescent reporter; capture onset dynamics at high temporal resolution.', + tactics: [ + { + id: 'eo-1', seq: 1, + name: 'Monitor — low cadence', + kind: 'standing_timelapse', state: 'done', + scope: { mode: 'global' }, + rationale: 'Baseline acquisition before signal appears.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: '1h 40m · baseline' }, + relations: {} + }, + { + id: 'eo-2', seq: 2, + name: 'Expression monitoring', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'global' }, + rationale: 'Accelerate cadence on signal, ramp 488 down on saturation, burst on stable structure.', + structure: { watch: 'reporter onset', reaction: 'accelerate + ramp laser', status: 'watching' }, + live_bind: ['signal', 'cadence'], + live: { + target: 'reporter onset', + summary: 'signal rising', + desc: 'accelerate cadence on signal · ramp 488 down on saturation · burst on stable structure', + readouts: [ + { + label: 'reporter signal', + value: 'rising', + sub: '+14% over 6 min', + bar: 48 + }, + { + label: 'cadence', + value: '120s 30s', + sub: 'accelerated on onset' + }, + { + label: '488 power', + value: '5% 3%', + sub: 'ramped to limit saturation' + } + ] + }, + relations: {} + }, + { + id: 'eo-3', seq: 3, + name: 'Burst on good structure', + kind: 'exclusive_burst', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Capture a burst once the reporter pattern holds.', + structure: { frames: 60, mode: 'fluorescence' }, + live_bind: [], + live: { + summary: 'queued · when structure stable', + desc: 'capture a burst once the reporter pattern holds' + }, + relations: { after: ['eo-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + hatching_detect: { + session_id: '20260627_2210_hatch_c', + title: 'Pre-hatching vigil · E11', + goal: 'Detect and capture the hatching event; accelerate acquisition as hatching approaches.', + tactics: [ + { + id: 'hd-1', seq: 1, + name: 'Pre-terminal monitoring', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'global' }, + rationale: 'Low cadence now; speed up as hatching approaches.', + structure: { watch: 'hatching', reaction: 'accelerate near event', status: 'watching' }, + live_bind: ['cadence'], + live: { + target: 'hatching', + summary: 'watching', + desc: 'low cadence now · speed up as hatching approaches', + readouts: [ + { label: 'est. time to hatch', value: '~38 min', sub: 'from motion + morphology' }, + { label: 'cadence', value: '180s', sub: 'will speed up near event' } + ] + }, + relations: {} + }, + { + id: 'hd-2', seq: 2, + name: 'Hatching speedup', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'High cadence through hatching.', + structure: { cadence_s: 30 }, + live_bind: ['cadence'], + live: { + summary: 'queued · ~T-10 min', + desc: 'high cadence through hatching' + }, + relations: { after: ['hd-1'] } + }, + { + id: 'hd-3', seq: 3, + name: 'Post-hatch monitor', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Resume normal cadence after the event.', + structure: { cadence_s: 120 }, + live_bind: ['cadence'], + live: { summary: 'queued · after event' }, + relations: { after: ['hd-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + transmission_survey: { + session_id: '20260628_1100_survey_a', + title: 'Transmission survey · plate A', + goal: 'Survey all embryos with brightfield only; no laser excitation.', + tactics: [ + { + id: 'srv-1', seq: 1, + name: 'Transmission burst', + kind: 'exclusive_burst', state: 'active', + scope: { mode: 'global' }, + rationale: 'LED/brightfield bursts, no laser — DIC-like contrast.', + structure: { frames: 30, mode: 'brightfield', phase: 'capturing' }, + live_bind: ['current_burst'], + live: { + summary: 'capturing', + desc: 'LED/brightfield bursts, no laser — DIC-like contrast', + readouts: [ + { label: 'bursts captured', value: '7', sub: 'across 3 embryos' }, + { label: 'illumination', value: 'LED · laser off', sub: 'brightfield' } + ] + }, + relations: {} + }, + { + id: 'srv-2', seq: 2, + name: 'Volume at best plane', + kind: 'oneshot', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Full volume capture at the best focal plane after operator review.', + structure: { note: 'operator selects plane' }, + live_bind: [], + live: { summary: 'queued · operator review' }, + relations: { after: ['srv-1'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + decided_plan: { + session_id: '20260628_1500_tempstrain_b', + title: 'Temperature-strain run · E02', + goal: 'Repeat the thermal strain protocol on a second embryo cohort.', + tactics: [ + { + id: 'dp-1', seq: 1, + name: 'Transmission burst — baseline', + kind: 'exclusive_burst', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Brightfield baseline before any change.', + structure: { frames: 3, mode: 'brightfield' }, + live_bind: [], + live: { + summary: 'queued · first', + desc: 'brightfield baseline before any change' + }, + relations: {} + }, + { + id: 'dp-2', seq: 2, + name: 'Temp-change burst protocol', + kind: 'scripted_protocol', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Thermal step to 30.0 °C with phased acquisition.', + structure: { + phases: [ + { name: 'before', state: 'todo', count: '0/1' }, + { name: 'during', state: 'todo', count: '0/3' }, + { name: 'after', state: 'todo', count: '0/1' } + ] + }, + live_bind: ['temperature', 'current_burst'], + live: { target: '→ 30.0 °C', summary: 'queued · second' }, + relations: { after: ['dp-1'] } + }, + { + id: 'dp-3', seq: 3, + name: 'Recovery monitor', + kind: 'standing_timelapse', state: 'planned', + scope: { mode: 'global' }, + rationale: 'Low-cadence monitoring after temperature settles.', + structure: { cadence_s: 180 }, + live_bind: ['cadence'], + live: { summary: 'queued · last' }, + relations: { after: ['dp-2'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + async_multi: { + session_id: '20260628_1630_async_multi', + title: 'Async multi-embryo run · 4 embryos', + goal: 'Per-embryo asynchronous acquisition with individual cadence phases; overlay a hatching watch on the two most advanced.', + tactics: [ + { + id: 'am-1', seq: 1, + name: 'Async timelapse — per-embryo cadence', + kind: 'standing_timelapse', state: 'active', + scope: { mode: 'embryos', embryo_ids: ['E01', 'E02', 'E03', 'E04'] }, + rationale: 'Each embryo runs at its own cadence based on developmental stage and reporter state.', + structure: { + cadence_s: 120, + per_embryo: [ + { embryo_id: 'E01', cadence_phase: 'normal', interval_s: 180 }, + { embryo_id: 'E02', cadence_phase: 'fast', interval_s: 30 }, + { embryo_id: 'E03', cadence_phase: 'burst', interval_s: 0 }, + { embryo_id: 'E04', cadence_phase: 'paused', interval_s: null } + ] + }, + live_bind: ['cadence'], + live: { + summary: 'running · 4 embryos', + readouts: [ + { label: 'active embryos', value: '3 / 4', sub: 'E04 paused' }, + { label: 'cadence range', value: '30–180s', sub: 'per-embryo mode' } + ] + }, + relations: {} + }, + { + id: 'am-2', seq: 2, + name: 'Hatching watch — E01, E02', + kind: 'reactive_monitor', state: 'active', + scope: { mode: 'embryos', embryo_ids: ['E01', 'E02'] }, + rationale: 'Overlay a hatching detector on the two most advanced embryos.', + structure: { watch: 'hatching', reaction: 'accelerate + alert', status: 'armed' }, + live_bind: ['signal'], + live: { + target: 'hatching', + summary: 'armed · E01, E02', + desc: 'watching for hatching onset on the two most advanced embryos', + readouts: [ + { label: 'watch status', value: 'armed', sub: 'no event yet' }, + { label: 'scope', value: 'E01, E02', sub: '2 of 4 embryos' } + ] + }, + relations: { layered_on: ['am-1'] } + } + ] + }, + + /* ------------------------------------------------------------------ */ + idle: null +}; diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 7edcd230..1f09ba06 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -931,6 +931,7 @@

Properties

+ From e65834db6da722ef8072ec7d0830a475400e3fbc Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:12:46 +0530 Subject: [PATCH 219/314] feat(operations): live telemetry binding + event-driven refresh - ExperimentOverview now subscribes to CONTEXT_UPDATED + 7 tactic-lifecycle events (TEMP_PROTOCOL_STARTED/COMPLETED, BURST_START/COMPLETE, EMBRYO_CADENCE_CHANGED, TEMPERATURE_SETPOINT_CHANGED, POWER_RAMP_STEP); each fires a debounced (500 ms) loadPlan()+render() refetch. - _debouncedRefresh(): coalesces rapid bursts into a single fetch+render. _subscribed flag prevents double-registration on repeated tab re-clicks. Subscriptions are skipped entirely in scenario mode (?scenario=). - _handleTempUpdate(): TEMPERATURE_UPDATE (~1 Hz) binds water_c/setpoint_c directly onto the active scripted_protocol tactic's temperature gauge IN PLACE (updates .ops-gv only), with no plan refetch. - _renderOpsReadout(): stamps data-livebind on each gauge div. Explicit r.bind key takes priority over normalised r.label, so display labels ("stage temp") and semantic bind keys ("temperature") are decoupled. - operations-scenarios.js: adds bind:'temperature' to the temp_strain stage-temp readout so the in-place selector resolves correctly. --- .../ui/web/static/js/experiment-overview.js | 91 ++++++++++++++++++- .../ui/web/static/js/operations-scenarios.js | 1 + 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index c46eba9c..a42bb022 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -16,6 +16,9 @@ const ExperimentOverview = { activePlan: null, // last fetched/loaded operation plan (overview spine) isLive: false, // true when data came from the API scenarioMode: false, // true when ?scenario= is active + _subscribed: false, // guard: prevents double-registration across tab re-clicks + _planRefreshTimer: null, // debounce handle for tactic-event-driven refetch + _tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed async init() { console.log('[ExperimentOverview] init() called, view=', this.activeView); @@ -46,6 +49,32 @@ const ExperimentOverview = { this.isLive = plan !== null || strategy !== null; this.render(strategy); this.initialized = true; + + // Subscribe to tactic-state events once per page load. + // Guard: _subscribed prevents double-registration across tab re-clicks. + // Skip entirely in scenario mode — no live backend, no websocket. + if (!this._subscribed) { + this._subscribed = true; + const refresh = () => this._debouncedRefresh(); + // Plan-changing events: re-fetch the whole plan after debounce. + // CONTEXT_UPDATED fires when OperationPlanUpdater patches the plan. + // The tactic-lifecycle events fire on transitions the updater also + // reacts to, so they all funnel into the same debounced refetch. + const TACTIC_EVENTS = [ + 'CONTEXT_UPDATED', + 'TEMP_PROTOCOL_STARTED', 'TEMP_PROTOCOL_COMPLETED', + 'BURST_START', 'BURST_COMPLETE', + 'EMBRYO_CADENCE_CHANGED', 'TEMPERATURE_SETPOINT_CHANGED', + 'POWER_RAMP_STEP', + ]; + TACTIC_EVENTS.forEach(ev => ClientEventBus.on(ev, refresh)); + + // High-frequency temperature binding (~1 Hz). + // Updates the active scripted_protocol tactic's temperature gauge + // IN PLACE — no plan refetch, no full re-render. + this._tempUpdateHandler = (data) => this._handleTempUpdate(data); + ClientEventBus.on('TEMPERATURE_UPDATE', this._tempUpdateHandler); + } }, async loadStrategy() { @@ -85,6 +114,57 @@ const ExperimentOverview = { } }, + // Debounced plan refetch — coalesces rapid tactic-event bursts into a single + // fetch+render. 500 ms window matches experiment-strip.js convention. + _debouncedRefresh() { + if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); + this._planRefreshTimer = setTimeout(async () => { + this._planRefreshTimer = null; + const plan = await this.loadPlan(); + this.activePlan = plan; + this.isLive = plan !== null; + this.render(this.activeStrategy); + }, 500); + }, + + // In-place temperature gauge update — called at ~1 Hz by TEMPERATURE_UPDATE. + // Finds the active scripted_protocol tactic's temperature readout in the DOM + // and rewrites only that element's value, never refetching the plan. + // No-op when there is no active scripted_protocol tactic with temperature binding. + _handleTempUpdate(data) { + if (!data || !data.sample) return; + const plan = this.activePlan; + if (!plan || !Array.isArray(plan.tactics)) return; + // Only act when an active scripted_protocol tactic declares temperature binding. + const activeTactic = plan.tactics.find( + t => t.state === 'active' + && t.kind === 'scripted_protocol' + && Array.isArray(t.live_bind) + && t.live_bind.includes('temperature') + ); + if (!activeTactic) return; + + const root = document.getElementById('experiment-overview-root'); + if (!root) return; + // _renderOpsReadout stamps data-livebind="temperature" on the gauge div + // when the readout label normalises to "temperature". + const gauge = root.querySelector('.ops-node.active .ops-gauge[data-livebind="temperature"]'); + if (!gauge) return; + const gv = gauge.querySelector('.ops-gv'); + if (!gv) return; + + const s = data.sample; + const water = s.water_c != null + ? parseFloat(s.water_c).toFixed(1) + '°C' + : '—'; + const sp = s.setpoint_c != null + ? ' → ' + + parseFloat(s.setpoint_c).toFixed(1) + + '°C' + : ''; + gv.innerHTML = water + sp; + }, + setView(view) { if (view === this.activeView) return; this.activeView = view; @@ -1228,8 +1308,17 @@ const ExperimentOverview = { }, // Render a readout gauge. `r.value` may contain trusted HTML (span markup). + // Stamps data-livebind on the outer div so _handleTempUpdate (and future + // live-binding) can find the gauge in-place without a full re-render. + // Priority: r.bind (explicit semantic key) > normalised r.label. _renderOpsReadout(r, ESC) { - return `
+ const bindKey = r.bind + ? r.bind + : (r.label + ? r.label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') + : ''); + const bindAttr = bindKey ? ` data-livebind="${ESC(bindKey)}"` : ''; + return `
${ESC(r.label)}
${r.value}
${r.bar != null diff --git a/gently/ui/web/static/js/operations-scenarios.js b/gently/ui/web/static/js/operations-scenarios.js index 9a4f74e7..0cd72fbf 100644 --- a/gently/ui/web/static/js/operations-scenarios.js +++ b/gently/ui/web/static/js/operations-scenarios.js @@ -70,6 +70,7 @@ window.OPERATIONS_SCENARIOS = { readouts: [ { label: 'stage temp', + bind: 'temperature', value: '29.432.0°C', bar: 62 }, From ab4395c2be8f8c36bf7482659801061d702fc189 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:29:43 +0530 Subject: [PATCH 220/314] fix(operations): wire seeding + agent plan guidance + queue_burst guard + paused state + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - templates.py: add OPERATION_PLAN_GUIDANCE block + inject after REACTIVE_MONITORING_MODES - agent.py exit_plan_mode: call seed_operation_plan_from_plan_item after link_session_campaign - timelapse_tools.py queue_burst: gate transition_tactic on result.startswith("Burst queued for ") - operation_plan_tools.py: add "paused" to _VALID_STATES (pause_timelapse uses it) - file_store.py transition_tactic: add read-modify-write / single-loop-thread note - spec: document live.readouts/phases + flat bound keys in §1 tactic schema - test: add test_queue_burst_soft_reject_does_not_transition (Fix #4 coverage) --- .superpowers/sdd/dv3-whole-branch-fixes.md | 109 ++++++++++++++++++ .../specs/2026-06-28-operations-tab-design.md | 8 ++ gently/app/agent.py | 10 ++ gently/app/tools/operation_plan_tools.py | 2 +- gently/app/tools/timelapse_tools.py | 8 +- gently/harness/memory/file_store.py | 4 + gently/harness/prompts/templates.py | 26 +++++ tests/test_tool_tactic_linkage.py | 28 +++++ 8 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 .superpowers/sdd/dv3-whole-branch-fixes.md diff --git a/.superpowers/sdd/dv3-whole-branch-fixes.md b/.superpowers/sdd/dv3-whole-branch-fixes.md new file mode 100644 index 00000000..5ec58d80 --- /dev/null +++ b/.superpowers/sdd/dv3-whole-branch-fixes.md @@ -0,0 +1,109 @@ +# dv3 whole-branch review fixes — backend half + +Agent: backend-fixes agent (this session). +Commit: see footer. +Frontend fixes (experiment-overview.js + css): separate agent, not touched here. + +--- + +## Fix #2 — agent prompt guidance to declare the Operation Plan + +**File:** `gently/harness/prompts/templates.py` + +Added constant `OPERATION_PLAN_GUIDANCE` (~line 225) and injected `{OPERATION_PLAN_GUIDANCE}` +into the system template f-string right after `{REACTIVE_MONITORING_MODES}` (~line 510). +The block tells the agent to call `declare_operation_plan` at experiment planning time +with kind/name/target/scope/rationale + a `live` object (`readouts`/`phases` + flat keys), +and to re-call (patch) on each tactic transition. Also notes that execution tools +(`queue_burst`, `enable_monitoring_mode`, etc.) accept `tactic_id` and flip state automatically. + +--- + +## Fix #3 — wire operation-plan seeding in exit_plan_mode + +**File:** `gently/app/agent.py`, `exit_plan_mode()` (~line 475) + +Hook site confirmed: inside `if active_id:` block, right after the `link_session_campaign` +try/except (line ~464-469). At that point `self.context_store` and `self.session_id` are +guaranteed valid (context_store is used unguarded at `get_plan_item` one line above). + +Added: +```python +try: + 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") +``` + +--- + +## Fix #4 — queue_burst phantom-active on soft-reject + +**File:** `gently/app/tools/timelapse_tools.py` (~line 1204) + +`orchestrator.queue_burst` returns `"Burst queued for {embryo_id} (request_id=..., ...)"` on +success and a rejection sentence (embryo not in timelapse / already had burst / already queued) +on soft-reject. Success is reliably distinguishable by `result.startswith("Burst queued for ")`. + +Guard applied: +```python +if tactic_id and isinstance(result, str) and result.startswith("Burst queued for "): + cs.transition_tactic(session, tactic_id, "active") +``` + +New test added to `tests/test_tool_tactic_linkage.py`: +`test_queue_burst_soft_reject_does_not_transition` — fake orchestrator returns +`"Embryo 'emb1' already has a queued burst."` → verifies `cs.transitions == []`. + +--- + +## Fix #5 — `paused` valid state + +**File:** `gently/app/tools/operation_plan_tools.py`, line 29 + +`_VALID_STATES` extended: `frozenset({"planned", "active", "done", "paused"})`. +`pause_timelapse` transitions to `"paused"` — this was previously an invalid state +causing a ValueError on validation. + +--- + +## Fix #7 — document `live` in the spec + +**File:** `docs/superpowers/specs/2026-06-28-operations-tab-design.md`, §1 + +Added `live` field to the tactic schema block with `readouts`/`phases` sub-keys and +a documentation paragraph explaining that the agent authors `readouts`/`phases` at +declaration time and the updater merges flat bound keys (`request_id`, `sustained_hz`, +`setpoint`, `locked`, `last_fired`, `new_phase`, …) as telemetry arrives. + +--- + +## Fix #8 — transition_tactic single-loop note + +**File:** `gently/harness/memory/file_store.py`, `transition_tactic` docstring (~line 847) + +Added one-line note: "read-modify-write with no lock; safe because all subscribed event +emissions run on the single asyncio loop thread — revisit if a worker-thread emitter +is ever added." + +--- + +## Test results + +``` +pytest tests/test_operation_plan_tool.py tests/test_tool_tactic_linkage.py \ + tests/test_operation_plan_seeding.py tests/test_operation_plan_store.py -v +65 passed, 0 failed +``` + +Full suite (excluding 3 pre-existing collection errors in test_campaign_coordination.py, +test_gently_store.py, test_text_tool_call_extraction.py): +- Before changes: 32 failed, 673 passed, 19 skipped +- After changes: 32 failed, 674 passed, 19 skipped (+1 new test, 0 new failures) + +--- + +## Commit + +`fix(operations): wire seeding + agent plan guidance + queue_burst guard + paused state + docs` diff --git a/docs/superpowers/specs/2026-06-28-operations-tab-design.md b/docs/superpowers/specs/2026-06-28-operations-tab-design.md index 95224ed1..d301e312 100644 --- a/docs/superpowers/specs/2026-06-28-operations-tab-design.md +++ b/docs/superpowers/specs/2026-06-28-operations-tab-design.md @@ -42,8 +42,16 @@ structure : # by kind oneshot|custom → { note } live_bind : [ 'temperature' | 'current_burst' | 'cadence' | 'signal' | ... ] # which telemetry fills readouts/progress relations : { after?:[tactic_id], layered_on?:[tactic_id], triggers?:[tactic_id] } # concurrency + composition +live : { # agent-authored display hints + updater-merged telemetry + readouts : [{label, value, sub?, bar?, bind?}] # instrument strip entries (e.g. {label:"cadence", value:"120 s"}) + phases : [{name, state:'done'|'active'|'todo', count, pips}] # phase stepper for scripted_protocol / exclusive_burst + # flat bound keys merged in by the updater as telemetry arrives: + # request_id, sustained_hz, setpoint, locked, last_fired, new_phase, ... +} ``` +The `live` object has two agent-authored sub-keys: `readouts` (a list of instrument-strip rows the agent populates at declaration time) and `phases` (a phase stepper list for scripted or burst tactics). The operation-plan updater additionally merges flat keys (`request_id`, `sustained_hz`, `setpoint`, `locked`, `last_fired`, `new_phase`, and any other telemetry bound via `live_bind`) directly onto `live` as events arrive — the agent seeds these at declaration if the value is already known, or leaves them absent for the updater to fill in. + This single typed object expresses a regular timelapse (standing), an async per-embryo run (standing with per-embryo cadence — *real* in the orchestrator), a reactive monitor incl. **hatching detection** (reactive), a temp-change protocol (scripted, phased), an exclusive burst, and arbitrary `custom` diff --git a/gently/app/agent.py b/gently/app/agent.py index b02b227c..47b2421d 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -467,6 +467,16 @@ def exit_plan_mode(self) -> str: ) except Exception: pass + + # Seed the Operation Plan from the plan item's tactics outline + # (idempotent: no-op if plan already has active/done tactics). + try: + 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") elif candidates: titles = [c[0].title for c in candidates] listing = ", ".join(titles[:5]) diff --git a/gently/app/tools/operation_plan_tools.py b/gently/app/tools/operation_plan_tools.py index 07d51835..93998a03 100644 --- a/gently/app/tools/operation_plan_tools.py +++ b/gently/app/tools/operation_plan_tools.py @@ -26,7 +26,7 @@ "custom", } ) -_VALID_STATES = frozenset({"planned", "active", "done"}) +_VALID_STATES = frozenset({"planned", "active", "done", "paused"}) def _validate_tactics(tactics: list) -> list[dict]: diff --git a/gently/app/tools/timelapse_tools.py b/gently/app/tools/timelapse_tools.py index 9e96cbe5..0c0c45c2 100644 --- a/gently/app/tools/timelapse_tools.py +++ b/gently/app/tools/timelapse_tools.py @@ -1201,8 +1201,12 @@ def queue_burst( force=force, tactic_id=tactic_id, ) - # Flip the matching plan tactic to active on enqueue (guarded no-op when absent). - if tactic_id: + # Flip the matching plan tactic to active only when the burst was actually queued. + # orchestrator.queue_burst returns "Burst queued for ..." on success and a + # human-readable rejection sentence on soft-reject (embryo absent, already had + # a burst, already has a queued burst). Gate on the success prefix so a + # soft-reject does NOT phantom-flip the tactic to active. + if tactic_id and isinstance(result, str) and result.startswith("Burst queued for "): cs = getattr(agent, "context_store", None) session = getattr(agent, "session_id", None) if cs and session: diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 98763cf3..7a9eba4e 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -842,6 +842,10 @@ def transition_tactic( Returns True on success, False if the plan is absent or the tactic id is not found (no-op, no crash). + + Note: read-modify-write with no lock; safe because all subscribed event + emissions run on the single asyncio loop thread — revisit if a + worker-thread emitter is ever added. """ plan = self.get_operation_plan(session_id) if plan is None: diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index f75fc7a5..536064b4 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -222,6 +222,30 @@ """ +OPERATION_PLAN_GUIDANCE = """ +## Operation Plan — keep it current + +At experiment planning time, call `declare_operation_plan` with every tactic +you intend to run. Each tactic needs at minimum: `id` (short stable string), +`name`, `kind`, `state` (start as `"planned"`), `scope`, and `rationale`. +For richer display, populate a `live` object on the tactic: + +- `readouts` — list of `{label, value}` dicts for the instrument strip + (e.g. `{label: "cadence", value: "120 s"}`). +- `phases` — list of `{name, state, count, pips}` for scripted/phased tactics. +- Flat bound keys (`request_id`, `sustained_hz`, `setpoint`, `locked`, + `last_fired`, `new_phase`, …) are merged in by the updater as live telemetry + arrives; you can seed them at declaration time if the value is already known. + +Re-call `declare_operation_plan` (patch) whenever a tactic's state changes: +`"planned"` → `"active"` when you start it, `"active"` → `"done"` when it +finishes. This keeps the Operations view in the UI synchronized with reality. +Execution tools (`queue_burst`, `enable_monitoring_mode`, `stop_timelapse`, +`pause_timelapse`) also accept an optional `tactic_id` and flip the state +automatically — pass it when a tool maps cleanly to one tactic. +""" + + ADAPTIVE_TIMELAPSE = """ # Adaptive Timelapse System @@ -483,6 +507,8 @@ def build_system_prompt( {REACTIVE_MONITORING_MODES} +{OPERATION_PLAN_GUIDANCE} + {AUTONOMY_AND_ADAPTATION} {USER_INTERACTION_GUIDELINES} diff --git a/tests/test_tool_tactic_linkage.py b/tests/test_tool_tactic_linkage.py index be245093..6b56fa47 100644 --- a/tests/test_tool_tactic_linkage.py +++ b/tests/test_tool_tactic_linkage.py @@ -185,6 +185,34 @@ async def test_queue_burst_passes_tactic_id_to_orchestrator(): assert orch.bursts_queued[0]["tactic_id"] == "t2" +@pytest.mark.asyncio +async def test_queue_burst_soft_reject_does_not_transition(): + """queue_burst (tool) must NOT flip the tactic to active on a soft-reject. + + orchestrator.queue_burst returns a rejection sentence (not starting with + "Burst queued for") when the embryo already has a queued burst, already + had a burst this session, or is not in the active timelapse. The tool + must treat those as non-events and leave transition_tactic uncalled. + """ + from gently.app.tools.timelapse_tools import queue_burst + + class RejectingOrchestrator(FakeOrchestrator): + def queue_burst(self, embryo_id, *, frames=60, mode="1hz", num_slices=1, + force=False, laser_config=None, tactic_id=None) -> str: + # Simulates "already has a queued burst" soft-reject + return f"Embryo '{embryo_id}' already has a queued burst." + + cs = FakeContextStore() + orch = RejectingOrchestrator() + agent = FakeAgent(cs=cs, orchestrator=orch) + + await queue_burst(embryo_id="emb1", tactic_id="t2", context=_ctx(agent, with_client=True)) + + assert cs.transitions == [], ( + f"transition_tactic must not be called on soft-reject; got {cs.transitions}" + ) + + # --------------------------------------------------------------------------- # stop_timelapse — mark done # --------------------------------------------------------------------------- From f4ab6064c6739d60bf9ddc0dea840a5c8175163a Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:32:37 +0530 Subject: [PATCH 221/314] fix(operations): surface bound live.* telemetry, paused state styling, delete dead swimlane code Fix #1: _renderOpsTactic now collects all flat keys on live{} (excl. readouts/phases/target/summary/desc) and renders them as a compact .ops-livefacts key:value strip for active and done tactics. Bound telemetry like sustained_hz, mp4_path, last_fired, request_id is now visible on the card face instead of being swallowed. Fix #5: added paused to STATE_LABEL; .ops-node.paused gets a muted grey dot + solid grey left edge distinct from done/active/planned. Fix #6: deleted 897 lines of unreachable swimlane dead code: _renderOverviewView, _renderHeader, _renderLegend, _renderSwimlanes, _renderLaneRow, _startNowTicker, _updateNowMarker. render() has called _renderOperationSpine for the overview path since the spine rewrite; all deleted functions were confirmed unreferenced outside their own chain. --- gently/ui/web/static/css/experiment.css | 60 ++ .../ui/web/static/js/experiment-overview.js | 922 +----------------- 2 files changed, 78 insertions(+), 904 deletions(-) diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 16f6ba73..1d48cef5 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -1074,3 +1074,63 @@ color: var(--text-muted); opacity: 0.75; } + +/* ---- Fix #1: bound live.* telemetry facts strip --------------- */ +/* Compact monospace key:value row rendered for active + done tactics + when the live object carries flat keys beyond readouts/phases/target. + Subtle instrument aesthetic — matches the ops-mono system, sits below + the structured readout strip without visual weight. */ + +.ops-livefacts { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + margin-top: 8px; + padding: 6px 10px; + background: var(--bg-hover, #21262d); + border: 1px solid var(--border, #30363d); + border-radius: 7px; + font-family: var(--ops-mono); + font-size: 11px; + line-height: 1.5; +} + +.ops-lf-pair { + display: inline-flex; + align-items: baseline; + gap: 5px; +} + +.ops-lf-k { + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: lowercase; +} + +.ops-lf-v { + color: var(--text); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* ---- Fix #5: paused tactic state ----------------------------- */ +/* Muted grey dot + solid grey left edge — visually distinct from + done (green), active (amber), and queued (dashed blue). */ + +.ops-node.paused::before { + border-color: var(--text-muted, #8b949e); + background: var(--bg-card, #161b22); + opacity: 0.7; +} + +.ops-node.paused .ops-stagelab { + color: var(--text-muted, #8b949e); +} + +.ops-node.paused .ops-card { + background: transparent; + border-color: rgba(139, 148, 158, 0.3); + border-left: 3px solid var(--text-muted, #8b949e); + opacity: 0.8; +} diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index a42bb022..6ba9b109 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -212,20 +212,6 @@ const ExperimentOverview = { } }, - // The "now" marker advances with wall-clock time and shows a countdown to - // the next base-interval acquisition. We update only the marker group's - // transform + the chip text, never re-rendering the whole SVG. Tick rate - // is ~4 Hz which keeps the line motion visibly smooth without burning - // cycles. Skipped while the tab is hidden. - _startNowTicker() { - this._stopNowTicker(); - const tick = () => { - if (!this._nowTickerCtx) return; - if (!document.hidden) this._updateNowMarker(); - this._nowTickerHandle = setTimeout(tick, 250); - }; - this._nowTickerHandle = setTimeout(tick, 250); - }, _stopNowTicker() { if (this._nowTickerHandle) { @@ -234,56 +220,7 @@ const ExperimentOverview = { } }, - _updateNowMarker() { - const ctx = this._nowTickerCtx; - if (!ctx || !ctx.marker.isConnected) return; - const elapsedRealS = (Date.now() - ctx.renderedAtMs) / 1000; - const effOffsetS = Math.min( - ctx.renderedOffsetS + elapsedRealS, - ctx.horizonS - ); - const x = ctx.xForT(effOffsetS); - ctx.marker.setAttribute('transform', `translate(${x},0)`); - - // Wall-clock from session-anchored time so the line and the clock - // can't drift apart even if the client clock is wrong. - const wallMs = ctx.startedAtMs + effOffsetS * 1000; - const d = new Date(wallMs); - const hh = String(d.getHours()).padStart(2, '0'); - const mm = String(d.getMinutes()).padStart(2, '0'); - const ss = String(d.getSeconds()).padStart(2, '0'); - - let label = `${hh}:${mm}:${ss}`; - if (ctx.baseIntervalS > 0) { - const nextTickS = Math.ceil(effOffsetS / ctx.baseIntervalS) * ctx.baseIntervalS; - const remainS = Math.max(0, Math.round(nextTickS - effOffsetS)); - const rm = Math.floor(remainS / 60); - const rs = String(remainS % 60).padStart(2, '0'); - label += ` · next ${rm}:${rs}`; - } - ctx.chipText.textContent = label; - - // Size the chip to fit; flip to the left of the line if we're near - // the right edge so it stays on-screen. - const textLen = label.length * 6.2 + 12; - const nearEnd = x + textLen + 8 > ctx.laneRight; - if (nearEnd) { - ctx.chipBg.setAttribute('x', -textLen - 4); - ctx.chipBg.setAttribute('width', textLen); - ctx.chipText.setAttribute('x', -textLen + 2); - } else { - ctx.chipBg.setAttribute('x', 4); - ctx.chipBg.setAttribute('width', textLen); - ctx.chipText.setAttribute('x', 10); - } - }, - _renderOverviewView(root, s) { - root.appendChild(this._renderHeader(s)); - root.appendChild(this._renderModes(s)); - root.appendChild(this._renderModeExpanded(s)); - root.appendChild(this._renderSwimlanes(s)); - }, _renderRulesView(root, s) { // Compact header echoing the session identity @@ -302,48 +239,6 @@ const ExperimentOverview = { root.appendChild(this._renderRulesTable(s)); }, - // ----------------------------------------------------------------- - // Header — session identification + key metrics strip - // (page-level title lives in .experiment-header-bar above) - // ----------------------------------------------------------------- - _renderHeader(s) { - const elapsedH = Math.floor(s.now_offset_s / 3600); - const elapsedM = Math.floor((s.now_offset_s % 3600) / 60); - const wrap = el('div', 'expov-header'); - - // Session identification — the navbar already carries the id on - // every tab, so we only render a name line when it actually adds - // info (i.e. a human label, not a hash). The data-source badge is - // still useful and gets its own row so it stays visible. - const metaRow = el('div', 'expov-header-row expov-header-row-meta'); - if (s.session_name && s.session_name !== s.session_id) { - metaRow.appendChild(elText('span', 'expov-session-name', s.session_name)); - } - metaRow.appendChild(elText('span', 'expov-live-badge', 'live')); - wrap.appendChild(metaRow); - - // Compact key-metric strip - const roleCounts = {}; - s.embryos.forEach(e => { roleCounts[e.role] = (roleCounts[e.role] || 0) + 1; }); - const roleStr = Object.entries(roleCounts).map(([r, n]) => `${n} ${r}`).join(' · '); - const metricsRow = el('div', 'expov-header-row expov-header-row-metrics'); - const metric = (label, val) => { - const m = el('span', 'expov-metric'); - m.appendChild(elText('span', 'expov-metric-val', val)); - m.appendChild(elText('span', 'expov-metric-lbl', label)); - return m; - }; - metricsRow.appendChild(metric('elapsed', `${elapsedH}h ${elapsedM}m`)); - metricsRow.appendChild(metric('base', `${s.base_interval_s}s`)); - const budgetText = (s.dose_budget_base_ms != null && isFinite(s.dose_budget_base_ms)) - ? `${(s.dose_budget_base_ms / 1000).toFixed(0)}s × role` - : 'no limit'; - metricsRow.appendChild(metric('budget', budgetText)); - metricsRow.appendChild(metric('embryos', `${s.embryos.length} · ${roleStr}`)); - wrap.appendChild(metricsRow); - - return wrap; - }, // ----------------------------------------------------------------- // Monitoring mode chips + expanded panel @@ -409,804 +304,6 @@ const ExperimentOverview = { return name.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); }, - // ----------------------------------------------------------------- - // Legend - // ----------------------------------------------------------------- - _renderLegend() { - const wrap = el('div', 'expov-legend'); - const items = [ - ['base', 'base cadence'], - ['fast', 'fast cadence'], - ['burst', 'burst window'], - ['cooldown', 'cooldown'], - ['paused', 'paused'], - ]; - items.forEach(([cls, label]) => { - const item = el('span', 'expov-legend-item'); - item.appendChild(elClass('span', `expov-legend-swatch ${cls}`)); - item.appendChild(elText('span', '', label)); - wrap.appendChild(item); - }); - const projItem = el('span', 'expov-legend-item'); - projItem.appendChild(elClass('span', 'expov-legend-swatch projected')); - projItem.appendChild(elText('span', '', 'projected')); - wrap.appendChild(projItem); - - const glyphs = [ - ['◇', 'trigger fired'], - ['●', 'now'], - ['■', 'stop condition'], - ['∞', 'open-ended'], - ['▲', 'burst start'], - ['⚠', 'budget warning'] - ]; - glyphs.forEach(([g, label]) => { - const item = el('span', 'expov-legend-item'); - item.appendChild(elText('span', 'expov-legend-glyph', g)); - item.appendChild(elText('span', '', label)); - wrap.appendChild(item); - }); - return wrap; - }, - - // ----------------------------------------------------------------- - // Swimlanes SVG — the main visualization - // ----------------------------------------------------------------- - _renderSwimlanes(s) { - const wrap = el('div', 'expov-swimlanes-wrap'); - - // Compact inline legend above the SVG - const legend = el('div', 'expov-mini-legend'); - const swatches = [ - ['base', 'base'], - ['fast', 'fast'], - ['burst', 'burst'], - ['cooldown', 'cooldown'] - ]; - swatches.forEach(([k, label]) => { - const item = el('span', 'expov-mini-legend-item'); - const sw = el('span', `expov-mini-legend-swatch ${k}`); - item.appendChild(sw); - item.appendChild(elText('span', '', label)); - legend.appendChild(item); - }); - const projItem = el('span', 'expov-mini-legend-item'); - projItem.appendChild(elClass('span', 'expov-mini-legend-swatch projected')); - projItem.appendChild(elText('span', '', 'projected')); - legend.appendChild(projItem); - wrap.appendChild(legend); - - // Layout constants (logical pixels in the SVG viewBox) - const LEFT = 180; // label gutter - const RIGHT = 80; // right gutter for stop icon + ∞ - const LANE_W = 900; // lane drawing area - const W = LEFT + LANE_W + RIGHT; - - const ROW_H = 100; // per-embryo row total height - const LANE_H = 28; // cadence lane height - const POWER_H = 22; // power strip height - const DOSE_H = 12; // dose gauge height - const ROW_PAD = 14; // top padding inside row - const TOP_AXIS_H = 36; // top axis area (time labels + wall-clock) - const BOTTOM_PAD = 8; - - const rows = s.embryos.length; - const H = TOP_AXIS_H + rows * ROW_H + BOTTOM_PAD; - - const svg = svgEl('svg', { - class: 'expov-swimlanes-svg', - viewBox: `0 0 ${W} ${H}`, - preserveAspectRatio: 'xMinYMin meet' - }); - - // Time scale helpers - const xForT = (t) => LEFT + (t / s.horizon_s) * LANE_W; - const nowX = xForT(s.now_offset_s); - - // ----- top axis: hour ticks with wall-clock annotation - const startedAt = new Date(s.started_at); - const axisG = svgEl('g'); - for (let h = 0; h <= Math.ceil(s.horizon_s / 3600); h++) { - const x = xForT(h * 3600); - axisG.appendChild(svgEl('line', { - x1: x, x2: x, y1: TOP_AXIS_H - 6, y2: H - BOTTOM_PAD, - class: 'expov-svg-axis', 'stroke-opacity': h === 0 ? 0.55 : 0.12 - })); - axisG.appendChild(svgEl('text', { - x: x + 4, y: 12, class: 'expov-svg-axis-label' - }, `+${h}h`)); - // Wall-clock subtitle - const wallClock = new Date(startedAt.getTime() + h * 3600 * 1000); - const hh = String(wallClock.getHours()).padStart(2, '0'); - const mm = String(wallClock.getMinutes()).padStart(2, '0'); - axisG.appendChild(svgEl('text', { - x: x + 4, y: 22, - class: 'expov-svg-axis-wallclock' - }, `${hh}:${mm}`)); - } - svg.appendChild(axisG); - - // ----- "now" marker: translatable group containing the vertical line - // and a live clock chip. The chip advances every tick and shows the - // countdown to the next base-interval acquisition window. The group - // gets translated by _tickNow, so we don't rebuild SVG every second. - const nowMarker = svgEl('g', { class: 'expov-svg-now-marker' }); - nowMarker.appendChild(svgEl('line', { - x1: 0, x2: 0, y1: TOP_AXIS_H - 4, y2: H - BOTTOM_PAD, - class: 'expov-svg-now-line' - })); - // Chip sits just below the axis labels (which live at y=12 and y=22) - // so it doesn't sit on top of the "+0h / wallclock" annotation when - // the now-line is near the start of the timeline. - const chipBg = svgEl('rect', { - x: 4, y: TOP_AXIS_H - 6, width: 120, height: 14, rx: 7, - class: 'expov-svg-now-chip-bg' - }); - const chipText = svgEl('text', { - x: 10, y: TOP_AXIS_H + 4, class: 'expov-svg-now-label' - }, ''); - nowMarker.appendChild(chipBg); - nowMarker.appendChild(chipText); - svg.appendChild(nowMarker); - - // Stash the bits the ticker needs to update without re-rendering. - this._nowTickerCtx = { - marker: nowMarker, - chipBg: chipBg, - chipText: chipText, - xForT, - startedAtMs: new Date(s.started_at).getTime(), - renderedAtMs: Date.now(), - renderedOffsetS: s.now_offset_s, - baseIntervalS: s.base_interval_s || 0, - horizonS: s.horizon_s, - laneLeft: LEFT, - laneRight: LEFT + LANE_W, - }; - this._updateNowMarker(); - - // ----- one group per embryo - s.embryos.forEach((emb, i) => { - const rowTop = TOP_AXIS_H + i * ROW_H; - const rowG = svgEl('g'); - rowG.appendChild(this._renderLaneRow(s, emb, { - LEFT, LANE_W, RIGHT, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, - TOP_AXIS_H, rowTop, xForT, nowX - })); - svg.appendChild(rowG); - }); - - wrap.appendChild(svg); - return wrap; - }, - - _renderLaneRow(s, emb, dim) { - const { LEFT, LANE_W, W, ROW_H, LANE_H, POWER_H, DOSE_H, ROW_PAD, - rowTop, xForT, nowX } = dim; - const g = svgEl('g'); - - // Hairline divider above each row (except first) - if (rowTop > dim.TOP_AXIS_H) { - g.appendChild(svgEl('line', { - x1: 8, x2: W - 8, y1: rowTop, y2: rowTop, - class: 'expov-svg-row-divider' - })); - } - - // ---- Left label gutter --------------------------------------- - // Single header line + phase pill. Power/dose labels are at the - // y-position of their respective sub-rows, right-aligned in the gutter. - const labelY = rowTop + ROW_PAD + 12; - - // Header line: icon · ID · role (· 10× hint for calibration) - g.appendChild(svgEl('text', { - x: 14, y: labelY + 1, class: 'expov-svg-role-icon', - fill: emb.color - }, emb.icon)); - g.appendChild(svgEl('text', { - x: 32, y: labelY, class: 'expov-svg-label' - }, emb.id)); - // role tag — eyebrow above the id (avoids colliding with long ids) - g.appendChild(svgEl('text', { - x: 14, y: rowTop + ROW_PAD - 1, - class: 'expov-svg-role-tag' - }, emb.role)); - - // Phase pill: current mode at glance - const currentPhase = emb.phases[emb.phases.length - 1]; - const pillY = labelY + 7; - const pillH = 14; - const phaseLabel = currentPhase.mode === 'burst' - ? `BURST · ${currentPhase.hz}Hz` - : `${currentPhase.mode.toUpperCase()} · ${currentPhase.cadence_s}s`; - const pillW = Math.max(70, phaseLabel.length * 6 + 12); - const pillX = 32; - const phaseColors = { - base: '#6b7280', - fast: '#fb923c', - burst: '#ef4444', - cooldown: '#a78bfa', - paused: '#3b82f6' - }; - const pillFill = phaseColors[currentPhase.mode] || '#6b7280'; - g.appendChild(svgEl('rect', { - x: pillX, y: pillY, width: pillW, height: pillH, rx: 7, - fill: pillFill, 'fill-opacity': 0.22, - stroke: pillFill, 'stroke-opacity': 0.65, 'stroke-width': 1 - })); - g.appendChild(svgEl('text', { - x: pillX + pillW / 2, y: pillY + 10, - 'text-anchor': 'middle', - fill: pillFill, 'font-size': 9.5, 'font-weight': 700, - 'font-family': "'JetBrains Mono', monospace" - }, phaseLabel)); - - // Tiny tp annotation under the pill (no stop — that's at lane right edge) - g.appendChild(svgEl('text', { - x: 32, y: pillY + pillH + 12, - class: 'expov-svg-sublabel' - }, `${emb.tp_acquired} tp acquired`)); - - // ---- Cadence lane -------------------------------------------- - const laneY = rowTop + ROW_PAD; - const laneMid = laneY + LANE_H / 2; - const laneBottom = laneY + LANE_H; - - // Phases — solid colored rects, no ticks. Cadence is read from the - // phase pill in the gutter and the optional inline cadence label. - // Min 4px visual width so micro-phases (burst, cooldown) stay visible. - emb.phases.forEach(ph => { - const x0 = xForT(ph.start); - const x1Raw = xForT(ph.end); - const x1 = Math.max(x1Raw, x0 + 4); - const width = x1 - x0; - const cls = `expov-svg-phase-${ph.mode}`; - g.appendChild(svgEl('rect', { - x: x0, y: laneY, width, height: LANE_H, rx: 2, - class: cls - })); - // Cadence text inside the rect is intentionally omitted — the - // gutter pill (currentPhase) and the colored rect (mode) already - // convey it. Keep an inline label only for cooldown, which is a - // transient state the pill won't be showing. - if (ph.mode === 'cooldown' && ph.cadence_s && width >= 42) { - g.appendChild(svgEl('text', { - x: x0 + width / 2, y: laneMid + 3.5, - 'text-anchor': 'middle', - class: 'expov-svg-phase-label' - }, `${ph.cadence_s}s · cool`)); - } - // Burst: keep the bright block + balloon since it's the most - // attention-worthy event in the lane - if (ph.mode === 'burst') { - const bx = (xForT(ph.start) + xForT(ph.end)) / 2; - const balloonY = laneY - 12; - const halfW = 30; - g.appendChild(svgEl('rect', { - x: bx - halfW, y: balloonY - 10, width: halfW * 2, height: 12, rx: 3, - class: 'expov-svg-burst-balloon-bg' - })); - g.appendChild(svgEl('text', { - x: bx, y: balloonY - 1, - 'text-anchor': 'middle', - class: 'expov-svg-burst-label' - }, `${ph.frames}f · ${ph.hz}Hz`)); - g.appendChild(svgEl('line', { - x1: bx, x2: bx, y1: balloonY + 2, y2: laneY, - stroke: '#ef4444', 'stroke-width': 1, 'stroke-opacity': 0.7 - })); - } - }); - - // ---- Acquisition density heatmap ---------------------------- - // Instead of one hairline per acquisition (reads as a barcode), - // we encode acquisition density as a luminance gradient over - // the past portion of the lane: sparse = lane stays muted, - // dense = a brighter band. The eye reads acquisition rate as - // brightness — no discrete marks, no clutter. - // - // Counts are derived from phase cadence then rescaled to match - // the authoritative `tp_acquired`, so the gradient never lies - // about how many timepoints fired even when the backend phase - // history is stale or incorrect. - const tickEnd = s.now_offset_s; - const ackPhases = emb.phases - .map((ph, i) => ({ ph, i })) - .filter(({ ph }) => ph.mode !== 'burst' && ph.cadence_s); - const predicted = ackPhases.map(({ ph }) => { - const phEnd = Math.min(ph.end ?? tickEnd, tickEnd); - const dur = phEnd - ph.start; - return dur > 0 ? Math.max(1, Math.floor(dur / ph.cadence_s) + 1) : 0; - }); - const predictedTotal = predicted.reduce((a, b) => a + b, 0); - const actualTotal = Number.isFinite(emb.tp_acquired) - ? emb.tp_acquired : predictedTotal; - const scale = predictedTotal > 0 ? actualTotal / predictedTotal : 0; - const acquisitions = []; - ackPhases.forEach(({ ph }, idx) => { - const phEnd = Math.min(ph.end ?? tickEnd, tickEnd); - const dur = phEnd - ph.start; - if (dur <= 0) return; - const n = Math.max(1, Math.round(predicted[idx] * scale)); - for (let j = 0; j < n; j++) { - acquisitions.push(ph.start + (dur * (j + 0.5)) / n); - } - }); - - if (acquisitions.length > 0 && tickEnd > 0) { - // Layer 1 (background): smoothed luminance gradient - // encoding overall acquisition density along the lane. - // The triangular kernel kills aliasing stripes caused by - // evenly-spaced acquisitions falling into bins. - const BINS = 64; - const binSec = tickEnd / BINS; - const raw = new Array(BINS).fill(0); - for (const t of acquisitions) { - const bin = Math.min(BINS - 1, Math.max(0, Math.floor(t / binSec))); - raw[bin] += 1; - } - const kernel = [1, 2, 3, 4, 5, 4, 3, 2, 1]; - const kSum = kernel.reduce((a, b) => a + b, 0); - const kOff = Math.floor(kernel.length / 2); - const density = new Array(BINS).fill(0); - for (let i = 0; i < BINS; i++) { - let acc = 0, w = 0; - for (let k = 0; k < kernel.length; k++) { - const j = i + k - kOff; - if (j < 0 || j >= BINS) continue; - acc += raw[j] * kernel[k]; - w += kernel[k]; - } - density[i] = w > 0 ? acc / w * (kSum / w) : 0; - } - const maxD = Math.max(...density, 1e-6); - const gradId = `expov-density-${(emb.id || 'e').replace(/\W+/g, '_')}-r${rowTop}`; - const grad = svgEl('linearGradient', { - id: gradId, x1: '0%', x2: '100%', y1: '0%', y2: '0%' - }); - for (let i = 0; i < BINS; i++) { - const intensity = density[i] / maxD; - // Lower ceiling than the heatmap-only version (0.22 vs - // 0.38) because the dots above will carry the per-event - // signal; the band just hints at rate. - const alpha = 0.03 + 0.22 * intensity; - grad.appendChild(svgEl('stop', { - offset: `${(i / (BINS - 1)) * 100}%`, - 'stop-color': '#ffffff', - 'stop-opacity': alpha.toFixed(3), - })); - } - g.appendChild(grad); - const pastW = xForT(tickEnd) - LEFT; - if (pastW > 0) { - g.appendChild(svgEl('rect', { - x: LEFT, y: laneY, - width: pastW, height: LANE_H, - fill: `url(#${gradId})`, - rx: 2, - 'pointer-events': 'none' - })); - } - // Layer 2 (foreground): one soft round dot per acquisition - // along the top edge of the lane — keeps the per-event - // temporal discreteness the heatmap alone hides. - const dotY = laneY + 2; - for (const t of acquisitions) { - const tx = xForT(t); - g.appendChild(svgEl('circle', { - cx: tx, cy: dotY, r: 1.3, - class: 'expov-svg-acq-dot' - })); - } - } - - // ---- Cadence-change markers --------------------------------- - // Where consecutive phases differ in cadence (or mode), drop a - // vertical divider across the lane and a "300→60s · T34" chip - // above so the change is named and time-stamped in the lane. - // Apply the same scale factor used for tick rendering so the - // T# stamps shown on diamonds and cadence chips agree with the - // visible tick density and the authoritative tp_acquired count. - // Otherwise the chip might say "T118" on an embryo where we - // only drew 54 ticks — visually contradictory. - const tpIndexAt = (atS) => { - let count = 0; - for (const ph of emb.phases) { - if (!ph.cadence_s) continue; - if (atS < ph.start) break; - const phEnd = Math.min(atS, ph.end ?? atS); - count += Math.floor((phEnd - ph.start) / ph.cadence_s) + 1; - } - return Math.max(1, Math.round(count * scale)); - }; - // Track placed chip x-positions to avoid stacking chips on top - // of one another (e.g. the burst-balloon already sits there). - const placedChipX = []; - const burstXs = emb.phases - .filter(p => p.mode === 'burst') - .map(p => xForT(p.start)); - const isCollision = (x) => { - const min = 70; // px buffer - if (burstXs.some(bx => Math.abs(bx - x) < min)) return true; - return placedChipX.some(px => Math.abs(px - x) < min); - }; - // Walk phases and detect transitions, but COLLAPSE consecutive - // identical (mode + cadence) phases so a row of redundant phase - // records doesn't generate redundant dividers/chips. - let prevEffective = emb.phases[0]; - for (let i = 1; i < emb.phases.length; i++) { - const curr = emb.phases[i]; - const prev = prevEffective; - const sameCadence = prev.cadence_s === curr.cadence_s; - const sameMode = prev.mode === curr.mode; - if (sameCadence && sameMode) { - continue; // collapse: prevEffective stays the same - } - prevEffective = curr; - if (prev.mode === 'burst' || curr.mode === 'burst') continue; - const cx = xForT(curr.start); - if (cx > xForT(s.now_offset_s)) continue; - // Divider is cheap to keep even on collision; the chip is what - // crowds the space, so we skip just the chip when crowded. - g.appendChild(svgEl('line', { - x1: cx, x2: cx, y1: laneY, y2: laneBottom, - class: 'expov-svg-cadence-divider' - })); - if (isCollision(cx)) continue; - const tp = tpIndexAt(curr.start); - const prevS = prev.cadence_s ?? '?'; - const currS = curr.cadence_s ?? '?'; - const chipText = `${prevS}→${currS}s · T${tp}`; - const chipW = chipText.length * 5.6 + 10; - const chipY = laneY - 13; - g.appendChild(svgEl('rect', { - x: cx - chipW / 2, y: chipY, - width: chipW, height: 12, rx: 3, - class: 'expov-svg-cadence-chip-bg' - })); - g.appendChild(svgEl('text', { - x: cx, y: chipY + 9, - 'text-anchor': 'middle', - class: 'expov-svg-cadence-chip' - }, chipText)); - placedChipX.push(cx); - } - - // ---- Power-change chips ------------------------------------- - // Same visual language as cadence chips, but parked in a row - // above so the two encodings stack neatly when they happen at - // the same trigger. Each chip names the rule outcome - // ("488 ↓ 5%→3%") and the timepoint it landed at. - const placedPowerChipX = []; - const hist488 = emb.power_history_488 || []; - // Walk the history, collect actual transitions (pairs where pct - // changes), then cluster consecutive close ones so a multi-step - // ramp gets a single annotation. - const transitions = []; - for (let k = 0; k < hist488.length - 1; k++) { - const a = hist488[k]; - const b = hist488[k + 1]; - if (a.pct === b.pct) continue; - transitions.push({ from: a, to: b }); - } - const CLUSTER_S = 60; - const clusters = []; - for (const tr of transitions) { - const last = clusters[clusters.length - 1]; - if (last && tr.to.at - last[last.length - 1].to.at <= CLUSTER_S) { - last.push(tr); - } else { - clusters.push([tr]); - } - } - for (const cluster of clusters) { - const first = cluster[0]; - const tail = cluster[cluster.length - 1]; - // Anchor the chip at the actual change time, not at any - // trailing anchor record (those can land past `now` and - // get hidden by the past-only guard). - const atS = Math.min(tail.to.at, s.now_offset_s); - const cx = xForT(atS); - const arrow = tail.to.pct < first.from.pct ? '↓' : '↑'; - const chipText = `488 ${arrow} ${first.from.pct}%→${tail.to.pct}% · T${tpIndexAt(atS)}`; - const chipW = chipText.length * 5.6 + 10; - // Sit just above the burst-balloon band (which lives at - // laneY-22..-10) so we stay within this row's vertical - // budget — laneY-40 would have crossed into the row above. - // Burst balloons live at separate x positions on every - // case I've seen, so dropping the burst-collision check - // lets the chip render even when a burst is on the same - // lane elsewhere. - const chipY = laneY - 25; - const crowded = - placedPowerChipX.some(px => Math.abs(px - cx) < 70); - g.appendChild(svgEl('line', { - x1: cx, x2: cx, y1: chipY + 12, y2: laneY, - class: 'expov-svg-power-chip-stem' - })); - if (crowded) continue; - g.appendChild(svgEl('rect', { - x: cx - chipW / 2, y: chipY, - width: chipW, height: 12, rx: 3, - class: 'expov-svg-power-chip-bg' - })); - g.appendChild(svgEl('text', { - x: cx, y: chipY + 9, - 'text-anchor': 'middle', - class: 'expov-svg-power-chip' - }, chipText)); - placedPowerChipX.push(cx); - } - - // Projected future segment (dashed) past 'now' to a horizon — - // skipped entirely when the embryo has been terminated, since - // there's no future to project. projEndT is hoisted because - // downstream code (stop-icon, dose-exhaust line) anchors to it. - const isTerminated = emb.terminated_at_s != null - && emb.terminated_at_s <= s.now_offset_s; - const projStartT = s.now_offset_s; - let projEndT = isTerminated ? emb.terminated_at_s : s.horizon_s; - let projEndsAtBudget = false; - if (!isTerminated) { - if (emb.projected_end_s) projEndT = Math.min(projEndT, emb.projected_end_s); - if (emb.dose_exhaust_at_s && emb.dose_exhaust_at_s < projEndT) { - projEndT = emb.dose_exhaust_at_s; - projEndsAtBudget = true; - } - const xProjStart = xForT(projStartT); - const xProjEnd = xForT(projEndT); - if (xProjEnd > xProjStart) { - g.appendChild(svgEl('line', { - x1: xProjStart, y1: laneMid, x2: xProjEnd, y2: laneMid, - class: projEndsAtBudget - ? 'expov-svg-projected-bar warn' - : 'expov-svg-projected-bar' - })); - } - } - // Terminated cap: small vertical stop bar at the termination - // point + a "■ DONE · T##" label below the lane so a finished - // embryo doesn't look like it's still acquiring. - if (isTerminated) { - const termX = xForT(emb.terminated_at_s); - g.appendChild(svgEl('line', { - x1: termX, x2: termX, - y1: laneY - 2, y2: laneBottom + 2, - class: 'expov-svg-terminated-bar' - })); - g.appendChild(svgEl('rect', { - x: termX - 2, y: laneY + LANE_H / 2 - 3, - width: 6, height: 6, - class: 'expov-svg-terminated-stop' - })); - const tp = tpIndexAt(emb.terminated_at_s); - const capText = `DONE · T${tp}`; - g.appendChild(svgEl('text', { - x: termX + 6, y: laneBottom + 9, - class: 'expov-svg-terminated-label' - }, capText)); - } - - // Trigger diamonds — placed in the upper half of the lane to avoid - // colliding with the burst balloon above. Each diamond gets a tiny - // T# label below it so the user can see at which timepoint the - // rule fired without hovering. - (emb.trigger_events || []).forEach(te => { - const x = xForT(te.at); - const dy = laneY + 6; - const size = 4; - const trig = s.triggers.find(t => t.id === te.trigger_id); - const label = trig ? trig.label : te.trigger_id; - const dia = svgEl('polygon', { - points: `${x},${dy-size} ${x+size},${dy} ${x},${dy+size} ${x-size},${dy}`, - class: 'expov-svg-trigger-diamond expov-svg-tooltip-target' - }); - const tooltip = svgEl('title'); - tooltip.textContent = `${label}\n${trig?.when_text || ''} → ${trig?.then_text || ''}` + - (te.count ? ` (×${te.count})` : ''); - dia.appendChild(tooltip); - g.appendChild(dia); - g.appendChild(svgEl('text', { - x: x, y: laneBottom + 9, - 'text-anchor': 'middle', - class: 'expov-svg-trigger-tp' - }, `T${tpIndexAt(te.at)}`)); - }); - - // Dose-exhaust warning: ⚠ + time-to-exhaust positioned ABOVE the lane - // so it doesn't overlap with the dashed projected bar - if (emb.dose_exhaust_at_s && emb.dose_exhaust_at_s < s.horizon_s) { - const exhX = xForT(emb.dose_exhaust_at_s); - const remain = emb.dose_exhaust_at_s - s.now_offset_s; - const rh = Math.floor(remain / 3600); - const rm = Math.floor((remain % 3600) / 60); - const exhText = `⚠ budget exhausts in ${rh > 0 ? rh + 'h ' : ''}${rm}m`; - g.appendChild(svgEl('text', { - x: exhX - 4, y: laneY - 5, - 'text-anchor': 'end', - fill: 'var(--accent-orange)', - 'font-size': 10, - 'font-weight': 600, - 'font-family': "'JetBrains Mono', monospace" - }, exhText)); - // Small dotted vertical marker so the user can see WHERE on the lane - g.appendChild(svgEl('line', { - x1: exhX, x2: exhX, y1: laneY, y2: laneY + LANE_H, - stroke: 'var(--accent-orange)', - 'stroke-width': 1.5, - 'stroke-dasharray': '2 2', - opacity: 0.7 - })); - } - // Open-ended ∞ glyph at right edge - if (emb.stop_kind === 'open_ended') { - g.appendChild(svgEl('text', { - x: LEFT + LANE_W + 8, y: laneMid + 5, - class: 'expov-svg-infinity' - }, '∞')); - } else { - g.appendChild(svgEl('text', { - x: xForT(projEndT) + 6, y: laneMid + 4, - class: 'expov-svg-stop-icon expov-svg-stop-hatch' - }, '■')); - } - - // ---- Power strip --------------------------------------------- - // Visual encoding makes "steady" vs "ramping" obvious: - // • Steady segments = thin grey horizontal line - // • Ramping segments = bright cyan line + filled area + dot at each step - // • Step transitions get a small arrow (↓ or ↑) and a delta tag - const powerY = laneY + LANE_H + 6; - const powerH = POWER_H; - const powerYBase = powerY + powerH; - g.appendChild(svgEl('line', { - x1: LEFT, x2: LEFT + LANE_W, y1: powerYBase, y2: powerYBase, - class: 'expov-svg-power-baseline' - })); - const yForPct = (pct) => powerYBase - (pct / 10) * powerH; - - const hist = emb.power_history_488 || []; - if (hist.length > 1) { - // Detect ramp clusters: consecutive change-steps with x-spacing < - // CLUSTER_PX get grouped, annotated once at the cluster end. - const CLUSTER_PX = 20; - const stepEvents = []; // each: {fromIdx, toIdx, isRamp} - for (let k = 0; k < hist.length - 1; k++) { - if (hist[k].pct !== hist[k+1].pct) { - stepEvents.push({ fromIdx: k, toIdx: k+1 }); - } - } - // Group consecutive close steps into clusters - const clusters = []; - stepEvents.forEach(step => { - const last = clusters[clusters.length - 1]; - const stepX = xForT(hist[step.toIdx].at); - if (last) { - const lastX = xForT(hist[last[last.length-1].toIdx].at); - if (Math.abs(stepX - lastX) < CLUSTER_PX) { - last.push(step); - return; - } - } - clusters.push([step]); - }); - - // Draw horizontal "steady" segments + vertical "step" lines for all - // adjacent (hist[k], hist[k+1]) pairs. - for (let k = 0; k < hist.length - 1; k++) { - const x0 = xForT(hist[k].at); - const x1 = xForT(hist[k+1].at); - const y = yForPct(hist[k].pct); - const yNext = yForPct(hist[k+1].pct); - g.appendChild(svgEl('line', { - x1: x0, x2: x1, y1: y, y2: y, - class: 'expov-svg-power-steady' - })); - if (hist[k].pct !== hist[k+1].pct) { - g.appendChild(svgEl('line', { - x1: x1, x2: x1, y1: y, y2: yNext, - class: 'expov-svg-power-step' - })); - g.appendChild(svgEl('circle', { - cx: x1, cy: yNext, r: 2.2, - class: 'expov-svg-power-stepdot' - })); - } - } - // Final tail to lane right edge - const last = hist[hist.length - 1]; - const lastX = xForT(last.at); - const lastY = yForPct(last.pct); - g.appendChild(svgEl('line', { - x1: lastX, x2: LEFT + LANE_W, y1: lastY, y2: lastY, - class: 'expov-svg-power-steady' - })); - - // One annotation per cluster — bracket + "5% → 3%" label - clusters.forEach(cluster => { - const first = cluster[0]; - const tail = cluster[cluster.length - 1]; - const xStart = xForT(hist[first.fromIdx].at); - const xEnd = xForT(hist[tail.toIdx].at); - const pctStart = hist[first.fromIdx].pct; - const pctEnd = hist[tail.toIdx].pct; - const arrow = pctEnd < pctStart ? '↓' : '↑'; - const yMid = (yForPct(pctStart) + yForPct(pctEnd)) / 2; - // Bracket: small horizontal line above the cluster steps - const bracketY = Math.min(yForPct(pctStart), yForPct(pctEnd)) - 6; - g.appendChild(svgEl('path', { - d: `M ${xStart} ${bracketY+3} L ${xStart} ${bracketY} L ${xEnd+2} ${bracketY} L ${xEnd+2} ${bracketY+3}`, - class: 'expov-svg-power-ramp-bracket' - })); - // Label "488 ↓ 5%→3%" anchored just right of the bracket end - g.appendChild(svgEl('text', { - x: xEnd + 6, y: bracketY + 4, - class: 'expov-svg-power-ramp-label' - }, `${arrow} ${pctStart}%→${pctEnd}%`)); - }); - - // Subtle filled area under the curve — helps read overall level - const areaPts = [`${xForT(hist[0].at)},${powerYBase}`]; - for (let k = 0; k < hist.length; k++) { - const x = xForT(hist[k].at); - const y = yForPct(hist[k].pct); - areaPts.push(`${x},${y}`); - if (k < hist.length - 1) { - const xNext = xForT(hist[k+1].at); - areaPts.push(`${xNext},${y}`); - } - } - areaPts.push(`${LEFT + LANE_W},${yForPct(last.pct)}`); - areaPts.push(`${LEFT + LANE_W},${powerYBase}`); - g.appendChild(svgEl('polygon', { - points: areaPts.join(' '), - class: 'expov-svg-power-area' - })); - } - // Power label with current value — "@" reads as "at this power" - // and avoids confusion with the bullet-separator used elsewhere - g.appendChild(svgEl('text', { - x: LEFT - 8, y: powerY + powerH / 2 + 3, - 'text-anchor': 'end', - class: 'expov-svg-sublabel' - }, `488 @ ${emb.laser_488_pct_now}%`)); - - // ---- Dose gauge ---------------------------------------------- - const doseY = powerYBase + 6; - const doseW = LANE_W; - g.appendChild(svgEl('rect', { - x: LEFT, y: doseY, width: doseW, height: DOSE_H, rx: 2, - class: 'expov-svg-dose-track' - })); - const dosePct = emb.dose_used_ms / emb.dose_budget_ms; - const fillCls = dosePct > 0.85 ? 'expov-svg-dose-fill-crit' - : dosePct > 0.60 ? 'expov-svg-dose-fill-warn' - : 'expov-svg-dose-fill-ok'; - g.appendChild(svgEl('rect', { - x: LEFT, y: doseY, width: Math.max(1, doseW * dosePct), height: DOSE_H, rx: 2, - class: fillCls - })); - // Dose label (shows 10× hint for calibration role) - const doseLabel = emb.role === 'calibration' ? 'dose (10×)' : 'dose'; - g.appendChild(svgEl('text', { - x: LEFT - 8, y: doseY + DOSE_H - 2, - 'text-anchor': 'end', - class: 'expov-svg-sublabel' - }, doseLabel)); - // Inside the bar: usage figure — "used of budget" is more scannable - // than "x / y s" which reads like a fraction - const usedS = (emb.dose_used_ms / 1000).toFixed(1); - const budgetS = (emb.dose_budget_ms / 1000).toFixed(1); - const doseText = emb.dose_budget_ms > 0 - ? `${usedS}s of ${budgetS}s (${Math.round(dosePct * 100)}%)` - : `${usedS}s used`; - g.appendChild(svgEl('text', { - x: LEFT + 6, y: doseY + DOSE_H - 2, - class: 'expov-svg-dose-text' - }, doseText)); - - return g; - }, // ================================================================= // Operation Spine — data-driven plan renderer (replaces swimlanes) @@ -1261,7 +358,7 @@ const ExperimentOverview = { // Render a single tactic node. _renderOpsTactic(t, idx, firstPlannedIdx, ESC) { - const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued' }; + const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued', paused: 'paused' }; const seq = String(t.seq || idx + 1).padStart(2, '0'); const stateLabel = STATE_LABEL[t.state] || t.state; // First queued tactic gets a "next" badge — COCKED instrument marker. @@ -1300,6 +397,23 @@ const ExperimentOverview = { inner += this._renderOpsKindPlanned(t, ESC); } + // Fix #1: surface flat live.* telemetry keys not covered by structured + // readouts/phases. Render for active (in-progress telemetry) and done + // (completion data such as sustained_hz, mp4_path, last_fired). + // Skip planned — no live data is bound yet. + if (t.state === 'active' || t.state === 'done') { + const SKIP = new Set(['readouts', 'phases', 'target', 'summary', 'desc']); + const flatEntries = Object.entries(live).filter(([k]) => !SKIP.has(k)); + if (flatEntries.length) { + const humanKey = k => k.replace(/_/g, ' '); + const pairs = flatEntries.map(([k, v]) => { + const vStr = v == null ? '—' : String(v); + return `${ESC(humanKey(k))}${ESC(vStr)}`; + }).join(''); + inner += `
${pairs}
`; + } + } + return `
${seq} · ${stateLabel}${nextBadge ? ' ' + nextBadge : ''}
From b98a041fc4560b5366aea39c89e8c02ed00df0aa Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:38:43 +0530 Subject: [PATCH 222/314] chore: untrack SDD scratch doc accidentally committed --- .superpowers/sdd/dv3-whole-branch-fixes.md | 109 --------------------- 1 file changed, 109 deletions(-) delete mode 100644 .superpowers/sdd/dv3-whole-branch-fixes.md diff --git a/.superpowers/sdd/dv3-whole-branch-fixes.md b/.superpowers/sdd/dv3-whole-branch-fixes.md deleted file mode 100644 index 5ec58d80..00000000 --- a/.superpowers/sdd/dv3-whole-branch-fixes.md +++ /dev/null @@ -1,109 +0,0 @@ -# dv3 whole-branch review fixes — backend half - -Agent: backend-fixes agent (this session). -Commit: see footer. -Frontend fixes (experiment-overview.js + css): separate agent, not touched here. - ---- - -## Fix #2 — agent prompt guidance to declare the Operation Plan - -**File:** `gently/harness/prompts/templates.py` - -Added constant `OPERATION_PLAN_GUIDANCE` (~line 225) and injected `{OPERATION_PLAN_GUIDANCE}` -into the system template f-string right after `{REACTIVE_MONITORING_MODES}` (~line 510). -The block tells the agent to call `declare_operation_plan` at experiment planning time -with kind/name/target/scope/rationale + a `live` object (`readouts`/`phases` + flat keys), -and to re-call (patch) on each tactic transition. Also notes that execution tools -(`queue_burst`, `enable_monitoring_mode`, etc.) accept `tactic_id` and flip state automatically. - ---- - -## Fix #3 — wire operation-plan seeding in exit_plan_mode - -**File:** `gently/app/agent.py`, `exit_plan_mode()` (~line 475) - -Hook site confirmed: inside `if active_id:` block, right after the `link_session_campaign` -try/except (line ~464-469). At that point `self.context_store` and `self.session_id` are -guaranteed valid (context_store is used unguarded at `get_plan_item` one line above). - -Added: -```python -try: - 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") -``` - ---- - -## Fix #4 — queue_burst phantom-active on soft-reject - -**File:** `gently/app/tools/timelapse_tools.py` (~line 1204) - -`orchestrator.queue_burst` returns `"Burst queued for {embryo_id} (request_id=..., ...)"` on -success and a rejection sentence (embryo not in timelapse / already had burst / already queued) -on soft-reject. Success is reliably distinguishable by `result.startswith("Burst queued for ")`. - -Guard applied: -```python -if tactic_id and isinstance(result, str) and result.startswith("Burst queued for "): - cs.transition_tactic(session, tactic_id, "active") -``` - -New test added to `tests/test_tool_tactic_linkage.py`: -`test_queue_burst_soft_reject_does_not_transition` — fake orchestrator returns -`"Embryo 'emb1' already has a queued burst."` → verifies `cs.transitions == []`. - ---- - -## Fix #5 — `paused` valid state - -**File:** `gently/app/tools/operation_plan_tools.py`, line 29 - -`_VALID_STATES` extended: `frozenset({"planned", "active", "done", "paused"})`. -`pause_timelapse` transitions to `"paused"` — this was previously an invalid state -causing a ValueError on validation. - ---- - -## Fix #7 — document `live` in the spec - -**File:** `docs/superpowers/specs/2026-06-28-operations-tab-design.md`, §1 - -Added `live` field to the tactic schema block with `readouts`/`phases` sub-keys and -a documentation paragraph explaining that the agent authors `readouts`/`phases` at -declaration time and the updater merges flat bound keys (`request_id`, `sustained_hz`, -`setpoint`, `locked`, `last_fired`, `new_phase`, …) as telemetry arrives. - ---- - -## Fix #8 — transition_tactic single-loop note - -**File:** `gently/harness/memory/file_store.py`, `transition_tactic` docstring (~line 847) - -Added one-line note: "read-modify-write with no lock; safe because all subscribed event -emissions run on the single asyncio loop thread — revisit if a worker-thread emitter -is ever added." - ---- - -## Test results - -``` -pytest tests/test_operation_plan_tool.py tests/test_tool_tactic_linkage.py \ - tests/test_operation_plan_seeding.py tests/test_operation_plan_store.py -v -65 passed, 0 failed -``` - -Full suite (excluding 3 pre-existing collection errors in test_campaign_coordination.py, -test_gently_store.py, test_text_tool_call_extraction.py): -- Before changes: 32 failed, 673 passed, 19 skipped -- After changes: 32 failed, 674 passed, 19 skipped (+1 new test, 0 new failures) - ---- - -## Commit - -`fix(operations): wire seeding + agent plan guidance + queue_burst guard + paused state + docs` From 3a2bda6e28ec17f95de6f0279dfe0f9835d7e4f8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:39:20 +0530 Subject: [PATCH 223/314] chore: gitignore .superpowers SDD scratch (untrack accidentally-committed reports) --- .gitignore | 1 + .superpowers/sdd/dv3-task-7-report.md | 85 --------------------------- 2 files changed, 1 insertion(+), 85 deletions(-) delete mode 100644 .superpowers/sdd/dv3-task-7-report.md diff --git a/.gitignore b/.gitignore index f9a50443..34a7c3b7 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,4 @@ gently/ui/tui/dist/ # Stray local storage: GENTLY_STORAGE_PATH default (D:\Gently3) resolves # literally to ./D:/ under the repo on Linux. Not data we track. /D:/ +.superpowers/ diff --git a/.superpowers/sdd/dv3-task-7-report.md b/.superpowers/sdd/dv3-task-7-report.md deleted file mode 100644 index 033112fe..00000000 --- a/.superpowers/sdd/dv3-task-7-report.md +++ /dev/null @@ -1,85 +0,0 @@ -# Task 7 Report: tactic_id threading + start-edge marking in execution tools - -## Call sites changed - -### `gently/app/orchestration/exclusive.py` - -**`BurstAcquisition.__init__`** (line ~95) -- Added `tactic_id: str | None = None` kwarg; stored as `self._tactic_id`. -- `BURST_START` event data (line ~127): added `"tactic_id": self._tactic_id`. -- `BURST_COMPLETE` event data (line ~229): added `"tactic_id": self._tactic_id`. - -### `gently/app/orchestration/temperature_protocol.py` - -**`run_temp_change_burst_protocol`** signature (line 22) -- Added `tactic_id=None` to the keyword-only params. -- `TEMP_PROTOCOL_STARTED` event data (line ~41): added `"tactic_id": tactic_id`. -- `TEMP_PROTOCOL_COMPLETED` event data (line ~67): added `"tactic_id": tactic_id`. - -### `gently/app/orchestration/timelapse.py` - -**`queue_burst`** method (line ~2056) -- Added `tactic_id: str | None = None` to signature. -- Passes `tactic_id=tactic_id` into `BurstAcquisition(...)` constructor (line ~2098). - -### `gently/app/tools/timelapse_tools.py` - -**`enable_monitoring_mode`** (line ~1025) -- Added `tactic_id: str | None = None`. -- On success: resolves `cs = getattr(agent, "context_store", None)`, `session = getattr(agent, "session_id", None)`; calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). - -**`queue_burst`** tool (line ~1150) -- Added `tactic_id: str | None = None`. -- Passes `tactic_id=tactic_id` to `orchestrator.queue_burst(...)`. -- After successful enqueue: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). - -**`stop_timelapse`** (line ~270) -- Added `tactic_id: str | None = None`. -- After successful stop: calls `cs.transition_tactic(session, tactic_id, "done")` (guarded). - -**`pause_timelapse`** (line ~293) -- Added `tactic_id: str | None = None`. -- After successful pause: calls `cs.transition_tactic(session, tactic_id, "paused")` (guarded). - -### `gently/app/tools/temperature_protocol_tools.py` - -**`run_temp_change_burst_protocol_tool`** (line 40) -- Added `tactic_id: str | None = None`. -- Before `asyncio.create_task`: calls `cs.transition_tactic(session, tactic_id, "active")` (guarded). -- Passes `tactic_id=tactic_id` into `_driver(...)` (the `run_temp_change_burst_protocol` coroutine). - -## How the transition + event threading work - -1. **Start edge (tool → store)**: Tool layer resolves the context store via `getattr(agent, "context_store", None)` and session via `getattr(agent, "session_id", None)` — the same pattern as `declare_operation_plan`. If both are present and `tactic_id` is truthy, `transition_tactic` flips the plan tactic to `"active"`. This happens synchronously (before the background driver spawns for the temp protocol, after enqueue for bursts). - -2. **Event threading (orchestration → bus)**: The `tactic_id` is included in `BURST_START`, `BURST_COMPLETE`, `TEMP_PROTOCOL_STARTED`, and `TEMP_PROTOCOL_COMPLETED` event `data` dicts. Task 8's `OperationPlanUpdater` subscribes to these events and can resolve the tactic by `data["tactic_id"]` to flip it to `"done"`. - -3. **Backward compatibility**: All `tactic_id` params default to `None`. Every transition call is guarded: `if tactic_id: cs = ...; if cs and session: cs.transition_tactic(...)`. When `tactic_id` is absent, no store access occurs and the tool behaves identically to before. - -## TDD evidence - -`tests/test_tool_tactic_linkage.py` — 20 tests, all pass. - -Coverage: -- `enable_monitoring_mode` with/without tactic_id (+ no-cs guard) -- `queue_burst` (tool) with/without tactic_id; tactic_id forwarded to orchestrator -- `stop_timelapse` with/without tactic_id -- `pause_timelapse` with/without tactic_id -- `BurstAcquisition._tactic_id` stored; `BURST_START` and `BURST_COMPLETE` events contain `tactic_id` -- `run_temp_change_burst_protocol_tool` with/without tactic_id -- `TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` carry tactic_id (including `None` backward-compat) - -Full suite (excluding 3 pre-existing broken collection targets): **638 passed, 19 skipped** — same count as before Task 7; no new failures. - -## Files changed - -- `gently/app/orchestration/exclusive.py` -- `gently/app/orchestration/temperature_protocol.py` -- `gently/app/orchestration/timelapse.py` -- `gently/app/tools/timelapse_tools.py` -- `gently/app/tools/temperature_protocol_tools.py` -- `tests/test_tool_tactic_linkage.py` (new) - -## Concerns - -None. All changes are purely additive (new optional kwarg, guarded call). The `@tool` decorator wraps sync functions as async coroutines — tests call them with keyword args and `await`; microscope-required tools need a `"client"` in the test context dict (FakeMicroscope provided). From 286e95e99acd9f471ff166acfa50d54ef5ee8f65 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:42:57 +0530 Subject: [PATCH 224/314] =?UTF-8?q?docs(tactics-library):=20spec=20+=20pla?= =?UTF-8?q?n=20(G,=203=20tasks)=20=E2=80=94=20save/reuse=20typed=20tactics?= =?UTF-8?q?=20on=20D's=20substrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-28-tactics-library.md | 35 ++++++++++++ .../2026-06-28-tactics-library-design.md | 57 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-tactics-library.md create mode 100644 docs/superpowers/specs/2026-06-28-tactics-library-design.md diff --git a/docs/superpowers/plans/2026-06-28-tactics-library.md b/docs/superpowers/plans/2026-06-28-tactics-library.md new file mode 100644 index 00000000..a845bfa8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-tactics-library.md @@ -0,0 +1,35 @@ +# Tactics Library (G) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Save / list / apply typed tactics (D's tactic objects) as reusable templates — a near-copy of the plan-template pattern. + +**Architecture:** A tactic-library domain in `FileContextStore` (save/list/get/apply), agent tools, and a read route — mirroring `save_plan_template`/`apply_plan_template`. + +## Global Constraints +- Mirror the plan-template pattern: `_plans.py:551-667` (`save_plan_template`/`list_plan_templates`/`get_plan_template`/`apply_plan_template`) + `plan_mode/tools/templates.py`. +- A saved tactic is a TEMPLATE (planned form): `{id,name,slug,kind,structure,scope_hint?,description,params?,created_at,created_by}` — NO live state. `apply_tactic` returns a fresh planned tactic (new run id, state="planned", no `live`). +- Store domain `agent/tactic_library/{id}_{slug}.yaml`; fire `CONTEXT_UPDATED`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Tactic-library store domain +**Files:** Modify `gently/harness/memory/file_store.py` (or `_plans.py` if templates live there) — add `save_tactic(tactic, name=None) -> str`, `list_tactics() -> list[dict]`, `get_tactic(id_or_name) -> dict | None`, `apply_tactic(id_or_name) -> dict | None`. Test: `tests/test_tactic_library_store.py`. +- [ ] Confirm the exact plan-template implementation (`_plans.py:551-667`) — the id/slug generation, the YAML write helper, the `_notify`/`CONTEXT_UPDATED` emit — and mirror it for tactics at `agent/tactic_library/`. `apply_tactic` returns a deep copy with a fresh `id` (e.g. `tac_<8hex>`), `state="planned"`, and `live`/run-state stripped. +- [ ] TDD: save a tactic → list shows it → get returns it → apply returns a fresh planned tactic (new id, no live, state=planned); get/apply unknown → None. `pytest tests/test_tactic_library_store.py -v`; `pytest -q` clean. Commit `feat(tactics-library): tactic-library store domain (save/list/get/apply)`. + +### Task 2: Agent tools +**Files:** Create `gently/app/tools/tactic_library_tools.py` (+ register in `tools/__init__`). Test: `tests/test_tactic_library_tools.py`. +- [ ] Mirror `plan_mode/tools/templates.py` (the `@tool` usage + store resolution from context). Tools: `save_tactic(name, tactic, description="")` → `store.save_tactic`; `list_tactics()` → `store.list_tactics`; `apply_tactic(id_or_name)` → `store.apply_tactic` then append the planned tactic to the current Operation Plan (`get_operation_plan(session)` → append → `set_operation_plan`; create a minimal plan if none). Resolve the context store + session from the agent (as `declare_operation_plan` does). Register the module. +- [ ] TDD (fake context store + session): save persists; apply adds a planned tactic to the current Operation Plan; list returns the library; missing store → error. `pytest tests/test_tactic_library_tools.py -v`; `pytest -q` clean. Commit `feat(tactics-library): save/list/apply_tactic agent tools`. + +### Task 3: Route `GET /api/tactic_library` +**Files:** Create `gently/ui/web/routes/tactic_library.py` (+ register in `routes/__init__.py`). Test: `tests/test_tactic_library_route.py`. +- [ ] Mirror `routes/operation_plan.py` — resolve `server.context_store`, return `{tactics: store.list_tactics()}`; empty list when none. Register the router. +- [ ] TDD (TestClient + mock store): returns the library; empty when none. `pytest tests/test_tactic_library_route.py -v`; `pytest -q` clean. Commit `feat(tactics-library): GET /api/tactic_library route`. + +## Self-Review +- Store→Task1; tools→Task2; route→Task3. ✓ +- Open confirmations: the plan-template implementation to mirror (T1), the `@tool` + store/session resolution (T2), the route store handle (T3). +- Type consistency: the saved-tactic dict shape is identical across store (T1), tools (T2), route (T3); `apply_tactic`'s fresh planned tactic matches D's tactic schema. diff --git a/docs/superpowers/specs/2026-06-28-tactics-library-design.md b/docs/superpowers/specs/2026-06-28-tactics-library-design.md new file mode 100644 index 00000000..3bd2ac37 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-tactics-library-design.md @@ -0,0 +1,57 @@ +# Design: Tactics library (sub-project G) + +Status: design decided 2026-06-28. Base branch: off `feature/operations-tab` (D) — G reuses D's +typed tactic objects (the Operation Plan substrate). Mirrors the existing **plan-template** pattern +(`_plans.py:551-667`, `plan_mode/tools/templates.py`). + +## 0. Why G is now small + +D made tactics **typed, agent-authored objects** (kind/structure/scope/live/relations). So a "tactics +library" is just **save / list / apply those typed tactic objects** — a near-copy of how plan +templates are already saved/applied. No new conceptual model; it's the reuse layer over D. + +## 1. The saved tactic + +``` +saved_tactic = { + id, name, slug, kind, # kind ∈ D's tactic kinds + structure, # the tactic's structure (phases/cadence/watch) sans live state + scope_hint?, # default scope (global/embryos) + description, params?, # human description + default params + created_at, created_by # 'agent' | 'human' +} +``` +Stored at `agent/tactic_library/{id}_{slug}.yaml` (a new FileContextStore domain), mirroring the +plan-template store. A saved tactic is a *template* — the planned form of a tactic, with no live +state (no `live`, no `id`-per-run). + +## 2. Architecture (three units, mirroring plan templates) + +### 2.1 Store — `FileContextStore` +Add `save_tactic(tactic, name=None) -> id`, `list_tactics() -> list[dict]`, `get_tactic(id_or_name)`, +`apply_tactic(id_or_name) -> dict` (returns a fresh planned tactic — new run id, `state:"planned"`, +no `live`). Mirror `save_plan_template`/`list_plan_templates`/`get_plan_template`/`apply_plan_template` +(`_plans.py:551-667`) — same id/slug, YAML, atomic write. Fire `CONTEXT_UPDATED`. + +### 2.2 Tools — `gently/app/tools/tactic_library_tools.py` +- `save_tactic(name, tactic, description="")` — persist a tactic template (the agent passes a tactic + dict, e.g. lifted from the current Operation Plan, or authored fresh). +- `list_tactics()` — the library. +- `apply_tactic(id_or_name)` — instantiate a fresh planned tactic and **add it to the current + Operation Plan** (`get_operation_plan` → append the planned tactic → `set_operation_plan`), so a + saved tactic flows straight into the active plan. Mirror `plan_mode/tools/templates.py`. + +### 2.3 Route — `GET /api/tactic_library` +Mirror `routes/operation_plan.py`; returns `{tactics:[...]}` from `list_tactics()`. For a future +browse UI; the tools are the primary surface for now. + +## 3. Out of scope +- A dedicated library browse/edit UI (the tools + chat suffice for v1; a panel is a follow-on). +- Tactic *sets*/composites (save a whole plan as a named operation template) — a natural extension + once single-tactic save/apply lands; defer. +- Sharing/export across machines. + +## 4. Testing +- Store: save→list→get→apply round-trip; apply returns a fresh planned tactic (new id, no live state). +- Tools: save persists; apply adds a planned tactic to the current Operation Plan; list returns the library. +- Route: returns the library; empty when none. From 126de2c3e1cee64308c984f20fc1cc485a9e8dd4 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:48:21 +0530 Subject: [PATCH 225/314] feat(tactics-library): tactic-library store domain (save/list/get/apply) Add save_tactic/list_tactics/get_tactic/apply_tactic to FileContextStore, mirroring the plan-template pattern. Templates live at agent/tactic_library/{id}_{slug}.yaml; apply returns a fresh planned tactic (new id, state=planned, no live). Fires CONTEXT_UPDATED. 33 TDD tests, all passing. --- gently/harness/memory/file_store.py | 88 ++++++++++ tests/test_tactic_library_store.py | 253 ++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 tests/test_tactic_library_store.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 7a9eba4e..eab691fc 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1793,6 +1793,94 @@ def delete_plan_template(self, template_id: str) -> bool: return True return False + # ================================================================== + # Tactic Library + # ================================================================== + + def save_tactic(self, tactic: dict, name: str | None = None) -> str: + """Persist a tactic as a reusable template in agent/tactic_library/. + + Strips runtime state (live, state, original id) and assigns a new + template id + slug. Fires CONTEXT_UPDATED. Returns the template id. + """ + name = name or tactic.get("name") or "unnamed" + slug = self._slugify(name) + tid = self._gen_id() + now = self._now() + + template = { + "id": tid, + "name": name, + "slug": slug, + "kind": tactic.get("kind", "unknown"), + "structure": copy.deepcopy(tactic.get("structure") or {}), + "scope_hint": copy.deepcopy(tactic.get("scope")), + "description": tactic.get("description") or tactic.get("rationale"), + "params": copy.deepcopy(tactic.get("params")), + "relations": copy.deepcopy(tactic.get("relations") or {}), + "live_bind": list(tactic.get("live_bind") or []), + "created_at": now, + "created_by": tactic.get("created_by", "agent"), + } + + path = self.agent_dir / "tactic_library" / f"{tid}_{slug}.yaml" + self._write_yaml(path, template) + self._notify_context_change("tactic_library") + logger.info(f"Saved tactic template '{name}' ({tid})") + return tid + + def list_tactics(self) -> list[dict]: + """List all saved tactic templates, ordered by created_at descending.""" + tl_dir = self.agent_dir / "tactic_library" + if not tl_dir.exists(): + return [] + tactics: list[dict] = [] + for f in tl_dir.iterdir(): + if f.suffix in (".yaml", ".yml"): + data = self._read_yaml(f) + if data: + tactics.append(data) + tactics.sort(key=lambda t: t.get("created_at", ""), reverse=True) + return tactics + + def get_tactic(self, id_or_name: str) -> dict | None: + """Return a tactic template by id or name, or None if not found.""" + tl_dir = self.agent_dir / "tactic_library" + if not tl_dir.exists(): + return None + for f in tl_dir.iterdir(): + if f.suffix in (".yaml", ".yml"): + data = self._read_yaml(f) + if data and (data.get("id") == id_or_name or data.get("name") == id_or_name): + return data + return None + + def apply_tactic(self, id_or_name: str) -> dict | None: + """Return a fresh planned tactic from a saved template. + + The returned dict has a new run id (distinct from the template id), + state="planned", and no live/runtime state. Returns None if the + template is not found. + """ + tmpl = self.get_tactic(id_or_name) + if tmpl is None: + return None + tactic = copy.deepcopy(tmpl) + # Assign a new run id — must differ from the template id + tactic["id"] = self._gen_id() + tactic["state"] = "planned" + # Promote scope_hint back to scope for the tactic + scope_hint = tactic.pop("scope_hint", None) + if scope_hint is not None: + tactic["scope"] = scope_hint + # Strip template-internal metadata + tactic.pop("slug", None) + tactic.pop("created_at", None) + tactic.pop("created_by", None) + # Strip runtime state (should not exist in a template, but guard anyway) + tactic.pop("live", None) + return tactic + # ================================================================== # Plan Snapshots # ================================================================== diff --git a/tests/test_tactic_library_store.py b/tests/test_tactic_library_store.py new file mode 100644 index 00000000..4a92acc5 --- /dev/null +++ b/tests/test_tactic_library_store.py @@ -0,0 +1,253 @@ +""" +FileContextStore — Tactic Library store domain TDD tests. + +Tests: save_tactic / list_tactics / get_tactic / apply_tactic + +Round-trip: save → list → get → apply returns a fresh planned tactic +(new id != template id, state="planned", no "live" key). +get/apply unknown → None. +""" + +import copy +import pytest + + +# --------------------------------------------------------------------------- +# Shared tactic fixture +# --------------------------------------------------------------------------- + +_TACTIC = { + "id": "t_original", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Establish pre-ramp cadence", + "structure": {"cadence_s": 120}, + "live_bind": ["cadence"], + "relations": {}, + "live": {"request_id": "req_abc", "started_at": "2026-06-28T10:00:00"}, +} + + +def _fresh_tactic(): + return copy.deepcopy(_TACTIC) + + +# --------------------------------------------------------------------------- +# save_tactic +# --------------------------------------------------------------------------- + + +class TestSaveTactic: + def test_returns_string_id(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + assert isinstance(tid, str) + assert len(tid) > 0 + + def test_id_differs_from_original(self, file_context_store): + """Template id must not equal the source tactic's original id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + assert tid != "t_original" + + def test_name_param_overrides_tactic_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic(), name="Custom Name") + tmpl = file_context_store.get_tactic(tid) + assert tmpl["name"] == "Custom Name" + + def test_name_falls_back_to_tactic_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["name"] == "Baseline timelapse" + + def test_live_state_stripped(self, file_context_store): + """The 'live' key must not appear in the saved template.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert "live" not in tmpl + + def test_run_state_not_preserved(self, file_context_store): + """'state' from the source tactic (active) is not stored as the template state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + # Template itself doesn't carry 'state'; it's restored on apply + assert tmpl.get("state") is None or tmpl.get("state") == "planned" + + def test_structure_preserved(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["structure"] == {"cadence_s": 120} + + def test_kind_preserved(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl["kind"] == "standing_timelapse" + + def test_file_created(self, file_context_store): + """A YAML file must exist in tactic_library/ after saving.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tl_dir = file_context_store.agent_dir / "tactic_library" + files = list(tl_dir.iterdir()) + assert any(f.stem.startswith(tid) for f in files) + + def test_fires_context_updated(self, file_context_store): + """save_tactic must fire CONTEXT_UPDATED.""" + from gently.core.event_bus import EventType, on + + seen = [] + unsub = on(EventType.CONTEXT_UPDATED, lambda e: seen.append(e)) + try: + file_context_store.save_tactic(_fresh_tactic()) + finally: + unsub() + assert len(seen) >= 1 + + +# --------------------------------------------------------------------------- +# list_tactics +# --------------------------------------------------------------------------- + + +class TestListTactics: + def test_empty_when_none_saved(self, file_context_store): + assert file_context_store.list_tactics() == [] + + def test_one_entry_after_save(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.list_tactics() + assert len(result) == 1 + + def test_two_entries_after_two_saves(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic(), name="Alpha") + file_context_store.save_tactic(_fresh_tactic(), name="Beta") + result = file_context_store.list_tactics() + assert len(result) == 2 + + def test_entries_contain_id_and_name(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.list_tactics() + assert result[0]["id"] == tid + assert result[0]["name"] == "Baseline timelapse" + + def test_ordered_newest_first(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic(), name="First") + file_context_store.save_tactic(_fresh_tactic(), name="Second") + result = file_context_store.list_tactics() + # Newest should appear first (sorted by created_at desc) + names = [r["name"] for r in result] + assert names[0] == "Second" + + +# --------------------------------------------------------------------------- +# get_tactic +# --------------------------------------------------------------------------- + + +class TestGetTactic: + def test_get_by_id(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic(tid) + assert result is not None + assert result["id"] == tid + + def test_get_by_name(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic("Baseline timelapse") + assert result is not None + assert result["kind"] == "standing_timelapse" + + def test_get_unknown_returns_none(self, file_context_store): + result = file_context_store.get_tactic("no_such_tactic") + assert result is None + + def test_get_unknown_id_returns_none(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.get_tactic("deadbeef") + assert result is None + + +# --------------------------------------------------------------------------- +# apply_tactic +# --------------------------------------------------------------------------- + + +class TestApplyTactic: + def test_apply_by_id_returns_dict(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert isinstance(applied, dict) + + def test_apply_by_name_returns_dict(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic("Baseline timelapse") + assert isinstance(applied, dict) + + def test_apply_unknown_returns_none(self, file_context_store): + result = file_context_store.apply_tactic("no_such_tactic") + assert result is None + + def test_apply_unknown_id_returns_none(self, file_context_store): + file_context_store.save_tactic(_fresh_tactic()) + result = file_context_store.apply_tactic("deadbeef") + assert result is None + + def test_applied_id_differs_from_template_id(self, file_context_store): + """apply_tactic must return a FRESH id, distinct from the template id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["id"] != tid + + def test_applied_id_differs_from_source_tactic_id(self, file_context_store): + """Applied id must not equal the original source tactic's id either.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["id"] != "t_original" + + def test_applied_state_is_planned(self, file_context_store): + """Returned tactic must have state='planned' regardless of template source state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["state"] == "planned" + + def test_applied_has_no_live_key(self, file_context_store): + """Returned tactic must not carry any 'live' runtime state.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "live" not in applied + + def test_applied_structure_matches_template(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["structure"] == {"cadence_s": 120} + + def test_applied_kind_matches_template(self, file_context_store): + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["kind"] == "standing_timelapse" + + def test_applied_is_independent_copy(self, file_context_store): + """Mutating the applied tactic must not affect a second apply call.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + first = file_context_store.apply_tactic(tid) + first["structure"]["cadence_s"] = 999 + second = file_context_store.apply_tactic(tid) + assert second["structure"]["cadence_s"] == 120 + + def test_two_applies_have_different_ids(self, file_context_store): + """Each apply call returns a fresh id.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + a1 = file_context_store.apply_tactic(tid) + a2 = file_context_store.apply_tactic(tid) + assert a1["id"] != a2["id"] + + def test_applied_no_slug_metadata(self, file_context_store): + """Template-internal 'slug' must not appear in the applied tactic.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "slug" not in applied + + def test_applied_no_created_at(self, file_context_store): + """Template 'created_at' must not bleed into the applied tactic.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert "created_at" not in applied From e25798d8c46ed26748e3a42906d13cbad58401ee Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:52:55 +0530 Subject: [PATCH 226/314] feat(tactics-library): save/list/apply_tactic agent tools Three @tool functions (ToolCategory.EXPERIMENT) over FileContextStore's tactic library: save_tactic persists a tactic dict as a reusable template, list_tactics returns a readable id/name/kind summary, apply_tactic instantiates a fresh planned tactic and appends it to the session's Operation Plan (creating a minimal plan if none exists). Store + session resolution mirrors declare_operation_plan. Registered in tools/__init__.py. TDD: 21 tests, all green. --- gently/app/tools/__init__.py | 2 + gently/app/tools/tactic_library_tools.py | 198 ++++++++++++ tests/test_tactic_library_tools.py | 371 +++++++++++++++++++++++ 3 files changed, 571 insertions(+) create mode 100644 gently/app/tools/tactic_library_tools.py create mode 100644 tests/test_tactic_library_tools.py diff --git a/gently/app/tools/__init__.py b/gently/app/tools/__init__.py index b3b31d1b..02db9d7c 100644 --- a/gently/app/tools/__init__.py +++ b/gently/app/tools/__init__.py @@ -43,6 +43,7 @@ resolution_tools, session_tools, stage_tools, + tactic_library_tools, temperature_protocol_tools, temperature_tools, timelapse_tools, @@ -89,6 +90,7 @@ def register_all_tools(): "operation_plan_tools", "plan_execution_tools", "resolution_tools", + "tactic_library_tools", "session_tools", "stage_tools", "temperature_protocol_tools", diff --git a/gently/app/tools/tactic_library_tools.py b/gently/app/tools/tactic_library_tools.py new file mode 100644 index 00000000..96a3eee5 --- /dev/null +++ b/gently/app/tools/tactic_library_tools.py @@ -0,0 +1,198 @@ +""" +Tactic Library tools — lets the agent save, list, and apply reusable tactics. + +save_tactic — persist a tactic dict from the current Operation Plan as a + reusable template in FileContextStore's tactic_library. +list_tactics — return a readable summary of all saved templates. +apply_tactic — instantiate a template into a fresh planned tactic and append + it to the current session's Operation Plan. + +Store + session resolution mirrors declare_operation_plan in operation_plan_tools.py. +""" + +import logging +from datetime import datetime, timezone + +from gently.harness.tools.helpers import require_agent +from gently.harness.tools.registry import ToolCategory, ToolExample, tool + +logger = logging.getLogger(__name__) + + +@tool( + name="save_tactic", + description=( + "Save a tactic from the current Operation Plan into the Tactic Library " + "as a reusable template. Pass the tactic dict (e.g. lifted directly from " + "your Operation Plan) and a human-readable name. Runtime state (live, state, " + "original id) is stripped; the template receives a new id. " + "Returns a confirmation with the new template id." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Save my baseline timelapse tactic to the library", + tool_input={ + "name": "Baseline timelapse", + "tactic": { + "id": "t1", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + }, + "description": "Standard 2-minute cadence baseline timelapse", + }, + ) + ], +) +async def save_tactic( + name: str, + tactic: dict, + description: str = "", + context: dict | None = None, +) -> str: + """Save a tactic as a reusable template in the Tactic Library. + + Parameters + ---------- + name : str + Human-readable name for the template. + tactic : dict + The tactic dict to save (e.g. from the current Operation Plan). + description : str + Optional description; if provided, overrides tactic's rationale field. + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot save tactic" + + # Merge description into tactic so save_tactic picks it up via tactic.get("description") + tactic_to_save = dict(tactic) + if description: + tactic_to_save["description"] = description + + tid = cs.save_tactic(tactic_to_save, name=name) + return f"Tactic '{name}' saved to library with id {tid}" + + +@tool( + name="list_tactics", + description=( + "List all tactics saved in the Tactic Library. " + "Returns a summary of each template: id, name, kind." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Show me the tactics in the library", + tool_input={}, + ) + ], +) +async def list_tactics( + context: dict | None = None, +) -> str: + """List all saved tactic templates. + + Parameters + ---------- + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot list tactics" + + tactics = cs.list_tactics() + if not tactics: + return "Tactic Library is empty — no saved templates yet." + + 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', '?')}") + return "\n".join(lines) + + +@tool( + name="apply_tactic", + description=( + "Instantiate a saved tactic template and append it to the current session's " + "Operation Plan. The template is looked up by id or name, a fresh planned tactic " + "is created (new id, state='planned', no runtime state), and it is queued in the " + "plan. If no plan exists for this session, a minimal one is created. " + "Returns an error if the tactic is not found." + ), + category=ToolCategory.EXPERIMENT, + examples=[ + ToolExample( + user_query="Apply the baseline timelapse tactic to the current session", + tool_input={"id_or_name": "Baseline timelapse"}, + ) + ], +) +async def apply_tactic( + id_or_name: str, + context: dict | None = None, +) -> str: + """Instantiate a tactic template and append it to the current Operation Plan. + + Parameters + ---------- + id_or_name : str + The template id or name to look up. + context : dict + Injected by the tool runtime — do not pass manually. + """ + agent, err = require_agent(context) + if err: + return err + + cs = getattr(agent, "context_store", None) + if cs is None: + return "Error: Context store not available — cannot apply tactic" + + session_id = getattr(agent, "session_id", None) + if not session_id: + return "Error: No active session — cannot apply tactic to Operation Plan" + + fresh = cs.apply_tactic(id_or_name) + if fresh is None: + return f"Error: No tactic found with id or name '{id_or_name}'" + + # Fetch or create the plan for this session + plan = cs.get_operation_plan(session_id) + if plan is None: + plan = { + "session_id": session_id, + "title": "", + "goal": "", + "tactics": [], + } + + plan.setdefault("tactics", []) + plan["tactics"].append(fresh) + plan["updated_at"] = datetime.now(timezone.utc).isoformat() + plan["updated_reason"] = f"tactic '{fresh.get('name', id_or_name)}' applied from library" + + cs.set_operation_plan(session_id, plan) + + tactic_name = fresh.get("name", id_or_name) + return ( + f"Tactic '{tactic_name}' (id {fresh['id']}) instantiated from library and " + f"queued as 'planned' in the Operation Plan for session {session_id}" + ) diff --git a/tests/test_tactic_library_tools.py b/tests/test_tactic_library_tools.py new file mode 100644 index 00000000..e1e33dff --- /dev/null +++ b/tests/test_tactic_library_tools.py @@ -0,0 +1,371 @@ +""" +TDD tests for gently/app/tools/tactic_library_tools.py + +Tests: +- save_tactic: calls store.save_tactic and returns a confirmation with the id +- list_tactics: returns readable summary from store.list_tactics +- apply_tactic: fetches template, appends fresh planned tactic to Operation Plan +- apply_tactic unknown id: returns error, does NOT call set_operation_plan +- missing context_store: returns error for all three tools +- tools are importable and registered in the tool registry +""" + +import copy +import pytest + + +# --------------------------------------------------------------------------- +# Shared tactic fixture +# --------------------------------------------------------------------------- + +_TACTIC = { + "id": "t_original", + "name": "Baseline timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "global"}, + "rationale": "Continuous pre-ramp imaging", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, +} + + +def _fresh_tactic(): + return copy.deepcopy(_TACTIC) + + +# --------------------------------------------------------------------------- +# Fake context store +# --------------------------------------------------------------------------- + + +class FakeContextStore: + """Minimal context store double for tool tests.""" + + def __init__(self): + self._library: dict[str, dict] = {} # id -> template + self._plans: dict[str, dict] = {} # session_id -> plan + self.save_tactic_calls: list = [] + self.set_operation_plan_calls: list = [] + + def save_tactic(self, tactic: dict, name: str | None = None) -> str: + tid = f"tpl_{len(self._library) + 1}" + name = name or tactic.get("name") or "unnamed" + template = { + "id": tid, + "name": name, + "kind": tactic.get("kind", "unknown"), + "description": tactic.get("description") or tactic.get("rationale"), + } + self._library[tid] = template + self.save_tactic_calls.append({"tactic": tactic, "name": name, "returned_id": tid}) + return tid + + def list_tactics(self) -> list[dict]: + return list(self._library.values()) + + def get_tactic(self, id_or_name: str) -> dict | None: + for t in self._library.values(): + if t.get("id") == id_or_name or t.get("name") == id_or_name: + return copy.deepcopy(t) + return None + + def apply_tactic(self, id_or_name: str) -> dict | None: + tmpl = self.get_tactic(id_or_name) + if tmpl is None: + return None + # Return a fresh planned tactic with a new id + fresh = copy.deepcopy(tmpl) + fresh["id"] = f"run_{id_or_name}" + fresh["state"] = "planned" + return fresh + + def get_operation_plan(self, session_id: str) -> dict | None: + return copy.deepcopy(self._plans.get(session_id)) + + def set_operation_plan(self, session_id: str, plan: dict) -> None: + self._plans[session_id] = copy.deepcopy(plan) + self.set_operation_plan_calls.append({"session_id": session_id, "plan": copy.deepcopy(plan)}) + + +# --------------------------------------------------------------------------- +# Fake agent +# --------------------------------------------------------------------------- + + +class FakeAgent: + def __init__(self, session_id="sess_001", context_store=None): + self.session_id = session_id + self.context_store = context_store or FakeContextStore() + + +def _make_context(agent): + return {"agent": agent} + + +# --------------------------------------------------------------------------- +# Import the tools under test +# --------------------------------------------------------------------------- + + +from gently.app.tools.tactic_library_tools import ( + apply_tactic, + list_tactics, + save_tactic, +) + + +# --------------------------------------------------------------------------- +# save_tactic tests +# --------------------------------------------------------------------------- + + +class TestSaveTactic: + @pytest.mark.asyncio + async def test_calls_store_save_tactic(self): + """save_tactic must call context_store.save_tactic once.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + assert len(cs.save_tactic_calls) == 1 + + @pytest.mark.asyncio + async def test_passes_name_through(self): + """The name passed to the tool must reach store.save_tactic.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="My custom tactic", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + call = cs.save_tactic_calls[0] + assert call["name"] == "My custom tactic" + + @pytest.mark.asyncio + async def test_returns_confirmation_with_id(self): + """Return value must contain the new template id.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + result = await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + # The id returned by the fake store is "tpl_1" + assert "tpl_1" in result + + @pytest.mark.asyncio + async def test_description_merged_into_tactic(self): + """If description is provided it should be in the tactic passed to the store.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + await save_tactic( + name="Baseline timelapse", + tactic=_fresh_tactic(), + description="Override description", + context=_make_context(agent), + ) + saved = cs.save_tactic_calls[0]["tactic"] + assert saved.get("description") == "Override description" + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + """If context_store is None, return an error string.""" + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await save_tactic( + name="x", + tactic=_fresh_tactic(), + context=_make_context(agent), + ) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await save_tactic(name="x", tactic=_fresh_tactic(), context={}) + assert result.startswith("Error:") + + +# --------------------------------------------------------------------------- +# list_tactics tests +# --------------------------------------------------------------------------- + + +class TestListTactics: + @pytest.mark.asyncio + async def test_returns_readable_summary(self): + """list_tactics should include id, name, kind in the output.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + # Pre-populate library + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + result = await list_tactics(context=_make_context(agent)) + assert "tpl_1" in result + assert "Baseline timelapse" in result + assert "standing_timelapse" in result + + @pytest.mark.asyncio + async def test_empty_library_message(self): + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + result = await list_tactics(context=_make_context(agent)) + assert "empty" in result.lower() or "no saved" in result.lower() + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await list_tactics(context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await list_tactics(context={}) + assert result.startswith("Error:") + + +# --------------------------------------------------------------------------- +# apply_tactic tests +# --------------------------------------------------------------------------- + + +class TestApplyTactic: + @pytest.mark.asyncio + async def test_appends_tactic_to_existing_plan(self): + """apply_tactic must append the fresh tactic to the session's Operation Plan.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + # Seed library + existing plan + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + cs._plans["sess_001"] = { + "session_id": "sess_001", + "title": "Test plan", + "goal": "Test goal", + "tactics": [], + } + + result = await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert len(cs.set_operation_plan_calls) == 1 + saved_plan = cs.set_operation_plan_calls[0]["plan"] + assert len(saved_plan["tactics"]) == 1 + assert saved_plan["tactics"][0]["state"] == "planned" + + @pytest.mark.asyncio + async def test_creates_minimal_plan_if_none_exists(self): + """If no plan exists yet, apply_tactic creates a minimal one.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + + await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert len(cs.set_operation_plan_calls) == 1 + plan = cs.set_operation_plan_calls[0]["plan"] + assert plan["session_id"] == "sess_001" + assert len(plan["tactics"]) == 1 + + @pytest.mark.asyncio + async def test_appends_not_replaces(self): + """apply_tactic appends; an existing tactic in the plan must remain.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + cs._plans["sess_001"] = { + "session_id": "sess_001", + "title": "", + "goal": "", + "tactics": [{"id": "existing_t", "name": "Prior tactic", "kind": "oneshot", "state": "done"}], + } + + await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + plan = cs.set_operation_plan_calls[0]["plan"] + assert len(plan["tactics"]) == 2 + ids = [t["id"] for t in plan["tactics"]] + assert "existing_t" in ids + + @pytest.mark.asyncio + async def test_unknown_id_returns_error_no_plan_write(self): + """Unknown tactic id → error string, set_operation_plan must NOT be called.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + + result = await apply_tactic(id_or_name="nonexistent", context=_make_context(agent)) + + assert result.startswith("Error:") + assert len(cs.set_operation_plan_calls) == 0 + + @pytest.mark.asyncio + async def test_missing_context_store_returns_error(self): + agent = FakeAgent(context_store=None) + agent.context_store = None + result = await apply_tactic(id_or_name="anything", context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_session_id_returns_error(self): + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + agent.session_id = None + result = await apply_tactic(id_or_name="anything", context=_make_context(agent)) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_missing_agent_returns_error(self): + result = await apply_tactic(id_or_name="anything", context={}) + assert result.startswith("Error:") + + @pytest.mark.asyncio + async def test_return_value_names_tactic_and_session(self): + """Confirmation message should mention the tactic name and session id.""" + cs = FakeContextStore() + agent = FakeAgent(context_store=cs) + cs.save_tactic(_fresh_tactic(), name="Baseline timelapse") + + result = await apply_tactic(id_or_name="Baseline timelapse", context=_make_context(agent)) + + assert "Baseline timelapse" in result + assert "sess_001" in result + + +# --------------------------------------------------------------------------- +# Registration smoke test +# --------------------------------------------------------------------------- + + +class TestRegistration: + def test_tools_are_importable(self): + """The three tools must import without error.""" + from gently.app.tools.tactic_library_tools import ( + apply_tactic, + list_tactics, + save_tactic, + ) + + assert callable(save_tactic) + assert callable(list_tactics) + assert callable(apply_tactic) + + def test_module_in_tools_package(self): + """tactic_library_tools must be in tools.__init__ exports.""" + import gently.app.tools as tools_pkg + + assert hasattr(tools_pkg, "tactic_library_tools") + + def test_tools_registered_in_registry(self): + """The three tools must appear by name in the tool registry.""" + import gently.app.tools # noqa: F401 — ensure registration side-effect + from gently.harness.tools.registry import get_tool_registry + + registry = get_tool_registry() + names = {entry.name for entry in registry.list_all()} + assert "save_tactic" in names, f"save_tactic not in registry: {names}" + assert "list_tactics" in names, f"list_tactics not in registry: {names}" + assert "apply_tactic" in names, f"apply_tactic not in registry: {names}" From 0dcfdca1eafdc6752a98a3c10574de7d1a475f73 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 19:55:52 +0530 Subject: [PATCH 227/314] feat(tactics-library): GET /api/tactic_library route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add read endpoint that returns the saved tactic library from FileContextStore. Returns {"tactics": []} when the store is absent, list_tactics() raises, or the library is empty — never a 500. --- gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/tactic_library.py | 27 ++++++++++++ tests/test_tactic_library_route.py | 58 ++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 gently/ui/web/routes/tactic_library.py create mode 100644 tests/test_tactic_library_route.py diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 6cd54a7a..f1ead737 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -14,6 +14,7 @@ from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router from .operation_plan import create_router as create_operation_plan_router +from .tactic_library import create_router as create_tactic_library_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 @@ -41,6 +42,7 @@ def register_all_routes(server): create_notebook_router, create_temperature_router, create_operation_plan_router, + create_tactic_library_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/tactic_library.py b/gently/ui/web/routes/tactic_library.py new file mode 100644 index 00000000..5ef12804 --- /dev/null +++ b/gently/ui/web/routes/tactic_library.py @@ -0,0 +1,27 @@ +"""Tactic Library route. + +Returns the saved tactic library from FileContextStore (``server.context_store``). +Never raises a 500 — returns an empty list when the store is absent or has no +saved tactics. +""" + +from fastapi import APIRouter + + +def create_router(server) -> APIRouter: + router = APIRouter() + + @router.get("/api/tactic_library") + async def get_tactic_library(): + cs = getattr(server, "context_store", None) + if cs is None: + return {"tactics": []} + try: + tactics = cs.list_tactics() + except Exception: + tactics = [] + if not tactics: + return {"tactics": []} + return {"tactics": tactics} + + return router diff --git a/tests/test_tactic_library_route.py b/tests/test_tactic_library_route.py new file mode 100644 index 00000000..9d3603f6 --- /dev/null +++ b/tests/test_tactic_library_route.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.tactic_library import create_router + +_SAMPLE_TACTICS = [ + {"id": "tac-1", "name": "Monitor drift", "description": "Watch Z drift every 5 min"}, + {"id": "tac-2", "name": "Adjust laser", "description": "Drop 488 nm by 10%"}, +] + + +def _server(tactics, context_store_available=True): + context_store = MagicMock() if context_store_available else None + if context_store is not None: + context_store.list_tactics.return_value = tactics + + srv = MagicMock() + srv.context_store = context_store + return srv, context_store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_returns_tactic_library(): + srv, cs = _server(_SAMPLE_TACTICS) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + body = r.json() + assert body["tactics"] == _SAMPLE_TACTICS + cs.list_tactics.assert_called_once() + + +def test_returns_empty_when_no_tactics(): + srv, cs = _server([]) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} + + +def test_returns_empty_when_context_store_absent(): + srv, _ = _server(_SAMPLE_TACTICS, context_store_available=False) + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} + + +def test_returns_empty_when_list_tactics_raises(): + srv, cs = _server(_SAMPLE_TACTICS) + cs.list_tactics.side_effect = RuntimeError("disk error") + r = _client(srv).get("/api/tactic_library") + assert r.status_code == 200 + assert r.json() == {"tactics": []} From 4703f9195f6182c901c5f0c9fc99856765b3ac69 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Sun, 28 Jun 2026 20:06:06 +0530 Subject: [PATCH 228/314] fix(tactics-library): scope round-trip test + preserve rationale + deterministic name lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TestScopeRoundTrip: asserts scope survives save→apply, and absent scope stays absent - Preserve rationale as its own key in save_tactic (alongside description); add TestRationalePreservation - Rewrite get_tactic to scan list_tactics() (newest-first) instead of iterdir(); add TestGetTacticDeterministic with name-collision case --- gently/harness/memory/file_store.py | 16 ++++--- tests/test_tactic_library_store.py | 65 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index eab691fc..6eaab365 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1816,6 +1816,7 @@ def save_tactic(self, tactic: dict, name: str | None = None) -> str: "structure": copy.deepcopy(tactic.get("structure") or {}), "scope_hint": copy.deepcopy(tactic.get("scope")), "description": tactic.get("description") or tactic.get("rationale"), + "rationale": tactic.get("rationale"), "params": copy.deepcopy(tactic.get("params")), "relations": copy.deepcopy(tactic.get("relations") or {}), "live_bind": list(tactic.get("live_bind") or []), @@ -1844,15 +1845,18 @@ def list_tactics(self) -> list[dict]: return tactics def get_tactic(self, id_or_name: str) -> dict | None: - """Return a tactic template by id or name, or None if not found.""" + """Return a tactic template by id or name, or None if not found. + + Uses list_tactics() (sorted newest-first by created_at) so that name + lookups are deterministic: on a name collision, the newest entry wins. + id lookups are unique by construction so order does not matter. + """ tl_dir = self.agent_dir / "tactic_library" if not tl_dir.exists(): return None - for f in tl_dir.iterdir(): - if f.suffix in (".yaml", ".yml"): - data = self._read_yaml(f) - if data and (data.get("id") == id_or_name or data.get("name") == id_or_name): - return data + for tactic in self.list_tactics(): + if tactic.get("id") == id_or_name or tactic.get("name") == id_or_name: + return tactic return None def apply_tactic(self, id_or_name: str) -> dict | None: diff --git a/tests/test_tactic_library_store.py b/tests/test_tactic_library_store.py index 4a92acc5..28cd5867 100644 --- a/tests/test_tactic_library_store.py +++ b/tests/test_tactic_library_store.py @@ -251,3 +251,68 @@ def test_applied_no_created_at(self, file_context_store): tid = file_context_store.save_tactic(_fresh_tactic()) applied = file_context_store.apply_tactic(tid) assert "created_at" not in applied + + +# --------------------------------------------------------------------------- +# Fix 1 — scope round-trip +# --------------------------------------------------------------------------- + + +class TestScopeRoundTrip: + def test_applied_scope_matches_source(self, file_context_store): + """scope from source tactic must survive the save→apply round-trip.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied["scope"] == {"mode": "global"} + + def test_applied_no_scope_when_source_has_none(self, file_context_store): + """If source tactic has no scope, applied tactic must not gain a scope key.""" + tactic = _fresh_tactic() + tactic.pop("scope") + tid = file_context_store.save_tactic(tactic) + applied = file_context_store.apply_tactic(tid) + assert "scope" not in applied + + +# --------------------------------------------------------------------------- +# Fix 2 — rationale preservation +# --------------------------------------------------------------------------- + + +class TestRationalePreservation: + def test_rationale_survives_save_apply(self, file_context_store): + """rationale from source tactic must survive the save→apply round-trip.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + applied = file_context_store.apply_tactic(tid) + assert applied.get("rationale") == "Establish pre-ramp cadence" + + def test_rationale_in_template(self, file_context_store): + """rationale must be stored in the saved template, not only in description.""" + tid = file_context_store.save_tactic(_fresh_tactic()) + tmpl = file_context_store.get_tactic(tid) + assert tmpl.get("rationale") == "Establish pre-ramp cadence" + + +# --------------------------------------------------------------------------- +# Fix 3 — deterministic name lookup (newest-wins on collision) +# --------------------------------------------------------------------------- + + +class TestGetTacticDeterministic: + def test_name_collision_returns_newest(self, file_context_store): + """When two tactics share a name, get_tactic(name) must return the newest.""" + import time + + t1 = _fresh_tactic() + t1["structure"] = {"cadence_s": 60} + file_context_store.save_tactic(t1, name="Shared Name") + # Small pause to guarantee distinct created_at timestamps + time.sleep(0.002) + t2 = _fresh_tactic() + t2["structure"] = {"cadence_s": 120} + file_context_store.save_tactic(t2, name="Shared Name") + + result = file_context_store.get_tactic("Shared Name") + assert result is not None + # Newest entry (cadence_s=120) must win + assert result["structure"]["cadence_s"] == 120 From df7cf8e60b5c7f8de624f831ad1e63fa2a8f0593 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:10:06 +0530 Subject: [PATCH 229/314] docs(d2): embryo roles/strain + multi-embryo observability spec + wrap-up PR plan --- docs/superpowers/PR-PLAN.md | 30 ++++++ ...-28-operations-multiembryo-roles-design.md | 95 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 docs/superpowers/PR-PLAN.md create mode 100644 docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md diff --git a/docs/superpowers/PR-PLAN.md b/docs/superpowers/PR-PLAN.md new file mode 100644 index 00000000..aeb217d3 --- /dev/null +++ b/docs/superpowers/PR-PLAN.md @@ -0,0 +1,30 @@ +# Wrap-up PR plan — the temperature-experiment + Operations goal + +**Principle (user-stated):** capture the breadth of work as **separate PRs**, each a distinct unit of +work with its own identity/ownership and its own tests, **additive on top of PR #58** +(`integration/ux2-all`, the UX-v2 stack), stacked so they **compose into the final product** in order. +Each PR diffs cleanly against its parent in the stack; integrate the chain when rig-verified. + +## The stack (base = PR #58 `integration/ux2-all`) + +| # | Branch | Unit of work | Parent | Tests / review | +|---|--------|--------------|--------|----------------| +| 1 | `feature/temperature-interface` (A) | Temperature persistence (`append/read_temperature_sample`) + sampler service + SVG temperature graph w/ setpoint line | #58 | per-task + whole-branch review; graph Chrome-audited | +| 2 | `feature/manual-mode-live-view` (B1) | Manual-mode imaging: lightsheet brightfield live view (sequence acquisition), illumination control (LED/laser presets), galvo/piezo scan params + **4 acquire-safety fixes** (C1/I1/I2/I3) | A | per-task + whole-branch (opus) review | +| 3 | `feature/temp-change-tactic` (C) | Automated temp-change burst protocol (`wait_for_temperature_lock` + driver), burst-acquisition wiring, protocol events + agent tool | B1 | per-task + whole-branch review + fixes | +| 4 | `feature/operations-tab` (D) | **Operations: the agent-authored Operation Plan** — typed declare tool, store, route, execution-linkage (tactic_id + updater), plan-item seeding, operation-spine renderer + live binding | C | 10 tasks + whole-branch (opus) review + 6 fixes; 105 tests; Chrome-audited | +| 5 | `feature/tactics-library` (G) | Save / list / apply reusable typed tactics (on D's substrate), mirroring plan-templates; apply→Operation Plan | D | 3 tasks + whole-branch review + fixes; ~64 tests | +| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | TBD (SDD) | + +Notes: +- Each branch is the natural unit of "distinct enough to own a PR." Sub-parts (e.g. B1's safety fixes) + stay inside their branch — granular enough to capture the work, not so granular it's noise. +- A/B1/C are **kept-as-is pending rig verification** (not merged); D/G/D2 build on top. The stack is + intact but unmerged — PRs can open stacked and merge the chain once verified on the rig. +- "Easy to put together into a final product" = the linear stack already composes; the integration + point is #58 → `development`. + +## At wrap-up +1. Verify each branch's tests pass (the SDD ledgers + whole-branch reviews are the evidence trail). +2. Open the stacked PRs in order (each targets its parent branch), each description capturing its unit. +3. Rebase/integrate the chain onto #58 when rig-verified; #58 → `development` as the final integration. diff --git a/docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md b/docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md new file mode 100644 index 00000000..c3163fc0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-operations-multiembryo-roles-design.md @@ -0,0 +1,95 @@ +# Design: Embryo roles/strain + multi-embryo Operations observability (sub-project D2) + +Status: design 2026-06-29 (revised after recon + user steering). Extends D (the agent-authored +Operation Plan). Driven by the real temperature-strain run: **multiple embryos with roles**. Branch: +`feature/embryo-roles-observability` (off G / the operations stack). + +## 0. Reuse what exists — gently ALREADY has embryo roles +Recon confirmed a first-class role concept; D2 builds on it, does NOT reinvent it: +- `role: str` on every embryo — `EmbryoInfo["role"]` in `embryo.yaml` (`gently/core/store_types.py:34`, + written by `FileStore.register_embryo` `file_store.py:564/576`) and `EmbryoState.role` + (`gently/harness/state.py:138`, default `"test"`). +- `gently/harness/roles.py:REGISTRY` — the taxonomy. Today: `test` (biological subject, magenta + `#ff66cc`, star, dose ×1), `calibration` (reference, cyan `#00cccc`, diamond, dose ×10, + nuclear-marker pipeline), `unassigned`. Each `EmbryoRole` carries `description, + default_cadence_seconds, detector_name, photodose_budget_multiplier, ui_color, ui_icon, + no_object_consecutive_terminal`. +- Already surfaced in the **Map view** (`marking.js` ROLE_CYCLE/ROLE_COLORS) + `GET + /api/embryos/positions` (`routes/data.py:689`); already read for photodose scaling + (`timelapse.py:426`). The **Embryos strip + Operations do NOT show role** — that is the gap D2 fills. + +## 0a. The load-bearing insight — strain ⊥ use +Role and strain are ORTHOGONAL axes that gently currently conflates (the `calibration` role hardcodes +the nuclear-marker detector): +- **Strain** = the biological sample (e.g. pan-nuclear GFP — GFP in every nucleus; a dopaminergic + reporter). It is what is *labeled* → it determines the **perception/detector**. +- **Use (role)** = what the embryo is FOR: subject (test), calibration, lineaging. Drives + cadence / photodose / adaptive-vs-fixed tactics. +The SAME pan-nuclear strain is used for **lineaging** (C. elegans lineage tracing) OR **calibration** +(microscopist calibrating the lightsheet on the same nuclear structure); and **lineaging** can run on +*non*-pan-nuclear strains. So neither determines the other. Fix: make **strain** the per-embryo field +that drives the detector, and let **role** be purely *use*. + +## 1. Schema changes + +### 1.1 Per-embryo `strain` (NEW, free-form string) +Add `strain: str | None` to `EmbryoInfo` (`store_types.py`) + `EmbryoState` (`state.py`), persisted in +`embryo.yaml` via `register_embryo(..., strain=None)`. Free-form (e.g. `"pan-nuclear GFP"`); coexists +with the existing plan-level `strain`/`genotype`/`reporter` spec overrides +(`memory/file_store.py:1746`). No strain registry this pass. + +### 1.2 Roles refined to USE — extend `roles.REGISTRY` +- Add `lineaging` to the REGISTRY (its own `ui_color`/`ui_icon`/`default_cadence_seconds`/policy). + Distinct *use* from calibration even when it shares the pan-nuclear strain. +- Add a `class` attribute to `EmbryoRole`: `'subject' | 'reference'` — `test`→subject; + `calibration`,`lineaging`→reference; `unassigned`→subject (safe default). Drives the Operations + foregrounding; derived from role, not a new per-embryo field. +- Keep `detector_name` ON the role THIS PASS (staged — see §4). Document that it logically belongs to + strain. + +### 1.3 Role-scoped tactics (on D's Operation Plan) +`tactic.scope` gains `mode:'role'` + `role:` (an existing REGISTRY key: test/calibration/lineaging). +`mode=role` resolves to all roster embryos with that role. Backward compatible (global/embryos unchanged). + +## 2. Observability — Operations roster lens (reads existing role + strain) +A roster lens above the operation spine, reading the LIVE embryos (via the tracker / `EmbryoState.role` +/ `/api/embryos/positions`) + `roles.REGISTRY` metadata (real `ui_color`/`ui_icon` — magenta test, cyan +calibration, lineaging's color) — NOT new colors: +- Split FIRST by role `class`: **Subjects** (foregrounded — adaptive tactics/scenarios) then + **References** (compact — calibration/lineaging holding steady). +- Within each class, group by role (use). Each embryo row: `id · role chip (REGISTRY color/icon) · + strain · cadence-phase chip · current tactic · state`. +- The spine's tactic nodes show scope by role ("→ test · E01,E02,E03"; "→ reference"). +This answers "who's a subject vs a reference, what's each one's strain + use, what's happening to each." + +## 3. Architecture (reuse D + existing role plumbing) +- Strain: field on EmbryoInfo/EmbryoState + register_embryo + the marking tool (`detection_tools.py`) + accepts strain alongside role; `/api/embryos/positions` adds `strain`. +- Roles: extend `roles.py:REGISTRY` (+ `class` on EmbryoRole); reuse `get_role`/`REGISTRY.get`. +- Operations: roster lens in `experiment-overview.js` reading the embryos endpoint + REGISTRY; role-scope + rendering on the spine. Role→embryo_ids resolver is a pure read over the roster. +- Tactic scope: `declare_operation_plan` accepts `scope.role`; seeding can set role-scoped tactics. + +## 4. ⚠️ FULL SEPARATION — TRACKED FUTURE WORK (staged out of this pass) +This pass STAGES the model; the clean end-state (do later, do not lose): +1. **Detector follows strain, not role.** Move `detector_name`/perception selection off `EmbryoRole` + onto strain (a strain→detector mapping / strain registry). `calibration` stops implying the + nuclear-marker pipeline; a pan-nuclear *test* embryo would also get nuclear perception. +2. **Strain registry** (like `roles.py`): strain key → {label, markers, default detector, ui}. Lets + strain drive detector cleanly + gives the UI consistent strain labels/colors. +3. **Role policy audit** once detector is off role: confirm role carries only cadence / photodose / + adaptive-vs-fixed / class, nothing strain-derived. +4. Backfill: existing embryos have role but no strain — a migration/default for strain on legacy data. +These are real work items; they belong in a follow-on PR after D2's staged version lands. + +## 5. Out of scope (D2) +- Per-role analysis/plots (results view, not Operations). +- A manual role/strain editor UI beyond what the Map marking tool already does. +- The full detector→strain decoupling (§4 — explicitly future). + +## 6. Testing +- Strain field round-trips through embryo.yaml (register/get); `/api/embryos/positions` includes it. +- `roles.REGISTRY` has lineaging + `class`; `get_role` returns class; existing roles unchanged. +- Role-scope resolver: role→embryo_ids over a roster; global/embryos unchanged; backward compat. +- Renderer: roster lens (class split → role groups → strain) + role-scoped spine render across fixtures + using REAL REGISTRY colors (node --check + Chrome audit). Roster-less/role-less plan renders as D today. From cc77f0f0d764f2272cacc0098f38a7cba1a3263c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:11:21 +0530 Subject: [PATCH 230/314] =?UTF-8?q?docs(d2):=20implementation=20plan=20(4?= =?UTF-8?q?=20tasks)=20=E2=80=94=20strain=20field,=20roles-as-use,=20role?= =?UTF-8?q?=20scope,=20roster=20lens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-29-embryo-roles-observability.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-embryo-roles-observability.md diff --git a/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md new file mode 100644 index 00000000..cdbe76dc --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md @@ -0,0 +1,43 @@ +# Embryo roles/strain + multi-embryo Operations observability (D2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Add a per-embryo `strain` field, refine roles to *use* (add lineaging + a subject/reference `class`), and build the Operations roster lens that reads existing roles + strain. Detector stays on role this pass (full detector→strain separation is tracked future work — spec §4). + +**Architecture:** Reuse gently's existing `role` (`roles.REGISTRY`, `EmbryoState.role`, `embryo.yaml`). Add `strain` alongside role; extend the registry; expose roles to the frontend; render a role+strain roster lens above the operation spine. + +## Global Constraints +- REUSE the existing role concept — do NOT invent a new taxonomy. Extend `gently/harness/roles.py:REGISTRY` (`EmbryoRole` dataclass) and read role via the existing accessors (`EmbryoState.role`, `EmbryoInfo["role"]`, `get_role`/`REGISTRY.get`, `/api/embryos/positions`). +- Strain is a FREE-FORM string per embryo (no registry this pass). Coexists with plan-level strain/genotype overrides. +- Roles render with their REAL `REGISTRY` `ui_color`/`ui_icon` (magenta test, cyan calibration, lineaging's own) — never invented colors. +- `class` ('subject'|'reference') is an attribute ON `EmbryoRole`, derived from role; test/unassigned→subject, calibration/lineaging→reference. +- Backward compatible: embryos without strain → None; plans without role-scope render as D today. +- Detector stays wired to role THIS PASS (spec §4 documents the full separation as future work — do not implement it here). +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Per-embryo `strain` field +**Files:** Modify `gently/core/store_types.py` (`EmbryoInfo` — add `strain: str | None`); `gently/harness/state.py` (`EmbryoState` — add `strain: str | None = None`); `gently/core/file_store.py` (`register_embryo` — accept `strain=None`, write/coalesce it in embryo.yaml like `role`); `gently/ui/web/routes/data.py` (`/api/embryos/positions` ~:689 — add `"strain": emb.get("strain")`). Test: `tests/test_embryo_strain.py`. +- [ ] Confirm how `role` is threaded through `register_embryo` (`file_store.py:507-580`, write at :576, coalesce at :564) and EmbryoState (`state.py:138`); mirror it for `strain`. Confirm the positions endpoint shape (`data.py:661-696`). +- [ ] TDD: register an embryo with `strain="pan-nuclear GFP"` → get_embryo returns it; update coalesces; absent → None; positions endpoint includes strain. `pytest tests/test_embryo_strain.py -v`; `pytest -q` clean. Commit `feat(d2): per-embryo strain field`. + +### Task 2: Roles refined — lineaging + subject/reference class +**Files:** Modify `gently/harness/roles.py` (add `class_: str = "subject"` — or `klass`/`role_class` to avoid the `class` keyword — to `EmbryoRole`; add a `lineaging` entry to `REGISTRY`; set `class_` on each role: test/unassigned→subject, calibration/lineaging→reference; give lineaging its own ui_color/ui_icon/default_cadence/detector kept None or nuclear like calibration). Test: `tests/test_roles_registry.py` (extend if exists). +- [ ] Add the `class_` field (default "subject") to `EmbryoRole`; set it on all REGISTRY entries; add `lineaging` (reference, distinct ui_color e.g. a teal/green, ui_icon, cadence). Keep `detector_name` as-is on each role (staged). Confirm nothing else constructs EmbryoRole positionally in a way the new field breaks. +- [ ] TDD: `REGISTRY["lineaging"].class_=="reference"`; `REGISTRY["test"].class_=="subject"`; `REGISTRY["calibration"].class_=="reference"`; `get_role("lineaging")` works; existing roles/fields unchanged. `pytest tests/test_roles_registry.py -v`; `pytest -q` clean. Commit `feat(d2): roles-as-use — add lineaging + subject/reference class`. + +### Task 3: `/api/roles` route + role-scoped tactic scope +**Files:** Create `gently/ui/web/routes/roles.py` (`GET /api/roles` → `{roles:[{name,description,class_,ui_color,ui_icon,default_cadence_seconds}]}` from `list_roles()`/REGISTRY; register in `routes/__init__.py`). Modify `gently/app/tools/operation_plan_tools.py` (allow `scope.mode=='role'` + `scope.role` in validation — accept a REGISTRY key); add a pure resolver `resolve_scope_embryos(scope, roster_or_embryos) -> list[str]` (in a small module or operation_plan_tools) mapping mode=role→embryo_ids by role. Test: `tests/test_roles_route.py`, `tests/test_role_scope.py`. +- [ ] `/api/roles` mirrors `routes/tactic_library.py` (simple list route, graceful). The resolver maps `{mode:'role',role:'test'}` against a list of embryos-with-roles → the matching ids; global→all, embryos→explicit. Validation accepts mode=role with a valid role key. +- [ ] TDD: route returns the registry incl. lineaging + class_; resolver resolves role→ids, global→all, embryos→explicit, unknown role→[]. `pytest tests/test_roles_route.py tests/test_role_scope.py -v`; `pytest -q` clean. Commit `feat(d2): /api/roles route + role-scoped tactic scope resolver`. + +### Task 4: Operations roster lens (frontend) +**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (add a roster lens above the operation spine: fetch `/api/embryos/positions` + `/api/roles`, group embryos by role `class_` (Subjects foregrounded, References compact) then by role, each row `id · role chip (REGISTRY ui_color/ui_icon) · strain · cadence-phase chip · current tactic (from the plan's role-scoped tactics) · state`; render tactic-node scope by role using the resolver/role labels); `gently/ui/web/static/css/experiment.css` (the `.ops-roster*` classes, using the role colors from the API, not hardcoded). Reference: the validated prototype `scratchpad/d2proto/index.html` (regrounded to real role colors). +- [ ] Build the roster lens reading the real endpoints + role metadata (colors from `/api/roles`, not invented); class split → role groups → strain; spine nodes show role-scope ("→ test · E01.."). Backward compat: no embryos/roles → omit the lens, spine renders as D. `node --check`; build/refresh the opsv3 (or d2) Chrome harness with the real files for the controller to audit. Commit `feat(d2): Operations roster lens — embryos by role + strain`. + +## Self-Review +- Strain→T1; roles/class→T2; roles route + role-scope→T3; roster lens→T4. ✓ +- Open confirmations: register_embryo/EmbryoState role threading (T1), EmbryoRole construction sites (T2), the route/resolver pattern (T3), the embryos+roles endpoints + plan cross-reference for current-tactic (T4). +- Type consistency: `strain` str|None across model/store/endpoint; `class_` on EmbryoRole + in /api/roles + read by the renderer; role keys consistent across REGISTRY, scope.role, resolver, renderer. +- Staged: detector stays on role (spec §4 future work referenced, not implemented). From 3ad9afefa3adf743c7052859785561e2fce9ea95 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:14:02 +0530 Subject: [PATCH 231/314] feat(d2): per-embryo strain field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add free-form `strain: str | None` to EmbryoInfo, EmbryoState, register_embryo (create + coalesce-update), and the positions endpoint — threaded exactly like the existing `role` field. Tests in tests/test_embryo_strain.py (6/6 pass). --- gently/core/file_store.py | 7 ++ gently/core/store_types.py | 1 + gently/harness/state.py | 3 + gently/ui/web/routes/data.py | 1 + tests/test_embryo_strain.py | 145 +++++++++++++++++++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 tests/test_embryo_strain.py diff --git a/gently/core/file_store.py b/gently/core/file_store.py index e7fb2174..d9dafaaa 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -516,6 +516,7 @@ def register_embryo( position_fine: dict | None = None, calibration: dict | None = None, role: str | None = None, + strain: str | None = None, ) -> None: """Register or update an embryo in a session. @@ -523,6 +524,10 @@ def register_embryo( (e.g. ``"test"``, ``"calibration"``, ``"unassigned"``). Persisted in embryo.yaml. None preserves the existing value on update. + ``strain`` is a free-form biological sample descriptor (e.g. + ``"pan-nuclear GFP"``). Orthogonal to role. None preserves the existing + value on update. + Position has two stages: coarse (bottom-camera / manual map placement) and fine (future SPIM-objective alignment). New callers should pass position_coarse / position_fine as dicts of shape {"x": float, "y": @@ -562,6 +567,7 @@ def register_embryo( if calibration is not None else existing.get("calibration"), "role": role if role is not None else existing.get("role", "test"), + "strain": strain if strain is not None else existing.get("strain"), "created_at": existing.get("created_at", _now()), } else: @@ -574,6 +580,7 @@ def register_embryo( "position_fine": position_fine, "calibration": calibration, "role": role if role is not None else "test", + "strain": strain, "created_at": _now(), } diff --git a/gently/core/store_types.py b/gently/core/store_types.py index aa3eeaff..1b7a60fe 100644 --- a/gently/core/store_types.py +++ b/gently/core/store_types.py @@ -32,6 +32,7 @@ class EmbryoInfo(TypedDict, total=False): position_y: float | None calibration: dict | None role: str | None # key into gently.harness.roles.REGISTRY + strain: str | None # free-form biological sample descriptor, e.g. "pan-nuclear GFP" created_at: str diff --git a/gently/harness/state.py b/gently/harness/state.py index 34b7d8f6..5e2af6c4 100644 --- a/gently/harness/state.py +++ b/gently/harness/state.py @@ -136,6 +136,9 @@ class EmbryoState: # is the safe choice — accidental Calibration→Test only over-protects; # accidental Test→Calibration would burn extra dose on the precious sample. role: str = "test" + # Free-form biological sample descriptor (orthogonal to role). Examples: + # "pan-nuclear GFP", "H2B-mCherry", "wild-type". None = unspecified. + strain: str | None = None # Position — two-stage: coarse (bottom-camera detection or manual map # placement, always present once an embryo exists) and fine (populated diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index cfbeb11c..eed3e5a9 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -687,6 +687,7 @@ async def embryo_positions(): "x": float(x), "y": float(y), "role": emb.get("role", "test"), + "strain": emb.get("strain"), "user_label": emb.get("user_label"), "confidence": emb.get("confidence"), "cadence_phase": emb.get("cadence_phase"), diff --git a/tests/test_embryo_strain.py b/tests/test_embryo_strain.py new file mode 100644 index 00000000..474a77ef --- /dev/null +++ b/tests/test_embryo_strain.py @@ -0,0 +1,145 @@ +"""Tests for per-embryo strain field (D2 Task 1). + +Covers: +- register_embryo with strain persists it through get_embryo +- update without strain coalesces (keeps existing value) +- absent strain on create → None +- /api/embryos/positions includes strain +""" + +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +# =========================================================================== +# FileStore tests +# =========================================================================== + + +def test_register_embryo_with_strain(file_store): + """register_embryo with strain; get_embryo returns it.""" + file_store.create_session("s1", name="test") + file_store.register_embryo( + "s1", + "embryo_1", + position_coarse={"x": 10.0, "y": 20.0}, + strain="pan-nuclear GFP", + ) + emb = file_store.get_embryo("s1", "embryo_1") + assert emb is not None + assert emb.get("strain") == "pan-nuclear GFP" + + +def test_register_embryo_strain_absent_is_none(file_store): + """register_embryo without strain → strain is None in get_embryo.""" + file_store.create_session("s2", name="test") + file_store.register_embryo("s2", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}) + emb = file_store.get_embryo("s2", "embryo_1") + assert emb is not None + assert emb.get("strain") is None + + +def test_register_embryo_strain_coalesces_on_update(file_store): + """Update without strain keeps the originally set value (coalesce).""" + file_store.create_session("s3", name="test") + # Create with strain + file_store.register_embryo( + "s3", + "embryo_1", + position_coarse={"x": 5.0, "y": 5.0}, + strain="H2B-mCherry", + ) + # Update with a new position but no strain — existing strain must be kept + file_store.register_embryo( + "s3", + "embryo_1", + position_coarse={"x": 6.0, "y": 6.0}, + ) + emb = file_store.get_embryo("s3", "embryo_1") + assert emb is not None + assert emb.get("strain") == "H2B-mCherry" + + +def test_register_embryo_strain_can_be_overwritten(file_store): + """Passing a new strain on update replaces the old value.""" + file_store.create_session("s4", name="test") + file_store.register_embryo( + "s4", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}, strain="wild-type" + ) + file_store.register_embryo( + "s4", "embryo_1", position_coarse={"x": 0.0, "y": 0.0}, strain="pan-nuclear GFP" + ) + emb = file_store.get_embryo("s4", "embryo_1") + assert emb is not None + assert emb.get("strain") == "pan-nuclear GFP" + + +# =========================================================================== +# /api/embryos/positions endpoint test +# =========================================================================== + + +def _build_client_with_tracker(embryos_dict): + """Build a FastAPI TestClient whose server has a timelapse_tracker.""" + from gently.ui.web.routes.data import create_router + + app = FastAPI() + server = MagicMock() + + tracker = MagicMock() + tracker.embryos = embryos_dict + server.timelapse_tracker = tracker + + # store.get_embryo_ids needed by /api/embryos (not under test here) + server.store.get_embryo_ids.return_value = [] + + app.include_router(create_router(server)) + return TestClient(app) + + +def test_positions_endpoint_includes_strain(): + """embryo_positions response includes the strain key from tracker.""" + client = _build_client_with_tracker( + { + "embryo_1": { + "stage_x_um": 10.0, + "stage_y_um": 20.0, + "role": "test", + "strain": "pan-nuclear GFP", + "uid": "abc", + "user_label": None, + "confidence": 0.9, + "cadence_phase": None, + "is_complete": False, + } + } + ) + resp = client.get("/api/embryos/positions") + assert resp.status_code == 200 + data = resp.json() + assert len(data["embryos"]) == 1 + point = data["embryos"][0] + assert "strain" in point + assert point["strain"] == "pan-nuclear GFP" + + +def test_positions_endpoint_strain_none_when_absent(): + """embryo_positions returns strain=None when tracker dict has no strain key.""" + client = _build_client_with_tracker( + { + "embryo_1": { + "stage_x_um": 1.0, + "stage_y_um": 2.0, + "role": "test", + # no "strain" key + } + } + ) + resp = client.get("/api/embryos/positions") + assert resp.status_code == 200 + point = resp.json()["embryos"][0] + assert "strain" in point + assert point["strain"] is None From d14c6de6ce67e067509e0e2482317ac663e79f8f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:19:06 +0530 Subject: [PATCH 232/314] =?UTF-8?q?feat(d2):=20roles-as-use=20=E2=80=94=20?= =?UTF-8?q?add=20lineaging=20+=20subject/reference=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add role_class field ("subject"|"reference") to EmbryoRole frozen dataclass. Set on all existing entries: test/unassigned→subject, calibration→reference. Add lineaging registry entry (role_class=reference, ui_color=#33cc88, icon=triangle, detector=perception) — a distinct use even when the strain overlaps calibration. Detector wiring is staged; strain→detector decoupling deferred to spec §4. 22 new tests in tests/test_roles_registry.py; 0 new failures in full suite. --- gently/harness/roles.py | 28 ++++++ tests/test_roles_registry.py | 174 +++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 tests/test_roles_registry.py diff --git a/gently/harness/roles.py b/gently/harness/roles.py index beaa59a4..68bee551 100644 --- a/gently/harness/roles.py +++ b/gently/harness/roles.py @@ -15,8 +15,16 @@ - ``test``: the biological subject (precious sample). Custom ad-hoc detector. - ``calibration``: reference embryo used for staging/calibration. Absorbs more photodose. Standard perception pipeline. +- ``lineaging``: lineage-tracing reference — tracks nuclei/divisions. Often + a pan-nuclear strain, but the strain is separate from this use. - ``unassigned``: explicit "not yet classified" state. Treated like ``test`` for safety until the user resolves the assignment. + +Role classes +------------ +``role_class`` distinguishes how Operations foregrounds embryos: +- ``"subject"`` — the primary biological subjects of the experiment. +- ``"reference"`` — reference embryos (staging, calibration, lineaging). """ from dataclasses import dataclass @@ -44,6 +52,9 @@ class EmbryoRole: # a short threshold. Test embryos can occasionally pop out and # back, so they get a longer one. no_object_consecutive_terminal: int | None = None + # 'subject' | 'reference' — used by Operations to foreground subjects + # vs references in multi-embryo layouts and scheduling decisions. + role_class: str = "subject" REGISTRY: dict[str, EmbryoRole] = { @@ -56,6 +67,7 @@ class EmbryoRole: ui_color="#888888", ui_icon="circle", no_object_consecutive_terminal=None, + role_class="subject", # safe default: protect like a subject ), "test": EmbryoRole( name="test", @@ -71,6 +83,7 @@ class EmbryoRole: ui_color="#ff66cc", # magenta ui_icon="star", no_object_consecutive_terminal=5, # forgiving — they might drift back + role_class="subject", ), "calibration": EmbryoRole( name="calibration", @@ -85,6 +98,21 @@ class EmbryoRole: ui_color="#00cccc", # cyan ui_icon="diamond", no_object_consecutive_terminal=2, # they don't drift back; gone == gone + role_class="reference", + ), + "lineaging": EmbryoRole( + name="lineaging", + description=( + "Lineage-tracing reference — tracks nuclei/divisions; often a " + "pan-nuclear strain but the strain is separate from this use." + ), + default_cadence_seconds=300.0, + detector_name="perception", # nuclear pipeline, same as calibration + photodose_budget_multiplier=5.0, + ui_color="#33cc88", # teal-green — distinct from cyan (calibration) and magenta (test) + ui_icon="triangle", + no_object_consecutive_terminal=2, # reference embryos don't drift back + role_class="reference", ), } diff --git a/tests/test_roles_registry.py b/tests/test_roles_registry.py new file mode 100644 index 00000000..9fc7181d --- /dev/null +++ b/tests/test_roles_registry.py @@ -0,0 +1,174 @@ +""" +Tests for gently/harness/roles.py — EmbryoRole registry. + +Covers: +- role_class field presence and correct values for all built-in roles +- lineaging role entry: existence, role_class, ui_color, ui_icon +- accessor functions: get_role, is_valid_role, list_roles +- existing roles' other fields unchanged +""" + +import pytest + +from gently.harness.roles import ( + REGISTRY, + EmbryoRole, + DEFAULT_ROLE, + get_role, + is_valid_role, + list_roles, +) + + +# --------------------------------------------------------------------------- +# role_class field — all existing roles +# --------------------------------------------------------------------------- + + +def test_test_role_class_is_subject(): + assert REGISTRY["test"].role_class == "subject" + + +def test_unassigned_role_class_is_subject(): + assert REGISTRY["unassigned"].role_class == "subject" + + +def test_calibration_role_class_is_reference(): + assert REGISTRY["calibration"].role_class == "reference" + + +# --------------------------------------------------------------------------- +# lineaging role — existence and fields +# --------------------------------------------------------------------------- + + +def test_lineaging_role_exists_in_registry(): + assert "lineaging" in REGISTRY + + +def test_lineaging_role_class_is_reference(): + assert REGISTRY["lineaging"].role_class == "reference" + + +def test_lineaging_ui_color(): + # Must be distinct from calibration (#00cccc) and test (#ff66cc) + color = REGISTRY["lineaging"].ui_color + assert color == "#33cc88" + assert color != REGISTRY["calibration"].ui_color + assert color != REGISTRY["test"].ui_color + + +def test_lineaging_ui_icon(): + assert REGISTRY["lineaging"].ui_icon == "triangle" + + +def test_lineaging_description_contains_lineage(): + desc = REGISTRY["lineaging"].description.lower() + assert "lineage" in desc or "nuclei" in desc + + +def test_lineaging_has_cadence(): + assert REGISTRY["lineaging"].default_cadence_seconds > 0 + + +# --------------------------------------------------------------------------- +# Accessor functions +# --------------------------------------------------------------------------- + + +def test_get_role_lineaging_returns_correct_entry(): + role = get_role("lineaging") + assert isinstance(role, EmbryoRole) + assert role.name == "lineaging" + assert role.role_class == "reference" + + +def test_is_valid_role_lineaging_true(): + assert is_valid_role("lineaging") is True + + +def test_is_valid_role_unknown_false(): + assert is_valid_role("nonexistent_role") is False + + +def test_list_roles_includes_lineaging(): + assert "lineaging" in list_roles() + + +def test_list_roles_sorted(): + roles = list_roles() + assert roles == sorted(roles) + + +def test_get_role_unknown_raises_key_error(): + with pytest.raises(KeyError, match="Unknown embryo role"): + get_role("not_a_role") + + +# --------------------------------------------------------------------------- +# Existing roles — other fields unchanged +# --------------------------------------------------------------------------- + + +def test_test_role_fields_unchanged(): + role = REGISTRY["test"] + assert role.name == "test" + assert role.ui_color == "#ff66cc" + assert role.ui_icon == "star" + assert role.photodose_budget_multiplier == 1.0 + assert role.detector_name == "dopaminergic_signal" + assert role.no_object_consecutive_terminal == 5 + + +def test_calibration_role_fields_unchanged(): + role = REGISTRY["calibration"] + assert role.name == "calibration" + assert role.ui_color == "#00cccc" + assert role.ui_icon == "diamond" + assert role.photodose_budget_multiplier == 10.0 + assert role.detector_name == "perception" + assert role.no_object_consecutive_terminal == 2 + + +def test_unassigned_role_fields_unchanged(): + role = REGISTRY["unassigned"] + assert role.name == "unassigned" + assert role.ui_color == "#888888" + assert role.ui_icon == "circle" + assert role.detector_name is None + assert role.no_object_consecutive_terminal is None + + +# --------------------------------------------------------------------------- +# DEFAULT_ROLE sanity +# --------------------------------------------------------------------------- + + +def test_default_role_is_in_registry(): + assert DEFAULT_ROLE in REGISTRY + + +def test_default_role_is_test(): + assert DEFAULT_ROLE == "test" + + +# --------------------------------------------------------------------------- +# EmbryoRole is frozen (immutable) +# --------------------------------------------------------------------------- + + +def test_embryo_role_is_frozen(): + role = REGISTRY["test"] + with pytest.raises(Exception): + role.name = "mutated" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# role_class field has a safe default for keyword construction +# --------------------------------------------------------------------------- + + +def test_embryo_role_default_role_class(): + """New EmbryoRole without role_class defaults to 'subject'.""" + role = EmbryoRole(name="dummy", description="test only") + assert role.role_class == "subject" From f48b2068da42d0785dd11f79aedbb4db36e85b5c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:19:09 +0530 Subject: [PATCH 233/314] docs(sdd): d2-task-2 report --- .superpowers/sdd/d2-task-2-report.md | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .superpowers/sdd/d2-task-2-report.md diff --git a/.superpowers/sdd/d2-task-2-report.md b/.superpowers/sdd/d2-task-2-report.md new file mode 100644 index 00000000..5a9a33c4 --- /dev/null +++ b/.superpowers/sdd/d2-task-2-report.md @@ -0,0 +1,106 @@ +# D2 Task 2 Report — roles-as-use: lineaging + subject/reference class + +## What was done + +### 1. `role_class` field added to `EmbryoRole` + +Added `role_class: str = "subject"` as the last field in the frozen dataclass +(`gently/harness/roles.py`, after `no_object_consecutive_terminal`). Default is +`"subject"` so any existing keyword-constructed `EmbryoRole` is safe without +change. Docstring in the module header updated to document the two values +(`"subject"` | `"reference"`) and their intent. + +### 2. `role_class` set on all three existing REGISTRY entries + +| Role | role_class | Rationale | +|-------------|-------------|------------------------------------------------| +| `test` | `"subject"` | The biological subject — the precious sample. | +| `unassigned`| `"subject"` | Safe default: protect like a subject until resolved. | +| `calibration`| `"reference"` | Reference embryo for staging/calibration. | + +### 3. `lineaging` entry added to REGISTRY + +```python +"lineaging": EmbryoRole( + name="lineaging", + description=( + "Lineage-tracing reference — tracks nuclei/divisions; often a " + "pan-nuclear strain but the strain is separate from this use." + ), + default_cadence_seconds=300.0, + detector_name="perception", # nuclear pipeline, same as calibration + photodose_budget_multiplier=5.0, + ui_color="#33cc88", # teal-green — distinct from cyan/magenta + ui_icon="triangle", + no_object_consecutive_terminal=2, # reference: gone == gone + role_class="reference", +) +``` + +Color distinctness verified: `#33cc88` (teal-green) ≠ `#00cccc` (calibration +cyan) ≠ `#ff66cc` (test magenta). + +### 4. EmbryoRole construction-site check + +``` +grep -rn "EmbryoRole(" gently/ +``` + +Result: only `gently/harness/roles.py` — the three (now four) REGISTRY entries, +all using keyword arguments. No positional construction anywhere. The new field's +default makes it safe for any future keyword construction too. + +### 5. What was NOT touched (detector — staged) + +- No changes to detector/perception wiring, session logic, cadence policy, + or any consumer of `EmbryoRole` fields. +- `detector_name="perception"` on `lineaging` mirrors `calibration` — the + staged approach defers any strain→detector decoupling to spec §4 future work. +- `DEFAULT_ROLE`, `get_role`, `is_valid_role`, `list_roles` — all unchanged + (they work generically over REGISTRY). + +## TDD evidence + +File: `tests/test_roles_registry.py` (created, 22 tests) + +``` +tests/test_roles_registry.py::test_test_role_class_is_subject PASSED +tests/test_roles_registry.py::test_unassigned_role_class_is_subject PASSED +tests/test_roles_registry.py::test_calibration_role_class_is_reference PASSED +tests/test_roles_registry.py::test_lineaging_role_exists_in_registry PASSED +tests/test_roles_registry.py::test_lineaging_role_class_is_reference PASSED +tests/test_roles_registry.py::test_lineaging_ui_color PASSED +tests/test_roles_registry.py::test_lineaging_ui_icon PASSED +tests/test_roles_registry.py::test_lineaging_description_contains_lineage PASSED +tests/test_roles_registry.py::test_lineaging_has_cadence PASSED +tests/test_roles_registry.py::test_get_role_lineaging_returns_correct_entry PASSED +tests/test_roles_registry.py::test_is_valid_role_lineaging_true PASSED +tests/test_roles_registry.py::test_is_valid_role_unknown_false PASSED +tests/test_roles_registry.py::test_list_roles_includes_lineaging PASSED +tests/test_roles_registry.py::test_list_roles_sorted PASSED +tests/test_roles_registry.py::test_get_role_unknown_raises_key_error PASSED +tests/test_roles_registry.py::test_test_role_fields_unchanged PASSED +tests/test_roles_registry.py::test_calibration_role_fields_unchanged PASSED +tests/test_roles_registry.py::test_unassigned_role_fields_unchanged PASSED +tests/test_roles_registry.py::test_default_role_is_in_registry PASSED +tests/test_roles_registry.py::test_default_role_is_test PASSED +tests/test_roles_registry.py::test_embryo_role_is_frozen PASSED +tests/test_roles_registry.py::test_embryo_role_default_role_class PASSED +22 passed in 0.12s +``` + +Full suite (excluding 3 pre-existing collection errors): +- Before task-2 changes: 41 pre-existing failures + 4 from this test file against old code = 45 total +- After task-2 changes: 32 failures, 765 passed — all 32 failures pre-existing (test_eval, test_mesh_service, test_prompt_manager and others), zero new failures introduced. + +## Files changed + +- `gently/harness/roles.py` — `role_class` field on dataclass + all 4 registry entries +- `tests/test_roles_registry.py` — new, 22 tests + +## Concerns + +None. The change is additive and isolated to `roles.py`. The frozen dataclass +with a defaulted field is backward-compatible. The `lineaging` detector choice +(`"perception"`) is intentionally the same as calibration — decoupling +detector-from-use is explicitly deferred to spec §4. From a697a681fd59a4478fa691482e111206335ce29f Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:24:05 +0530 Subject: [PATCH 234/314] feat(d2): /api/roles route + role-scoped tactic scope resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/roles returns REGISTRY as {roles:[{name,description,role_class, ui_color,ui_icon,default_cadence_seconds}]}; registered in routes/__init__.py - resolve_scope_embryos() in gently/app/orchestration/role_scope.py maps scope.mode in {global,embryos,role} → list[embryo_id]; unknown → [] - _validate_tactics already passes scope through unmodified; role-scoped tactics (mode='role', role in REGISTRY) confirmed to pass declare_operation_plan - Embryo id key confirmed as 'embryo_id' (from /api/embryos/positions) - 22 new tests: 9 route + 13 resolver/validation; 0 new failures in suite --- gently/app/orchestration/role_scope.py | 60 ++++++++ gently/ui/web/routes/__init__.py | 2 + gently/ui/web/routes/roles.py | 30 ++++ tests/test_role_scope.py | 198 +++++++++++++++++++++++++ tests/test_roles_route.py | 78 ++++++++++ 5 files changed, 368 insertions(+) create mode 100644 gently/app/orchestration/role_scope.py create mode 100644 gently/ui/web/routes/roles.py create mode 100644 tests/test_role_scope.py create mode 100644 tests/test_roles_route.py diff --git a/gently/app/orchestration/role_scope.py b/gently/app/orchestration/role_scope.py new file mode 100644 index 00000000..b36bd89e --- /dev/null +++ b/gently/app/orchestration/role_scope.py @@ -0,0 +1,60 @@ +"""Role-scoped tactic target resolver. + +``resolve_scope_embryos`` maps a tactic ``scope`` dict + a roster of embryo +dicts to a list of embryo IDs that the tactic should operate on. + +Scope modes +----------- +``global`` + All embryo IDs in the roster. +``embryos`` + Explicit list from ``scope['embryo_ids']``. +``role`` + IDs of embryos whose ``role`` field matches ``scope['role']``. +missing/unknown + Empty list (safe default — never errors). + +Embryo dict shape +----------------- +The resolver expects the shape produced by ``/api/embryos/positions``: +each dict must have an ``'embryo_id'`` key (not just ``'id'``). +""" + + +def resolve_scope_embryos(scope: dict | None, embryos: list[dict]) -> list[str]: + """Return the embryo IDs that match *scope* from *embryos*. + + Parameters + ---------- + scope: + Tactic scope dict, e.g. ``{"mode": "role", "role": "test"}``. + ``None`` is treated as an unknown scope → returns ``[]``. + embryos: + List of embryo dicts, each with at minimum ``embryo_id`` and ``role`` + keys. Any dict lacking ``embryo_id`` is silently skipped. + + Returns + ------- + list[str] + Matched embryo IDs. Never raises. + """ + if not scope: + return [] + + mode = scope.get("mode") + + if mode == "global": + return [e["embryo_id"] for e in embryos if "embryo_id" in e] + + if mode == "embryos": + return list(scope.get("embryo_ids") or []) + + 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 + ] + + return [] diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index f1ead737..caebd0ec 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -14,6 +14,7 @@ from .data import create_router as create_data_router from .experiments import create_router as create_experiments_router from .operation_plan import create_router as create_operation_plan_router +from .roles import create_router as create_roles_router from .tactic_library import create_router as create_tactic_library_router from .temperature import create_router as create_temperature_router from .images import create_router as create_images_router @@ -42,6 +43,7 @@ def register_all_routes(server): create_notebook_router, create_temperature_router, create_operation_plan_router, + create_roles_router, create_tactic_library_router, ): router = factory(server) diff --git a/gently/ui/web/routes/roles.py b/gently/ui/web/routes/roles.py new file mode 100644 index 00000000..057a97e4 --- /dev/null +++ b/gently/ui/web/routes/roles.py @@ -0,0 +1,30 @@ +"""Embryo Role Registry route. + +Returns the static REGISTRY of embryo roles from ``gently.harness.roles``. +Never raises a 500 — the registry is global and read-only. +""" + +from fastapi import APIRouter + +from gently.harness.roles import REGISTRY + + +def create_router(server) -> APIRouter: # noqa: ARG001 (server not needed; registry is global) + router = APIRouter() + + @router.get("/api/roles") + async def get_roles(): + roles = [ + { + "name": role.name, + "description": role.description, + "role_class": role.role_class, + "ui_color": role.ui_color, + "ui_icon": role.ui_icon, + "default_cadence_seconds": role.default_cadence_seconds, + } + for role in REGISTRY.values() + ] + return {"roles": roles} + + return router diff --git a/tests/test_role_scope.py b/tests/test_role_scope.py new file mode 100644 index 00000000..53b8ee1a --- /dev/null +++ b/tests/test_role_scope.py @@ -0,0 +1,198 @@ +"""Tests for resolve_scope_embryos resolver + role-scoped tactic validation (Task 3, D2). + +Resolver lives at gently.app.orchestration.role_scope.resolve_scope_embryos. + +Contracts: +- mode='global' → all embryo ids +- mode='embryos' + embryo_ids → exactly those ids +- mode='role' + role='test' → ids of embryos with that role +- mode='role' with unknown role → [] (not an error) +- missing/unknown mode → [] +- embryo dicts use 'embryo_id' key (from /api/embryos/positions) + +Scope validation in declare_operation_plan: +- A tactic with scope.mode='role' and a valid REGISTRY key passes validation. +""" + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_EMBRYOS = [ + {"embryo_id": "e1", "role": "test"}, + {"embryo_id": "e2", "role": "test"}, + {"embryo_id": "e3", "role": "calibration"}, + {"embryo_id": "e4", "role": "lineaging"}, + {"embryo_id": "e5", "role": "unassigned"}, +] + + +# --------------------------------------------------------------------------- +# Resolver tests +# --------------------------------------------------------------------------- + + +def test_global_scope_returns_all_ids(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "global"}, _EMBRYOS) + assert set(ids) == {"e1", "e2", "e3", "e4", "e5"} + + +def test_embryos_scope_returns_explicit_ids(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "embryos", "embryo_ids": ["e1", "e3"]}, _EMBRYOS) + assert ids == ["e1", "e3"] + + +def test_role_scope_filters_by_role(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "test"}, _EMBRYOS) + assert set(ids) == {"e1", "e2"} + + +def test_role_scope_calibration(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "calibration"}, _EMBRYOS) + assert ids == ["e3"] + + +def test_role_scope_lineaging(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "lineaging"}, _EMBRYOS) + assert ids == ["e4"] + + +def test_role_scope_unknown_role_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "role", "role": "nonexistent_role"}, _EMBRYOS) + assert ids == [] + + +def test_unknown_mode_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "burst_only"}, _EMBRYOS) + assert ids == [] + + +def test_missing_mode_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({}, _EMBRYOS) + assert ids == [] + + +def test_none_scope_returns_empty(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos(None, _EMBRYOS) + assert ids == [] + + +def test_empty_embryo_list(): + from gently.app.orchestration.role_scope import resolve_scope_embryos + + ids = resolve_scope_embryos({"mode": "global"}, []) + assert ids == [] + + +def test_embryo_id_key_used_not_id(): + """Resolver must read 'embryo_id', not 'id'.""" + from gently.app.orchestration.role_scope import resolve_scope_embryos + + # If resolver accidentally uses 'id', this returns [] + embryos = [{"embryo_id": "eX", "role": "test"}] + ids = resolve_scope_embryos({"mode": "global"}, embryos) + assert "eX" in ids + + +# --------------------------------------------------------------------------- +# declare_operation_plan: role-scoped tactic passes validation +# --------------------------------------------------------------------------- + + +class _FakeContextStore: + def __init__(self): + self._plans = {} + + def set_operation_plan(self, sid, plan): + self._plans[sid] = plan + + def get_operation_plan(self, sid): + return self._plans.get(sid) + + +class _FakeAgent: + def __init__(self): + self.context_store = _FakeContextStore() + self.session_id = "sess_scope_test" + + +@pytest.mark.asyncio +async def test_role_scoped_tactic_passes_declare_validation(): + """A tactic with scope.mode='role' and a valid role key is accepted by declare_operation_plan.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + role_scoped_tactic = { + "id": "t-role", + "name": "Test-embryo timelapse", + "kind": "standing_timelapse", + "state": "active", + "scope": {"mode": "role", "role": "test"}, + "rationale": "Only image the test embryos", + "structure": {"cadence_s": 120, "per_embryo": []}, + "live_bind": ["cadence"], + "relations": {}, + } + + ctx = {"agent": _FakeAgent()} + result = await declare_operation_plan( + title="Role-scoped plan", + goal="Image test embryos only", + tactics=[role_scoped_tactic], + updated_reason="role scope test", + context=ctx, + ) + + assert "Error" not in result, f"Unexpected error: {result}" + stored = ctx["agent"].context_store.get_operation_plan("sess_scope_test") + assert stored is not None + tactic = stored["tactics"][0] + assert tactic["scope"]["mode"] == "role" + assert tactic["scope"]["role"] == "test" + + +@pytest.mark.asyncio +async def test_role_scoped_tactic_all_registry_roles(): + """All four REGISTRY role keys are accepted as valid scope.role values.""" + from gently.app.tools.operation_plan_tools import declare_operation_plan + + for role_name in ("test", "calibration", "lineaging", "unassigned"): + tactic = { + "id": f"t-{role_name}", + "name": f"{role_name} monitor", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"mode": "role", "role": role_name}, + "rationale": f"Target {role_name} embryos", + "structure": {"cadence_s": 300}, + "live_bind": [], + "relations": {}, + } + ctx = {"agent": _FakeAgent()} + result = await declare_operation_plan( + title=f"Plan for {role_name}", + goal="Role-specific imaging", + tactics=[tactic], + context=ctx, + ) + assert "Error" not in result, f"Role {role_name!r} was rejected: {result}" diff --git a/tests/test_roles_route.py b/tests/test_roles_route.py new file mode 100644 index 00000000..c710673f --- /dev/null +++ b/tests/test_roles_route.py @@ -0,0 +1,78 @@ +"""Tests for GET /api/roles — embryo role registry endpoint (Task 3, D2). + +Verifies: +- Returns all roles in the registry including lineaging and role_class. +- Each entry has the expected fields. +- Never raises a 500 (graceful). +""" +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.roles import create_router + + +def _client(): + """Build a minimal TestClient with only the roles router.""" + server = object() # roles route never uses server attributes + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_roles_returns_200(): + r = _client().get("/api/roles") + assert r.status_code == 200 + + +def test_roles_body_has_roles_key(): + body = _client().get("/api/roles").json() + assert "roles" in body + assert isinstance(body["roles"], list) + + +def test_roles_includes_all_registry_entries(): + """All four built-in roles must be present.""" + roles = _client().get("/api/roles").json()["roles"] + names = {r["name"] for r in roles} + assert names == {"unassigned", "test", "calibration", "lineaging"} + + +def test_roles_include_lineaging(): + roles = _client().get("/api/roles").json()["roles"] + lineaging = next(r for r in roles if r["name"] == "lineaging") + assert lineaging["role_class"] == "reference" + assert lineaging["ui_color"] == "#33cc88" + assert lineaging["ui_icon"] == "triangle" + + +def test_roles_entry_has_all_required_fields(): + """Every role entry exposes the required fields.""" + required = {"name", "description", "role_class", "ui_color", "ui_icon", "default_cadence_seconds"} + roles = _client().get("/api/roles").json()["roles"] + for role in roles: + missing = required - role.keys() + assert not missing, f"Role {role.get('name')!r} missing fields: {missing}" + + +def test_test_role_class_is_subject(): + roles = _client().get("/api/roles").json()["roles"] + test_role = next(r for r in roles if r["name"] == "test") + assert test_role["role_class"] == "subject" + + +def test_calibration_role_class_is_reference(): + roles = _client().get("/api/roles").json()["roles"] + cal = next(r for r in roles if r["name"] == "calibration") + assert cal["role_class"] == "reference" + + +def test_cadence_seconds_is_numeric(): + roles = _client().get("/api/roles").json()["roles"] + for role in roles: + assert isinstance(role["default_cadence_seconds"], (int, float)) + + +def test_never_500(): + """The route must never raise a 500 regardless of server state.""" + r = _client().get("/api/roles") + assert r.status_code != 500 From b1a47ced30317da71ac2a5d051856236963c624e Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:34:27 +0530 Subject: [PATCH 235/314] =?UTF-8?q?feat(d2):=20Operations=20roster=20lens?= =?UTF-8?q?=20=E2=80=94=20embryos=20by=20role=20+=20strain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a population roster lens above the tactic spine in the Operations view. Reads /api/embryos/positions (role, strain, cadence_phase) and /api/roles (ui_color, ui_icon, role_class) fetched in parallel at init and on each debounced refresh. Roster structure: - SUBJECTS section (role_class=subject, foregrounded, animated live dot) - REFERENCES section (role_class=reference, compact/muted rows) - Within each class: role groups with left-edge in real REGISTRY ui_color - Each embryo row: id · role chip · strain · cadence-phase chip · current tactic (cross-referenced from plan scope) · state Tactic spine nodes gain role-scope badges when roster context is present: → test · E01, E02, E03 (role mode, magenta from API) → all embryos (global, muted grey) → E01, E02 (explicit embryos, muted grey) Role colors come from /api/roles ui_color at runtime via inline styles (_hexToRgba helper); nothing role-specific is hardcoded in experiment.css. Backward compat: if either endpoint is empty/absent, rosterCtx = null, the lens is omitted, and the spine renders exactly as before D2. The fifth rosterCtx argument on _renderOpsTactic defaults to null so all existing callers (scenario mode etc.) are unaffected. node --check: PASS --- gently/ui/web/static/css/experiment.css | 267 ++++++++++++++++++ .../ui/web/static/js/experiment-overview.js | 253 ++++++++++++++++- 2 files changed, 514 insertions(+), 6 deletions(-) diff --git a/gently/ui/web/static/css/experiment.css b/gently/ui/web/static/css/experiment.css index 1d48cef5..a9f89bd7 100644 --- a/gently/ui/web/static/css/experiment.css +++ b/gently/ui/web/static/css/experiment.css @@ -1134,3 +1134,270 @@ border-left: 3px solid var(--text-muted, #8b949e); opacity: 0.8; } + +/* ============================================================ + D2 — Roster Lens (.ops-roster* classes) + Role accent colors are injected at runtime via inline style + from /api/roles — never hardcoded here so the real REGISTRY + palette (magenta test #ff66cc, cyan calibration #00cccc, + teal lineaging #33cc88) drives the rendering. + ============================================================ */ + +/* Sub-label between roster block and spine */ +.ops-section-label { + font-family: var(--ops-mono); + font-size: 9.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-muted); + opacity: 0.65; + margin: 0 0 10px; +} + +/* Roster container */ +.ops-roster { + margin-bottom: 28px; + border: 1px solid var(--border, #30363d); + border-radius: 12px; + overflow: hidden; +} + +.ops-roster-head { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + background: var(--bg-hover, #21262d); + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-roster-title { + font-family: var(--ops-mono); + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} + +.ops-roster-count { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: auto; +} + +/* Class section (SUBJECTS / REFERENCES) */ +.ops-class-section { + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-class-section:last-child { + border-bottom: none; +} + +.ops-class-header { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 16px; + border-bottom: 1px solid var(--border, #30363d); +} + +.ops-class-header.subject { background: rgba(245, 166, 35, 0.04); } +.ops-class-header.reference { background: rgba(139, 148, 158, 0.06); } + +.ops-class-label { + font-family: var(--ops-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; +} + +.ops-class-header.subject .ops-class-label { color: var(--ops-active); } +.ops-class-header.reference .ops-class-label { color: var(--text-muted); } + +.ops-class-desc { + font-family: var(--ops-mono); + font-size: 9.5px; + color: var(--text-muted); + opacity: 0.65; +} + +/* Pulsing live dot in the SUBJECTS header */ +.ops-class-live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ops-active); + box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.2); + animation: ops-roster-pulse 2s ease-in-out infinite; + flex-shrink: 0; +} + +@keyframes ops-roster-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Role group within a class section */ +.ops-role-group { + border-bottom: 1px solid rgba(48, 54, 61, 0.5); +} + +.ops-role-group:last-child { + border-bottom: none; +} + +/* Role group header — left edge color injected via inline style */ +.ops-role-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-left: 3px solid transparent; +} + +.ops-role-name { + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + /* color injected via inline style */ +} + +.ops-role-sep { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); +} + +.ops-role-count { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); +} + +.ops-role-ids { + font-family: var(--ops-mono); + font-size: 10px; + color: var(--text-muted); + margin-left: 2px; +} + +/* Embryo rows — subjects full size, references compact */ +.ops-roster-embryo { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 16px 6px 20px; + border-bottom: 1px solid rgba(48, 54, 61, 0.4); + font-family: var(--ops-mono); + font-size: 11.5px; + transition: background 0.1s; +} + +.ops-roster-embryo:last-child { border-bottom: none; } +.ops-roster-embryo:hover { background: var(--bg-hover, #21262d); } + +.ops-roster-embryo.compact { + padding: 4px 16px 4px 20px; + font-size: 10.5px; + opacity: 0.8; +} + +/* Embryo row fields */ +.ops-rem-id { + color: var(--text); + font-weight: 600; + min-width: 32px; +} + +/* Role chip — background/color/border via inline style from /api/roles */ +.ops-rem-role-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: 9px; + font-size: 9.5px; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.04em; + border: 1px solid transparent; + white-space: nowrap; + flex-shrink: 0; +} + +.ops-rem-strain { + color: var(--text-muted); + font-size: 10.5px; + opacity: 0.8; + flex: 0 0 auto; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Cadence phase chip — same visual system as .ops-cadence-phase */ +.ops-rem-phase { + padding: 2px 8px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + flex-shrink: 0; +} + +.ops-rem-phase.normal { background: rgba(139, 148, 158, 0.2); color: var(--text-muted); } +.ops-rem-phase.fast { background: rgba(251, 146, 60, 0.2); color: var(--accent-orange, #fb923c); } +.ops-rem-phase.burst { background: rgba(239, 68, 68, 0.2); color: #f87171; } +.ops-rem-phase.paused { background: rgba(90, 169, 230, 0.15); color: var(--ops-plan); } + +.ops-rem-tactic { + color: var(--text); + font-size: 11px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ops-rem-state { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + flex-shrink: 0; +} + +.ops-rem-state.active { color: var(--ops-active); } +.ops-rem-state.done { color: var(--ops-done); } + +/* Scope badge on tactic spine nodes */ +.ops-scope-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 9px; + border-radius: 9px; + font-family: var(--ops-mono); + font-size: 10px; + font-weight: 600; + border: 1px solid transparent; + text-transform: lowercase; + letter-spacing: 0.02em; + white-space: nowrap; +} + +/* Global / explicit-embryos scope — static muted style */ +.ops-scope-global { + background: rgba(139, 148, 158, 0.15); + color: var(--text-muted); + border-color: rgba(139, 148, 158, 0.3); +} +/* Role-scoped badges use inline style (color/bg/border from /api/roles) */ diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 6ba9b109..f480ea1a 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -19,6 +19,8 @@ const ExperimentOverview = { _subscribed: false, // guard: prevents double-registration across tab re-clicks _planRefreshTimer: null, // debounce handle for tactic-event-driven refetch _tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed + _rosterEmbyros: [], // embryos from /api/embryos/positions (D2 roster lens) + _rolesMap: null, // Map(role name → registry obj) from /api/roles (D2 roster lens) async init() { console.log('[ExperimentOverview] init() called, view=', this.activeView); @@ -40,12 +42,17 @@ const ExperimentOverview = { this.scenarioMode = false; // Fetch plan (overview) and strategy (rules) in parallel so tab-switching // between the two views doesn't require a second round-trip. - const [plan, strategy] = await Promise.all([ + // D2: also fetch roster + roles for the roster lens. + const [plan, strategy, rosterEmbyros, rolesMap] = await Promise.all([ this.loadPlan(), - this.loadStrategy() + this.loadStrategy(), + this._loadRoster(), + this._loadRolesMap(), ]); this.activePlan = plan; this.activeStrategy = strategy; + this._rosterEmbyros = rosterEmbyros; + this._rolesMap = rolesMap; this.isLive = plan !== null || strategy !== null; this.render(strategy); this.initialized = true; @@ -116,12 +123,17 @@ const ExperimentOverview = { // Debounced plan refetch — coalesces rapid tactic-event bursts into a single // fetch+render. 500 ms window matches experiment-strip.js convention. + // D2: also re-fetches the embryo roster so the lens stays current. _debouncedRefresh() { if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); this._planRefreshTimer = setTimeout(async () => { this._planRefreshTimer = null; - const plan = await this.loadPlan(); + const [plan, rosterEmbyros] = await Promise.all([ + this.loadPlan(), + this._loadRoster(), + ]); this.activePlan = plan; + this._rosterEmbyros = rosterEmbyros; this.isLive = plan !== null; this.render(this.activeStrategy); }, 500); @@ -338,8 +350,24 @@ const ExperimentOverview = { // Index of the first queued (planned) tactic — gets the "next" badge. const firstPlannedIdx = tactics.findIndex(t => t.state === 'planned'); + // Roster lens: visible when embryos + role metadata are both available. + // Gracefully absent if either fetch failed or returned nothing (backward compat + // with plans that pre-date D2 — spine renders exactly as before). + const rosterEmbyros = this._rosterEmbyros || []; + const rolesMap = this._rolesMap; + const rosterCtx = (rosterEmbyros.length > 0 && rolesMap && rolesMap.size > 0) + ? { embryos: rosterEmbyros, rolesMap } + : null; + const rosterHtml = rosterCtx + ? ` + ${this._renderRosterLens(rosterEmbyros, rolesMap, plan, ESC)}` + : ''; + const spineLabel = rosterCtx + ? '' + : ''; + const spineNodes = tactics - .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC)) + .map((t, idx) => this._renderOpsTactic(t, idx, firstPlannedIdx, ESC, rosterCtx)) .join(''); root.innerHTML = ` @@ -352,12 +380,15 @@ const ExperimentOverview = { in use queued
+ ${rosterHtml} + ${spineLabel}
${spineNodes}
`; }, // Render a single tactic node. - _renderOpsTactic(t, idx, firstPlannedIdx, ESC) { + // rosterCtx = { embryos, rolesMap } | null — when present, adds role-scope badge (D2). + _renderOpsTactic(t, idx, firstPlannedIdx, ESC, rosterCtx = null) { const STATE_LABEL = { done: 'done', active: 'in use', planned: 'queued', paused: 'paused' }; const seq = String(t.seq || idx + 1).padStart(2, '0'); const stateLabel = STATE_LABEL[t.state] || t.state; @@ -372,11 +403,17 @@ const ExperimentOverview = { const summary = live.summary || ''; const desc = live.desc || ''; - // Header row: name · target · summary + // Scope badge — D2: shows role → embryo-ids when roster context is available. + const scopeBadge = rosterCtx + ? this._renderOpsScopeBadge(t.scope, rosterCtx.embryos, rosterCtx.rolesMap, ESC) + : ''; + + // Header row: name · target · scope badge · summary let inner = `
${ESC(t.name)} ${target ? `${ESC(target)}` : ''} + ${scopeBadge} ${summary ? `${ESC(summary)}` : ''}
${desc ? `
${ESC(desc)}
` : ''}`; @@ -524,6 +561,210 @@ const ExperimentOverview = { return ''; }, + // ================================================================= + // D2 — Roster Lens: embryo population by role class + role + // ================================================================= + + // Fetch the current embryo roster from /api/embryos/positions. + // Returns [] on failure or when no embryos have positions yet. + async _loadRoster() { + try { + const resp = await fetch('/api/embryos/positions', { cache: 'no-store' }); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data.embryos) ? data.embryos : []; + } catch (e) { + console.warn('[ExperimentOverview] roster fetch error:', e); + return []; + } + }, + + // Fetch the roles registry from /api/roles. + // Returns a Map(name → {ui_color, ui_icon, role_class, default_cadence_seconds}). + // Returns empty Map on failure. + async _loadRolesMap() { + try { + const resp = await fetch('/api/roles', { cache: 'no-store' }); + if (!resp.ok) return new Map(); + const data = await resp.json(); + const map = new Map(); + if (Array.isArray(data.roles)) { + for (const r of data.roles) { + map.set(r.name, r); + } + } + return map; + } catch (e) { + console.warn('[ExperimentOverview] roles fetch error:', e); + return new Map(); + } + }, + + // Convert a hex color (#rrggbb) to rgba(r,g,b,alpha) for inline styles. + _hexToRgba(hex, alpha) { + const h = (hex || '#888888').replace('#', ''); + const r = parseInt(h.slice(0, 2), 16) || 0; + const g = parseInt(h.slice(2, 4), 16) || 0; + const b = parseInt(h.slice(4, 6), 16) || 0; + return `rgba(${r},${g},${b},${alpha})`; + }, + + // Cross-reference: find the name of the active (or most recently done) tactic + // that covers a given embryo (by embryo_id + role). + // Scope resolution: global → covers all; role → covers matching role; + // embryos → covers listed ids. Returns '' when no tactic found. + _resolveCurrentTactic(embryoId, role, plan) { + if (!plan || !Array.isArray(plan.tactics)) return ''; + const covers = (t) => { + const scope = t.scope || { mode: 'global' }; + if (scope.mode === 'global') return true; + if (scope.mode === 'role') return scope.role === role; + if (scope.mode === 'embryos') { + return Array.isArray(scope.embryo_ids) && scope.embryo_ids.includes(embryoId); + } + return false; + }; + // Prefer the active tactic covering this embryo. + const active = plan.tactics.find(t => t.state === 'active' && covers(t)); + if (active) return active.name; + // Fall back to the most recently done tactic (last in array order). + for (let i = plan.tactics.length - 1; i >= 0; i--) { + if (plan.tactics[i].state === 'done' && covers(plan.tactics[i])) { + return plan.tactics[i].name; + } + } + return ''; + }, + + // Render a scope badge for a tactic node in the spine. + // Colors for role-scoped badges come from the roles registry (API), not CSS. + // Global and explicit-embryos scopes use the static `.ops-scope-global` class. + _renderOpsScopeBadge(scope, embryos, rolesMap, ESC) { + if (!scope || scope.mode === 'global') { + return '→ all embryos'; + } + if (scope.mode === 'role') { + const role = scope.role || ''; + const roleInfo = rolesMap ? rolesMap.get(role) : null; + const color = (roleInfo && roleInfo.ui_color) || '#8b949e'; + const matchIds = embryos + .filter(e => e.role === role) + .map(e => e.embryo_id) + .join(', '); + const label = matchIds + ? `→ ${ESC(role)} · ${ESC(matchIds)}` + : `→ ${ESC(role)}`; + const bg = this._hexToRgba(color, 0.12); + const border = this._hexToRgba(color, 0.35); + return `${label}`; + } + if (scope.mode === 'embryos') { + const ids = ESC((scope.embryo_ids || []).join(', ')); + return `→ ${ids}`; + } + return ''; + }, + + // Render the D2 roster lens: embryos grouped by role class then by role. + // SUBJECTS section is foregrounded; REFERENCES section is compact/muted. + // Role colors/icons come from the rolesMap (API data), not from CSS constants. + // Returns empty string when embryos array is empty (backward compat). + _renderRosterLens(embryos, rolesMap, plan, ESC) { + if (!embryos || embryos.length === 0) return ''; + + // Group embryos by role_class then by role, preserving first-seen order. + const CLASS_ORDER_PREF = ['subject', 'reference']; + const classOrder = []; + const byClass = {}; + + for (const emb of embryos) { + const roleInfo = rolesMap.get(emb.role); + const cls = (roleInfo && roleInfo.role_class) || 'subject'; + if (!byClass[cls]) { + byClass[cls] = { roleOrder: [], byRole: {} }; + classOrder.push(cls); + } + const section = byClass[cls]; + if (!section.byRole[emb.role]) { + section.byRole[emb.role] = []; + section.roleOrder.push(emb.role); + } + section.byRole[emb.role].push(emb); + } + + // Canonical class order: subjects first. + classOrder.sort((a, b) => { + const ai = CLASS_ORDER_PREF.indexOf(a); + const bi = CLASS_ORDER_PREF.indexOf(b); + return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi); + }); + + const totalCount = embryos.length; + const totalRoles = classOrder.reduce((n, cls) => n + byClass[cls].roleOrder.length, 0); + + const classSections = classOrder.map(cls => { + const { roleOrder, byRole } = byClass[cls]; + const isSubject = cls === 'subject'; + + const classHeaderInner = isSubject + ? `Subjects— adaptive tactics / scenarios` + : `References— steady acquisition`; + + const roleGroups = roleOrder.map(role => { + const roleEmbyros = byRole[role]; + const roleInfo = rolesMap.get(role); + const uiColor = (roleInfo && roleInfo.ui_color) || '#8b949e'; + const uiIcon = (roleInfo && roleInfo.ui_icon) || ''; + const bgRgba = this._hexToRgba(uiColor, 0.08); + const ids = roleEmbyros.map(e => e.embryo_id).join(', '); + + const embryoRows = roleEmbyros.map(emb => { + const cadencePhase = emb.cadence_phase || 'normal'; + const strain = emb.strain || '—'; + const label = emb.user_label || emb.embryo_id; + const tacticName = this._resolveCurrentTactic(emb.embryo_id, emb.role, plan); + const stateStr = emb.is_complete ? 'done' + : cadencePhase === 'paused' ? 'paused' + : 'active'; + const compact = isSubject ? '' : ' compact'; + const chipBg = this._hexToRgba(uiColor, 0.15); + const chipBorder = this._hexToRgba(uiColor, 0.4); + return `
+ ${ESC(label)} + ${uiIcon ? ESC(uiIcon) + ' ' : ''}${ESC(role)} + ${ESC(strain)} + ${ESC(cadencePhase)} + ${ESC(tacticName || '—')} + ${ESC(stateStr)} +
`; + }).join(''); + + return `
+
+ ${ESC(role.toUpperCase())} + · + ${roleEmbyros.length} embryo${roleEmbyros.length !== 1 ? 's' : ''} + ${ESC(ids)} +
+ ${embryoRows} +
`; + }).join(''); + + return `
+
${classHeaderInner}
+ ${roleGroups} +
`; + }).join(''); + + return `
+
+ Population roster + ${totalCount} embryo${totalCount !== 1 ? 's' : ''} · ${totalRoles} role${totalRoles !== 1 ? 's' : ''} +
+ ${classSections} +
`; + }, + // ----------------------------------------------------------------- // Rules table — full subtab view, grouped by rule kind // ----------------------------------------------------------------- From 866d4e08fc96d7abd7f9ea0bf41c2ba71ccc04d2 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:47:06 +0530 Subject: [PATCH 236/314] fix(d2): surface strain through timelapse_tracker + rename _rosterEmbryos + resolver-coupling note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - timelapse_tracker.py: EMBRYO_DETECTED handler now sets emb["strain"] when "strain" key present in data (mirrors role handling); seed_from_experiment now passes strain=getattr(emb, "strain", None) in the EMBRYO_DETECTED payload — fixes strain always showing "—" in live roster. - experiment-overview.js: rename _rosterEmbyros → _rosterEmbryos (9 sites, property + local variable); add coupling note on _resolveCurrentTactic pointing to role_scope.py resolve_scope_embryos. - tests/test_embryo_strain.py: 4 new tracker tests (EMBRYO_DETECTED handler + seed_from_experiment with/without strain); all 10 pass. --- .../ui/web/static/js/experiment-overview.js | 19 ++-- gently/ui/web/timelapse_tracker.py | 3 + tests/test_embryo_strain.py | 89 +++++++++++++++++++ 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index f480ea1a..fac90551 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -19,7 +19,7 @@ const ExperimentOverview = { _subscribed: false, // guard: prevents double-registration across tab re-clicks _planRefreshTimer: null, // debounce handle for tactic-event-driven refetch _tempUpdateHandler: null,// stored handler ref so it can be off()'d if needed - _rosterEmbyros: [], // embryos from /api/embryos/positions (D2 roster lens) + _rosterEmbryos: [], // embryos from /api/embryos/positions (D2 roster lens) _rolesMap: null, // Map(role name → registry obj) from /api/roles (D2 roster lens) async init() { @@ -43,7 +43,7 @@ const ExperimentOverview = { // Fetch plan (overview) and strategy (rules) in parallel so tab-switching // between the two views doesn't require a second round-trip. // D2: also fetch roster + roles for the roster lens. - const [plan, strategy, rosterEmbyros, rolesMap] = await Promise.all([ + const [plan, strategy, rosterEmbryos, rolesMap] = await Promise.all([ this.loadPlan(), this.loadStrategy(), this._loadRoster(), @@ -51,7 +51,7 @@ const ExperimentOverview = { ]); this.activePlan = plan; this.activeStrategy = strategy; - this._rosterEmbyros = rosterEmbyros; + this._rosterEmbryos = rosterEmbryos; this._rolesMap = rolesMap; this.isLive = plan !== null || strategy !== null; this.render(strategy); @@ -128,12 +128,12 @@ const ExperimentOverview = { if (this._planRefreshTimer) clearTimeout(this._planRefreshTimer); this._planRefreshTimer = setTimeout(async () => { this._planRefreshTimer = null; - const [plan, rosterEmbyros] = await Promise.all([ + const [plan, rosterEmbryos] = await Promise.all([ this.loadPlan(), this._loadRoster(), ]); this.activePlan = plan; - this._rosterEmbyros = rosterEmbyros; + this._rosterEmbryos = rosterEmbryos; this.isLive = plan !== null; this.render(this.activeStrategy); }, 500); @@ -353,14 +353,14 @@ const ExperimentOverview = { // Roster lens: visible when embryos + role metadata are both available. // Gracefully absent if either fetch failed or returned nothing (backward compat // with plans that pre-date D2 — spine renders exactly as before). - const rosterEmbyros = this._rosterEmbyros || []; + const rosterEmbryos = this._rosterEmbryos || []; const rolesMap = this._rolesMap; - const rosterCtx = (rosterEmbyros.length > 0 && rolesMap && rolesMap.size > 0) - ? { embryos: rosterEmbyros, rolesMap } + const rosterCtx = (rosterEmbryos.length > 0 && rolesMap && rolesMap.size > 0) + ? { embryos: rosterEmbryos, rolesMap } : null; const rosterHtml = rosterCtx ? ` - ${this._renderRosterLens(rosterEmbyros, rolesMap, plan, ESC)}` + ${this._renderRosterLens(rosterEmbryos, rolesMap, plan, ESC)}` : ''; const spineLabel = rosterCtx ? '' @@ -613,6 +613,7 @@ const ExperimentOverview = { // that covers a given embryo (by embryo_id + role). // Scope resolution: global → covers all; role → covers matching role; // embryos → covers listed ids. Returns '' when no tactic found. + // NOTE: mirrors resolve_scope_embryos() in gently/app/orchestration/role_scope.py — keep in sync if a new scope mode is added. _resolveCurrentTactic(embryoId, role, plan) { if (!plan || !Array.isArray(plan.tactics)) return ''; const covers = (t) => { diff --git a/gently/ui/web/timelapse_tracker.py b/gently/ui/web/timelapse_tracker.py index f980001d..965e4019 100644 --- a/gently/ui/web/timelapse_tracker.py +++ b/gently/ui/web/timelapse_tracker.py @@ -216,6 +216,8 @@ def handle_event(self, event_type: str, data: dict): emb["stage_y_um"] = data["y"] if data.get("role"): emb["role"] = data["role"] + if "strain" in data: + emb["strain"] = data.get("strain") if data.get("uid"): emb["uid"] = data["uid"] if data.get("user_label"): @@ -486,6 +488,7 @@ def seed_from_experiment(self, experiment) -> int: "x": x, "y": y, "role": getattr(emb, "role", "test"), + "strain": getattr(emb, "strain", None), "user_label": getattr(emb, "user_label", None), "confidence": getattr(emb, "detection_confidence", None), }, diff --git a/tests/test_embryo_strain.py b/tests/test_embryo_strain.py index 474a77ef..7f2836eb 100644 --- a/tests/test_embryo_strain.py +++ b/tests/test_embryo_strain.py @@ -143,3 +143,92 @@ def test_positions_endpoint_strain_none_when_absent(): point = resp.json()["embryos"][0] assert "strain" in point assert point["strain"] is None + + +# =========================================================================== +# TimelapseStateTracker – Fix 1 (strain surfaces through tracker) +# =========================================================================== + + +def test_tracker_embryo_detected_sets_strain(): + """EMBRYO_DETECTED with strain populates the tracker embryo dict.""" + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + tracker = TimelapseStateTracker() + tracker.handle_event( + "EMBRYO_DETECTED", + { + "embryo_id": "e1", + "x": 100.0, + "y": 200.0, + "role": "test", + "strain": "pan-nuclear GFP", + "uid": "abc", + }, + ) + assert tracker.embryos["e1"]["strain"] == "pan-nuclear GFP" + + +def test_tracker_embryo_detected_strain_none_propagates(): + """EMBRYO_DETECTED with explicit strain=None still writes the key.""" + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + tracker = TimelapseStateTracker() + tracker.handle_event( + "EMBRYO_DETECTED", + { + "embryo_id": "e2", + "x": 0.0, + "y": 0.0, + "role": "test", + "strain": None, + }, + ) + assert "strain" in tracker.embryos["e2"] + assert tracker.embryos["e2"]["strain"] is None + + +def test_tracker_seed_from_experiment_includes_strain(): + """seed_from_experiment copies strain from EmbryoState into tracker.""" + from types import SimpleNamespace + + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + emb = SimpleNamespace( + stage_position={"x": 50.0, "y": 60.0}, + uid="u1", + role="test", + strain="H2B-mCherry", + user_label=None, + detection_confidence=0.95, + ) + experiment = SimpleNamespace(embryos={"e3": emb}) + + tracker = TimelapseStateTracker() + seeded = tracker.seed_from_experiment(experiment) + + assert seeded == 1 + assert tracker.embryos["e3"]["strain"] == "H2B-mCherry" + + +def test_tracker_seed_from_experiment_strain_absent_is_none(): + """seed_from_experiment with no strain attribute → tracker strain is None.""" + from types import SimpleNamespace + + from gently.ui.web.timelapse_tracker import TimelapseStateTracker + + emb = SimpleNamespace( + stage_position={"x": 10.0, "y": 20.0}, + uid="u2", + role="control", + # no .strain attribute + user_label=None, + detection_confidence=None, + ) + experiment = SimpleNamespace(embryos={"e4": emb}) + + tracker = TimelapseStateTracker() + tracker.seed_from_experiment(experiment) + + assert "strain" in tracker.embryos["e4"] + assert tracker.embryos["e4"]["strain"] is None From 4353c10d71f1353eef0e60215f626ba13fb1ceb9 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 05:47:31 +0530 Subject: [PATCH 237/314] chore(d2): untrack .superpowers SDD scratch (committed by a subagent) --- .superpowers/sdd/d2-task-2-report.md | 106 --------------------------- 1 file changed, 106 deletions(-) delete mode 100644 .superpowers/sdd/d2-task-2-report.md diff --git a/.superpowers/sdd/d2-task-2-report.md b/.superpowers/sdd/d2-task-2-report.md deleted file mode 100644 index 5a9a33c4..00000000 --- a/.superpowers/sdd/d2-task-2-report.md +++ /dev/null @@ -1,106 +0,0 @@ -# D2 Task 2 Report — roles-as-use: lineaging + subject/reference class - -## What was done - -### 1. `role_class` field added to `EmbryoRole` - -Added `role_class: str = "subject"` as the last field in the frozen dataclass -(`gently/harness/roles.py`, after `no_object_consecutive_terminal`). Default is -`"subject"` so any existing keyword-constructed `EmbryoRole` is safe without -change. Docstring in the module header updated to document the two values -(`"subject"` | `"reference"`) and their intent. - -### 2. `role_class` set on all three existing REGISTRY entries - -| Role | role_class | Rationale | -|-------------|-------------|------------------------------------------------| -| `test` | `"subject"` | The biological subject — the precious sample. | -| `unassigned`| `"subject"` | Safe default: protect like a subject until resolved. | -| `calibration`| `"reference"` | Reference embryo for staging/calibration. | - -### 3. `lineaging` entry added to REGISTRY - -```python -"lineaging": EmbryoRole( - name="lineaging", - description=( - "Lineage-tracing reference — tracks nuclei/divisions; often a " - "pan-nuclear strain but the strain is separate from this use." - ), - default_cadence_seconds=300.0, - detector_name="perception", # nuclear pipeline, same as calibration - photodose_budget_multiplier=5.0, - ui_color="#33cc88", # teal-green — distinct from cyan/magenta - ui_icon="triangle", - no_object_consecutive_terminal=2, # reference: gone == gone - role_class="reference", -) -``` - -Color distinctness verified: `#33cc88` (teal-green) ≠ `#00cccc` (calibration -cyan) ≠ `#ff66cc` (test magenta). - -### 4. EmbryoRole construction-site check - -``` -grep -rn "EmbryoRole(" gently/ -``` - -Result: only `gently/harness/roles.py` — the three (now four) REGISTRY entries, -all using keyword arguments. No positional construction anywhere. The new field's -default makes it safe for any future keyword construction too. - -### 5. What was NOT touched (detector — staged) - -- No changes to detector/perception wiring, session logic, cadence policy, - or any consumer of `EmbryoRole` fields. -- `detector_name="perception"` on `lineaging` mirrors `calibration` — the - staged approach defers any strain→detector decoupling to spec §4 future work. -- `DEFAULT_ROLE`, `get_role`, `is_valid_role`, `list_roles` — all unchanged - (they work generically over REGISTRY). - -## TDD evidence - -File: `tests/test_roles_registry.py` (created, 22 tests) - -``` -tests/test_roles_registry.py::test_test_role_class_is_subject PASSED -tests/test_roles_registry.py::test_unassigned_role_class_is_subject PASSED -tests/test_roles_registry.py::test_calibration_role_class_is_reference PASSED -tests/test_roles_registry.py::test_lineaging_role_exists_in_registry PASSED -tests/test_roles_registry.py::test_lineaging_role_class_is_reference PASSED -tests/test_roles_registry.py::test_lineaging_ui_color PASSED -tests/test_roles_registry.py::test_lineaging_ui_icon PASSED -tests/test_roles_registry.py::test_lineaging_description_contains_lineage PASSED -tests/test_roles_registry.py::test_lineaging_has_cadence PASSED -tests/test_roles_registry.py::test_get_role_lineaging_returns_correct_entry PASSED -tests/test_roles_registry.py::test_is_valid_role_lineaging_true PASSED -tests/test_roles_registry.py::test_is_valid_role_unknown_false PASSED -tests/test_roles_registry.py::test_list_roles_includes_lineaging PASSED -tests/test_roles_registry.py::test_list_roles_sorted PASSED -tests/test_roles_registry.py::test_get_role_unknown_raises_key_error PASSED -tests/test_roles_registry.py::test_test_role_fields_unchanged PASSED -tests/test_roles_registry.py::test_calibration_role_fields_unchanged PASSED -tests/test_roles_registry.py::test_unassigned_role_fields_unchanged PASSED -tests/test_roles_registry.py::test_default_role_is_in_registry PASSED -tests/test_roles_registry.py::test_default_role_is_test PASSED -tests/test_roles_registry.py::test_embryo_role_is_frozen PASSED -tests/test_roles_registry.py::test_embryo_role_default_role_class PASSED -22 passed in 0.12s -``` - -Full suite (excluding 3 pre-existing collection errors): -- Before task-2 changes: 41 pre-existing failures + 4 from this test file against old code = 45 total -- After task-2 changes: 32 failures, 765 passed — all 32 failures pre-existing (test_eval, test_mesh_service, test_prompt_manager and others), zero new failures introduced. - -## Files changed - -- `gently/harness/roles.py` — `role_class` field on dataclass + all 4 registry entries -- `tests/test_roles_registry.py` — new, 22 tests - -## Concerns - -None. The change is additive and isolated to `roles.py`. The frozen dataclass -with a defaulted field is backward-compatible. The `lineaging` detector choice -(`"perception"`) is intentionally the same as calibration — decoupling -detector-from-use is explicitly deferred to spec §4. From a7e41e5b344113b80c48437749ab1069b4406c15 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:15:16 +0530 Subject: [PATCH 238/314] =?UTF-8?q?docs(f):=20session=E2=86=94plans=20link?= =?UTF-8?q?/delink=20spec=20+=20plan=20(4=20tasks);=20base=20plans=20defer?= =?UTF-8?q?red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-29-session-plan-linking.md | 39 +++++++++++ .../2026-06-29-session-plan-linking-design.md | 64 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-session-plan-linking.md create mode 100644 docs/superpowers/specs/2026-06-29-session-plan-linking-design.md diff --git a/docs/superpowers/plans/2026-06-29-session-plan-linking.md b/docs/superpowers/plans/2026-06-29-session-plan-linking.md new file mode 100644 index 00000000..f58b8a41 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-session-plan-linking.md @@ -0,0 +1,39 @@ +# Session ↔ plans link/delink (F) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Let a session link to multiple plan items, with link/delink from both the Plans tab and the session view. Source of truth = `PlanItem.session_ids` (reverse-query for a session's plans). Base plans deferred. + +**Architecture:** Data-layer `unlink_plan_item_session` + `get_plan_items_for_session` (reverse query); link/delink HTTP endpoints; link/delink UI on the Plans-tab item detail + a "Linked plans" panel on the session/Operations view. + +## Global Constraints +- Source of truth for session↔plan-item linkage = `PlanItem.session_ids` (a list; `link_plan_item_session` appends — `file_store.py:1415`). A session's plans = reverse query. Do NOT add a plan_item list to SessionIntent. +- Campaign edge stays `SessionIntent.campaign_ids` (`link/unlink_session_campaign`). Linking a session to a plan item also `link_session_campaign(item.campaign_id)`. Delink (v1) only removes the plan-item edge. +- Endpoints mirror `gently/ui/web/routes/campaigns.py` (the item-detail route at :212, PATCH at :324). UI mirrors `campaigns.js` item-detail rendering (~:789-802 Sessions section). +- Repo/base plans DEFERRED (spec §4) — do not build. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Data layer — unlink + reverse-query +**Files:** Modify `gently/harness/memory/file_store.py` — add `unlink_plan_item_session(item_id, session_id)` (remove session from the item's `session_ids`, clear back-compat `session_id` if equal, persist to the campaign plan, fire `_notify_plan_change`; idempotent if absent) and `get_plan_items_for_session(session_id) -> list[PlanItem]` (iterate `get_active_campaigns()` → `get_plan_items(campaign_id)` → filter `session_id in (item.session_ids or [])`). Test: `tests/test_session_plan_linking_store.py`. +- [ ] Confirm `link_plan_item_session` (:1415) — how it appends to session_ids + persists the plan item to `plan/current.yaml` — and mirror the persistence for unlink. Confirm `get_active_campaigns`/`get_plan_items`. +- [ ] TDD: link a session to 2 items (different campaigns) → `get_plan_items_for_session` returns both; `unlink_plan_item_session` removes it from one (other remains); unlink absent → no-op; back-compat `session_id` cleared when it matched. `pytest tests/test_session_plan_linking_store.py -v`; `pytest -q` clean. Commit `feat(f): unlink_plan_item_session + get_plan_items_for_session`. + +### Task 2: Link/delink endpoints +**Files:** Modify `gently/ui/web/routes/campaigns.py` — `POST /api/campaigns/{cid}/items/{item_id}/sessions` (body `{session_id}` → `link_plan_item_session` + `link_session_campaign`; return updated sessions) and `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` (→ `unlink_plan_item_session`); add `GET /api/sessions/{id}/plans` in `gently/ui/web/routes/sessions.py` (→ `get_plan_items_for_session`, return `[{id,title,campaign_id,status}]`). Test: `tests/test_session_plan_linking_routes.py`. +- [ ] Mirror the campaigns route store resolution (`server.context_store`/`gently_store`) + the item-detail route (:212). The session-plans route mirrors `sessions.py` resolution. Graceful (404 item/session; empty list). +- [ ] TDD (TestClient + mock store): POST links (store.link_plan_item_session + link_session_campaign called); DELETE delinks (unlink called); GET returns the session's plans; bad ids handled. `pytest tests/test_session_plan_linking_routes.py -v`; `pytest -q` clean. Commit `feat(f): session↔plan link/delink endpoints`. + +### Task 3: Plans-tab link/delink UI +**Files:** Modify `gently/ui/web/static/js/campaigns.js` (the item-detail Sessions section ~:789-802 — add a `[+ link session]` picker (sessions from `/api/sessions`) + a `[delink]` button per session, calling the POST/DELETE endpoints then re-rendering the item) + `gently/ui/web/static/css/campaigns.css` (the control styles). +- [ ] Render the link control + per-session delink in the item-detail Sessions list; wire to the endpoints; optimistic re-render / refetch the item on success; keep the "No linked sessions" empty state with the link control. `node --check`; build a Chrome-MCP harness (real campaigns.js + a stubbed item-detail + /api/sessions) for the controller to audit. Commit `feat(f): Plans-tab session link/delink controls`. + +### Task 4: Session "Linked plans" panel +**Files:** Modify the session/Operations view JS (`gently/ui/web/static/js/experiment-overview.js` or the session view `review.js` — whichever renders the active session header; CONFIRM which surface) — add a "Linked plans" panel listing `/api/sessions/{id}/plans` rows (title · campaign · status · `[delink]`) + a `[+ link to a plan]` picker (plan items from active campaigns via `/api/campaigns` tree); wire to the same endpoints; CSS as needed. +- [ ] Confirm the session-scoped render surface + how it knows the current session_id. Render the panel reading `/api/sessions/{id}/plans`; link/delink hit the same endpoints + refresh; symmetric with Task 3. Backward compat: no linked plans → a tidy empty state, no crash. `node --check`; Chrome-MCP harness audit. Commit `feat(f): session Linked-plans panel (link/delink)`. + +## Self-Review +- Data→T1; endpoints→T2; Plans-tab UI→T3; session panel→T4. ✓ +- Open confirmations: link_plan_item_session persistence (T1), campaigns/sessions route patterns (T2), the campaigns.js item-detail render seam (T3), the session-scoped render surface + current session_id (T4). +- Type consistency: plan-item linkage via `PlanItem.session_ids`; the endpoints + both UIs hit the same POST/DELETE/GET; a link from one surface appears on the other after refresh. diff --git a/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md new file mode 100644 index 00000000..4b3be8f6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-session-plan-linking-design.md @@ -0,0 +1,64 @@ +# Design: Session ↔ plans link/delink (sub-project F) + +Status: design 2026-06-29 (after recon + user steering). Branch `feature/session-plan-linking` (off D2). +Lets a session link to MULTIPLE plan items, with link/delink from BOTH the Plans tab and the session +view. "Repo/base plans" is DEFERRED (user has ideas — separate follow-on). + +## 0. What exists (recon) +- Session↔**campaign**: many-to-many (`SessionIntent.campaign_ids` list; `link/unlink_session_campaign`). +- Session↔**plan-item**: `PlanItem.session_ids` is a LIST (a session can already appear under multiple + items in storage) — but a session's *own* notion of "its plans" is a single `active_plan_item_id` + pointer, and `SessionIntent` stores no plan-item ids. +- `attach_session_to_plan` appends to `item.session_ids` (via `link_plan_item_session`) but overwrites + the single active pointer; `detach_session_from_plan` only clears the pointer (NOT a data delink). +- Plans tab (`campaigns.js:789-802`) shows a per-item read-only Sessions list ("No linked sessions"). +- NO link/delink endpoint or UI anywhere; NO `unlink_plan_item_session`; session endpoint returns no linkage. + +## 1. The model — source of truth = `PlanItem.session_ids` +A session's linked plan items = the reverse query over plan items whose `session_ids` includes the +session. No new field on SessionIntent (avoids dual source of truth). Multi-plan falls out naturally +(a session can be in many items' `session_ids`). The campaign edge stays on `SessionIntent.campaign_ids`. + +- **Link** session↔plan-item: `link_plan_item_session(item_id, session_id)` (exists, appends) + + `link_session_campaign(session_id, item.campaign_id)` (exists). +- **Delink**: NEW `unlink_plan_item_session(item_id, session_id)` — remove the session from + `item.session_ids` (+ clear the back-compat `session_id` if it pointed there); fire `_notify_plan_change`. + Campaign edge: leave it unless no other item of that campaign links the session (refinement — for v1, + delink only touches the plan-item edge; campaign delink stays the existing separate control). +- **Session's plans**: NEW `get_plan_items_for_session(session_id) -> list[PlanItem]` (reverse query + across `get_active_campaigns` → `get_plan_items` → filter `session_id in item.session_ids`). + +## 2. Endpoints (mirror `routes/campaigns.py`) +- `POST /api/campaigns/{cid}/items/{item_id}/sessions` body `{session_id}` → link (link_plan_item_session + + link_session_campaign). Returns the updated item sessions. +- `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` → delink (unlink_plan_item_session). +- `GET /api/sessions/{id}/plans` → the session's linked plan items (id, title, campaign_id, status) via + `get_plan_items_for_session`. (A new sub-route; leaves the existing session payload untouched.) + +## 3. UI — both surfaces +### 3.1 Plans tab item-detail (`campaigns.js` ~:789-802) +The existing per-item Sessions list gains: a **[+ link session]** control (a picker of recent sessions +from `/api/sessions`) and a **[delink]** button per listed session. Calls the POST/DELETE endpoints, +re-renders the item detail. Empty state keeps "No linked sessions" + the link control. + +### 3.2 Session / Operations view — "Linked plans" panel +A panel (in the Operations/experiment view header or a session detail strip) listing the session's +linked plan items (from `/api/sessions/{id}/plans`): each row `plan item title · campaign · status · +[delink]`, plus **[+ link to a plan]** (a picker of plan items from the active campaigns). Symmetric +with 3.1 — link/delink from either side; both hit the same endpoints + refresh. + +## 4. Out of scope (deferred) +- **Repo/base plans** — the user has ideas; a separate follow-on (a repo plans library / seed). Noted, + not built here. +- Campaign-edge auto-cleanup on plan delink (v1 leaves the campaign link; refine later). +- Reworking `attach_session_to_plan`/`detach` agent tools beyond what's needed — the data-layer + delink (`unlink_plan_item_session`) is added; wiring a `detach` that calls it is a small optional add. + +## 5. Testing +- Data layer: `unlink_plan_item_session` removes the session (+ back-compat session_id); idempotent on + absent; `get_plan_items_for_session` reverse-query returns the right items across campaigns; multi-plan + (a session under 2 items) round-trips. +- Endpoints: POST links (item.session_ids gains it + campaign linked); DELETE delinks; GET returns the + session's plans; mirror `tests/test_*route*` with a mock store. +- UI: link/delink controls on both surfaces (node --check + Chrome audit of the Plans-tab item detail + + the session "Linked plans" panel); link from one side shows on the other after refresh. From 775392b418750186b5ec9f4d6c86512c881e6c62 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:19:33 +0530 Subject: [PATCH 239/314] feat(f): unlink_plan_item_session + get_plan_items_for_session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two missing data-layer pieces for session↔plan linking (Task 1): - unlink_plan_item_session: mirrors link's load/persist/notify; idempotent; clears back-compat session_id scalar when the last session is removed. - get_plan_items_for_session: reverse query across active campaigns; deduped by item id; back-compat via _dict_to_plan_item's existing normalisation. 10 new tests, 0 regressions. --- gently/harness/memory/file_store.py | 45 ++++++ tests/test_session_plan_linking_store.py | 167 +++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 tests/test_session_plan_linking_store.py diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py index 6eaab365..2e4fb23c 100644 --- a/gently/harness/memory/file_store.py +++ b/gently/harness/memory/file_store.py @@ -1437,6 +1437,51 @@ def link_plan_item_session( self._notify_plan_change(campaign_id) return True + def unlink_plan_item_session(self, item_id: str, session_id: str) -> bool: + """Remove a session from a plan item's session_ids list. + + Mirrors the load/persist/notify pattern of link_plan_item_session. + Clears the back-compat scalar session_id when it matched the removed + session (sets it to the most-recent remaining session_id, or None). + Idempotent: returns False without writing if the session isn't linked. + Returns True on successful removal. + """ + loc = self._find_plan_item_location(item_id) + if not loc: + return False + campaign_id, items, idx = loc + item = items[idx] + sids = item.get("session_ids") or ([item["session_id"]] if item.get("session_id") else []) + if session_id not in sids: + return False + sids = [s for s in sids if s != session_id] + item["session_ids"] = sids + item["session_id"] = sids[-1] if sids else None # back-compat: most recent remaining + item["updated_at"] = self._now() + self._write_plan_items(campaign_id, items) + self._notify_plan_change(campaign_id) + return True + + def get_plan_items_for_session(self, session_id: str) -> list["PlanItem"]: + """Return all plan items linked to a session. + + Iterates active campaigns only (mirrors the normal read path). + Back-compat: matches items whose scalar session_id equals the query even + when session_ids is empty (old items written before the list field existed). + Deduplicates by item id. + """ + seen: set[str] = set() + result: list[PlanItem] = [] + for campaign in self.get_active_campaigns(): + for item in self.get_plan_items(campaign.id): + if item.id in seen: + continue + # session_ids is already populated from back-compat in _dict_to_plan_item + if session_id in (item.session_ids or []) or item.session_id == session_id: + seen.add(item.id) + result.append(item) + return result + def skip_plan_item(self, item_id: str, reason: str | None = None): self.update_plan_item( item_id, diff --git a/tests/test_session_plan_linking_store.py b/tests/test_session_plan_linking_store.py new file mode 100644 index 00000000..5cd72466 --- /dev/null +++ b/tests/test_session_plan_linking_store.py @@ -0,0 +1,167 @@ +""" +FileContextStore: session ↔ plan-item link/delink (Task 1 — data layer). + +Tests: +- link a session to items in 2 different campaigns +- get_plan_items_for_session returns both items +- unlink_plan_item_session removes one (the other remains) +- get_plan_items_for_session returns only the remaining item after unlink +- unlinking a session that isn't linked → False, no side-effects +- back-compat scalar session_id is cleared when the unlinked session matched it +""" + +import pytest + +from gently.harness.memory.model import PlanItemStatus, PlanItemType + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_item(store, campaign_id: str, title: str) -> str: + """Create a bench plan item and return its id.""" + return store.create_plan_item( + campaign_id=campaign_id, + type=PlanItemType.BENCH.value, + title=title, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def two_campaigns(file_context_store): + """Two active campaigns, each with one plan item. Returns (store, cid1, cid2, item1, item2).""" + store = file_context_store + cid1 = store.create_campaign(description="Campaign Alpha") + cid2 = store.create_campaign(description="Campaign Beta") + item1 = _make_item(store, cid1, "Step A — bench prep") + item2 = _make_item(store, cid2, "Step B — gel run") + return store, cid1, cid2, item1, item2 + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestLinkAndReverseQuery: + def test_link_and_get_plan_items_for_session(self, two_campaigns): + """Linking a session to 2 items across 2 campaigns; reverse query returns both.""" + store, _cid1, _cid2, item1, item2 = two_campaigns + session_id = "sess_abc" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.link_plan_item_session(item2, session_id, set_in_progress=False) + + items = store.get_plan_items_for_session(session_id) + ids = {it.id for it in items} + assert item1 in ids + assert item2 in ids + assert len(ids) == 2 + + def test_session_not_in_unrelated_item(self, two_campaigns): + """Items not linked to the session don't appear in the reverse query.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_xyz" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + items = store.get_plan_items_for_session(session_id) + assert len(items) == 1 + assert items[0].id == item1 + + def test_unknown_session_returns_empty(self, two_campaigns): + """A session with no links → empty list, no error.""" + store, *_ = two_campaigns + result = store.get_plan_items_for_session("sess_ghost") + assert result == [] + + +class TestUnlink: + def test_unlink_removes_from_one_campaign(self, two_campaigns): + """Unlinking a session from one item leaves the other item still linked.""" + store, _cid1, _cid2, item1, item2 = two_campaigns + session_id = "sess_def" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.link_plan_item_session(item2, session_id, set_in_progress=False) + + result = store.unlink_plan_item_session(item1, session_id) + assert result is True + + remaining = store.get_plan_items_for_session(session_id) + ids = {it.id for it in remaining} + assert item1 not in ids + assert item2 in ids + + def test_unlink_idempotent_returns_false(self, two_campaigns): + """Unlinking a session that isn't linked returns False without crashing.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + result = store.unlink_plan_item_session(item1, "sess_nothere") + assert result is False + + def test_unlink_unknown_item_returns_false(self, two_campaigns): + """Unlinking from an item that doesn't exist returns False.""" + store, *_ = two_campaigns + result = store.unlink_plan_item_session("item_ghost", "sess_x") + assert result is False + + def test_unlink_no_side_effect_when_not_linked(self, two_campaigns): + """After a no-op unlink, the item's session_ids are unchanged.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_linked" + store.link_plan_item_session(item1, session_id, set_in_progress=False) + + store.unlink_plan_item_session(item1, "sess_other") # different session — no-op + + item = store.get_plan_item(item1) + assert session_id in item.session_ids + + def test_full_unlink_clears_all_sessions(self, two_campaigns): + """After unlinking all sessions, get_plan_items_for_session returns nothing.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_ghi" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + store.unlink_plan_item_session(item1, session_id) + + remaining = store.get_plan_items_for_session(session_id) + assert remaining == [] + + +class TestBackCompatSessionId: + def test_back_compat_cleared_on_unlink_when_matched(self, two_campaigns): + """Back-compat scalar session_id is set to None when the last session is unlinked.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_jkl" + + store.link_plan_item_session(item1, session_id, set_in_progress=False) + item_before = store.get_plan_item(item1) + assert item_before.session_id == session_id # back-compat set by link + + store.unlink_plan_item_session(item1, session_id) + + item_after = store.get_plan_item(item1) + assert item_after.session_id is None + assert item_after.session_ids == [] + + def test_back_compat_set_to_most_recent_remaining(self, two_campaigns): + """When one of two sessions is unlinked, back-compat session_id = remaining session.""" + store, _cid1, _cid2, item1, _item2 = two_campaigns + sid1 = "sess_first" + sid2 = "sess_second" + + store.link_plan_item_session(item1, sid1, set_in_progress=False) + store.link_plan_item_session(item1, sid2, set_in_progress=False) + + store.unlink_plan_item_session(item1, sid1) + + item = store.get_plan_item(item1) + assert sid2 in item.session_ids + assert item.session_id == sid2 # most recent remaining + assert sid1 not in item.session_ids From 26ea06a88f1d53a79179c3966078684259661a64 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:26:29 +0530 Subject: [PATCH 240/314] =?UTF-8?q?feat(f):=20session=E2=86=94plan=20link/?= =?UTF-8?q?delink=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/campaigns/{cid}/items/{item_id}/sessions — links a session to a plan item (link_plan_item_session + link_session_campaign) and returns the campaign's updated sessions. DELETE …/sessions/{sid} delinks via unlink_plan_item_session, returns {unlinked: bool}. GET /api/sessions/{session_id}/plans reads plan items for a session via get_plan_items_for_session, maps to [{id,title,campaign_id,status}]. 16/16 route tests pass; no new failures in suite (32 pre-existing). --- gently/ui/web/routes/campaigns.py | 32 +++ gently/ui/web/routes/sessions.py | 23 ++ tests/test_session_plan_linking_routes.py | 274 ++++++++++++++++++++++ 3 files changed, 329 insertions(+) create mode 100644 tests/test_session_plan_linking_routes.py diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index b0dbae11..857ed2db 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -254,6 +254,38 @@ async def get_item_detail(campaign_id: str, item_id: str): "sessions": [_serialize(s) for s in sessions], } + @router.post("/api/campaigns/{campaign_id}/items/{item_id}/sessions") + async def link_session_to_item(campaign_id: str, item_id: str, request: Request): + """Link a session to a plan item (appends) and record it against the campaign.""" + cs = _get_store() + campaign = _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + body = await request.json() + session_id = body.get("session_id") + if not session_id: + raise HTTPException(status_code=400, detail="session_id required") + + cs.link_plan_item_session(item_id, session_id) + cs.link_session_campaign(session_id, campaign.id) + + sessions = cs.get_sessions_for_campaign(item.campaign_id) + return {"sessions": [_serialize(s) for s in sessions]} + + @router.delete("/api/campaigns/{campaign_id}/items/{item_id}/sessions/{session_id}") + async def unlink_session_from_item(campaign_id: str, item_id: str, session_id: str): + """Remove a session link from a plan item. Returns {unlinked: bool}.""" + cs = _get_store() + _resolve(cs, campaign_id) + item = cs.get_plan_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Plan item not found") + + unlinked = cs.unlink_plan_item_session(item_id, session_id) + return {"unlinked": unlinked} + @router.get("/api/campaigns/{campaign_id}/planned-sessions") async def get_planned_sessions(campaign_id: str): """Planned sessions linked to a campaign.""" diff --git a/gently/ui/web/routes/sessions.py b/gently/ui/web/routes/sessions.py index ccbc3a93..505e7f0d 100644 --- a/gently/ui/web/routes/sessions.py +++ b/gently/ui/web/routes/sessions.py @@ -204,6 +204,29 @@ async def resume_session(session_id: str): "rehydrated_projections": rehydrated, } + @router.get("/api/sessions/{session_id}/plans") + async def get_session_plans(session_id: str): + """Plan items linked to a session, via the context store.""" + cs = getattr(server, "context_store", None) + if cs is None: + return {"plans": []} + try: + items = cs.get_plan_items_for_session(session_id) + except Exception as e: + logger.warning("get_plan_items_for_session failed for %s: %s", session_id, e) + return {"plans": []} + return { + "plans": [ + { + "id": item.id, + "title": item.title, + "campaign_id": item.campaign_id, + "status": item.status.value, + } + for item in items + ] + } + @router.get("/api/sessions/{session_id}") async def get_session(session_id: str): """Get session state for review, from the live FileStore. diff --git a/tests/test_session_plan_linking_routes.py b/tests/test_session_plan_linking_routes.py new file mode 100644 index 00000000..b9542d4a --- /dev/null +++ b/tests/test_session_plan_linking_routes.py @@ -0,0 +1,274 @@ +"""Route tests: session ↔ plan link/delink HTTP endpoints (Task 2 of F). + +Endpoints under test: + POST /api/campaigns/{cid}/items/{item_id}/sessions → link session to item + DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{sid} → delink session from item + GET /api/sessions/{session_id}/plans → plan items for a session +""" + +from datetime import datetime +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.harness.memory.model import PlanItem, PlanItemStatus, PlanItemType, SessionIntent + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_item( + item_id: str, + campaign_id: str, + title: str, + status: PlanItemStatus = PlanItemStatus.PLANNED, +) -> PlanItem: + return PlanItem( + id=item_id, + campaign_id=campaign_id, + type=PlanItemType.BENCH, + title=title, + status=status, + ) + + +def _make_app(mock_cs): + """FastAPI test app with campaign + session routers wired to mock_cs.""" + from gently.ui.web.routes.campaigns import create_router as campaign_router + from gently.ui.web.routes.sessions import create_router as session_router + + app = FastAPI() + + class _Server: + context_store = mock_cs + agent_bridge = None + gently_store = None + + server = _Server() + app.include_router(campaign_router(server)) + app.include_router(session_router(server)) + return app + + +def _mock_cs(item=None, sessions=None): + """Build a mock context store with sensible defaults.""" + cs = MagicMock() + cs.resolve_campaign.return_value = MagicMock(id="c1") + cs.get_plan_item.return_value = item + cs.get_sessions_for_campaign.return_value = sessions if sessions is not None else [] + cs.unlink_plan_item_session.return_value = True + cs.get_plan_items_for_session.return_value = [] + return cs + + +# --------------------------------------------------------------------------- +# POST /api/campaigns/{cid}/items/{item_id}/sessions +# --------------------------------------------------------------------------- + + +class TestPostLinkSession: + def test_calls_link_plan_item_session(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + cs.link_plan_item_session.assert_called_once_with("i1", "s1") + + def test_calls_link_session_campaign_with_resolved_id(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.resolve_campaign.return_value = MagicMock(id="c1") + client = TestClient(_make_app(cs)) + + client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + cs.link_session_campaign.assert_called_once_with("s1", "c1") + + def test_returns_sessions_list(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item, sessions=[]) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + body = r.json() + assert "sessions" in body + assert isinstance(body["sessions"], list) + + def test_returns_serialized_session_intents(self): + item = _make_item("i1", "c1", "Step A") + si = SessionIntent( + session_id="s1", + campaign_ids=["c1"], + created_at=datetime(2026, 6, 1), + ) + cs = _mock_cs(item=item, sessions=[si]) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 200 + sessions = r.json()["sessions"] + assert len(sessions) == 1 + assert sessions[0]["session_id"] == "s1" + assert sessions[0]["campaign_ids"] == ["c1"] + + def test_missing_campaign_is_404(self): + cs = _mock_cs() + cs.resolve_campaign.return_value = None + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/nope/items/i1/sessions", json={"session_id": "s1"}) + + assert r.status_code == 404 + + def test_missing_item_is_404(self): + cs = _mock_cs(item=None) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/nope/sessions", json={"session_id": "s1"}) + + assert r.status_code == 404 + + def test_missing_session_id_body_is_400(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + client = TestClient(_make_app(cs)) + + r = client.post("/api/campaigns/c1/items/i1/sessions", json={}) + + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id} +# --------------------------------------------------------------------------- + + +class TestDeleteUnlinkSession: + def test_calls_unlink_plan_item_session(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = True + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s1") + + assert r.status_code == 200 + cs.unlink_plan_item_session.assert_called_once_with("i1", "s1") + + def test_returns_unlinked_true(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = True + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s1") + + assert r.status_code == 200 + assert r.json()["unlinked"] is True + + def test_returns_unlinked_false_when_not_linked(self): + item = _make_item("i1", "c1", "Step A") + cs = _mock_cs(item=item) + cs.unlink_plan_item_session.return_value = False + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/i1/sessions/s99") + + assert r.status_code == 200 + assert r.json()["unlinked"] is False + + def test_missing_item_is_404(self): + cs = _mock_cs(item=None) + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/c1/items/nope/sessions/s1") + + assert r.status_code == 404 + + def test_missing_campaign_is_404(self): + cs = _mock_cs() + cs.resolve_campaign.return_value = None + client = TestClient(_make_app(cs)) + + r = client.delete("/api/campaigns/nope/items/i1/sessions/s1") + + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /api/sessions/{session_id}/plans +# --------------------------------------------------------------------------- + + +class TestGetSessionPlans: + def test_returns_mapped_plan_items(self): + cs = _mock_cs() + cs.get_plan_items_for_session.return_value = [ + _make_item("i1", "c1", "Step A", PlanItemStatus.PLANNED), + _make_item("i2", "c2", "Step B", PlanItemStatus.IN_PROGRESS), + ] + client = TestClient(_make_app(cs)) + + r = client.get("/api/sessions/s1/plans") + + assert r.status_code == 200 + plans = r.json()["plans"] + assert len(plans) == 2 + assert plans[0] == { + "id": "i1", + "title": "Step A", + "campaign_id": "c1", + "status": "planned", + } + assert plans[1] == { + "id": "i2", + "title": "Step B", + "campaign_id": "c2", + "status": "in_progress", + } + + def test_empty_list_for_unknown_session(self): + cs = _mock_cs() + cs.get_plan_items_for_session.return_value = [] + client = TestClient(_make_app(cs)) + + r = client.get("/api/sessions/ghost/plans") + + assert r.status_code == 200 + assert r.json()["plans"] == [] + + def test_calls_store_with_session_id(self): + cs = _mock_cs() + client = TestClient(_make_app(cs)) + + client.get("/api/sessions/sess_abc/plans") + + cs.get_plan_items_for_session.assert_called_once_with("sess_abc") + + def test_graceful_when_no_context_store(self): + from gently.ui.web.routes.sessions import create_router as session_router + + app = FastAPI() + + class _ServerNoCS: + context_store = None + agent_bridge = None + gently_store = None + + server = _ServerNoCS() + app.include_router(session_router(server)) + client = TestClient(app) + + r = client.get("/api/sessions/s1/plans") + + assert r.status_code == 200 + assert r.json()["plans"] == [] From 73edb37e09d2da172601d2d4f541b2da54d29081 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 07:41:44 +0530 Subject: [PATCH 241/314] feat(f): Plans-tab session link/delink controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add [+ link session] picker and per-session [×] delink to the item-detail Sessions section. Sessions rendered from item.session_ids (item-scoped), not the campaign pool. Backend get_item_detail now filters to item.session_ids. Picker: click opens inline select from /api/sessions (already-linked filtered out), Link → POST /api/.../sessions → selectItem re-fetch; Cancel closes. Delink: × → DELETE /api/.../sessions/:sid → selectItem re-fetch. State: _sessionPickerOpen + _availableSessions on state; reset on item switch. CSS: .session-link-btn, .session-delink-btn, .session-picker* styles added. node --check: pass. Chrome-MCP harness: scratchpad/flink/index.html (verified link, delink, picker flow, empty-state, filtered select). --- gently/ui/web/routes/campaigns.py | 8 +- gently/ui/web/static/css/campaigns.css | 103 +++++++++++++++++++ gently/ui/web/static/js/campaigns.js | 133 +++++++++++++++++++++++-- 3 files changed, 234 insertions(+), 10 deletions(-) diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index 857ed2db..7c463c96 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -244,8 +244,12 @@ async def get_item_detail(campaign_id: str, item_id: str): } ) - # Sessions linked to this campaign - sessions = cs.get_sessions_for_campaign(item.campaign_id) + # Sessions — return only those linked to this specific item (item.session_ids), + # not all campaign sessions. The frontend uses item.session_ids as the canonical + # list and this pool as metadata (name, created_at) for display. + item_sids = set(item.session_ids or []) + all_sessions = cs.get_sessions_for_campaign(item.campaign_id) + sessions = [s for s in all_sessions if s.session_id in item_sids] return { "item": _serialize(item), diff --git a/gently/ui/web/static/css/campaigns.css b/gently/ui/web/static/css/campaigns.css index 88690dec..b874ab44 100644 --- a/gently/ui/web/static/css/campaigns.css +++ b/gently/ui/web/static/css/campaigns.css @@ -1435,6 +1435,109 @@ font-size: 0.62rem; color: var(--text-muted); } +.detail-session-right { + display: flex; + align-items: center; + gap: 6px; +} +.detail-session-empty { + color: var(--text-muted); + font-size: 0.75rem; + font-style: italic; +} + +/* Session link button (header action) */ +.session-link-btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + border-radius: 6px; + padding: 2px 8px; + font: inherit; + font-size: 0.65rem; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} +.session-link-btn:hover { + color: var(--text); + border-color: var(--accent, #2f6df6); +} + +/* Per-session delink button */ +.session-delink-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 1px 4px; + border-radius: 4px; + opacity: 0.45; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} +.session-delink-btn:hover { + opacity: 1; + color: var(--accent-orange, #f97316); + background: rgba(249,115,22,0.12); +} + +/* Inline session picker */ +.session-picker { + margin-top: 8px; + padding: 10px; + background: rgba(0,0,0,0.18); + border: 1px solid var(--border); + border-radius: 8px; +} +.session-picker--loading { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; +} +.session-picker-select { + width: 100%; + background: var(--bg-dark, #0f172a); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + font: inherit; + font-size: 0.75rem; + margin-bottom: 8px; + box-sizing: border-box; +} +.session-picker-actions { + display: flex; + gap: 6px; +} +.session-picker-link-btn { + flex: 1; + background: var(--accent, #2f6df6); + color: #fff; + border: none; + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.session-picker-link-btn:hover { opacity: 0.85; } +.session-picker-cancel-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 12px; + font: inherit; + font-size: 0.72rem; + cursor: pointer; + transition: color 0.15s; +} +.session-picker-cancel-btn:hover { color: var(--text); } /* ================================================================ SHARED COMPONENTS diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index 6a3c3799..c465e3f9 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -67,6 +67,8 @@ const state = { editingSpec: false, // inspector imaging-spec edit mode _inspectorData: null, // last item-detail payload (for re-render on edit toggle) _specError: '', // inline save error in the spec editor + _sessionPickerOpen: false, // whether the session link picker is visible + _availableSessions: null, // null = not yet loaded, [] = loaded (for link picker) }; // ── DOM refs (cached on init) ──────────────────────────── @@ -141,6 +143,10 @@ function boot() { case 'spec-edit': e.stopPropagation(); startSpecEdit(); break; case 'spec-cancel': e.stopPropagation(); cancelSpecEdit(); break; case 'spec-save': e.stopPropagation(); saveSpecEdit(); break; + case 'session-picker-open': e.stopPropagation(); openSessionPicker(); break; + case 'session-picker-cancel': e.stopPropagation(); cancelSessionPicker(); break; + case 'session-picker-link': e.stopPropagation(); submitSessionLink(); break; + case 'session-delink': e.stopPropagation(); handleSessionDelink(el.dataset.sessionId); break; } }); @@ -630,6 +636,8 @@ async function selectItem(itemId) { if (itemId !== state.selectedItemId) { state.editingSpec = false; state._specError = ''; + state._sessionPickerOpen = false; + state._availableSessions = null; } state.selectedItemId = itemId; @@ -673,7 +681,13 @@ function renderInspector(data) { const item = data.item; const deps = data.dependencies || []; const dnts = data.dependents || []; - const sessions = data.sessions || []; + // Build a metadata map from the campaign-level sessions included in the payload, + // then derive the per-item sessions list from item.session_ids (the true source + // of truth). data.sessions is the campaign pool; we only show sessions that are + // actually linked to THIS item. + const _sessionMeta = {}; + (data.sessions || []).forEach(s => { _sessionMeta[s.session_id || s.id] = s; }); + const sessions = (item.session_ids || []).map(sid => _sessionMeta[sid] || { session_id: sid, id: sid }); if ($inspectorTitle) $inspectorTitle.textContent = item.title; if ($inspectorStatus) { @@ -786,20 +800,46 @@ function renderInspector(data) { html += section('References', refHtml); } - // Sessions + // Sessions — item-scoped (item.session_ids), with link/delink controls. + const _linkBtn = ``; + let sessHtml = ''; if (sessions.length > 0) { - let sessHtml = ''; sessions.forEach(s => { + const sid = s.session_id || s.id || ''; + const name = s.name || s.planned_intent || sid || 'Session'; sessHtml += `
- ${esc(s.planned_intent || s.id || 'Session')} - ${s.created_at ? `${formatDate(s.created_at)}` : ''} + ${esc(name)} + + ${s.created_at ? `${formatDate(s.created_at)}` : ''} + +
`; }); - html += section('Sessions', sessHtml); } else { - html += section('Sessions', - '
No linked sessions
'); + sessHtml = '
No linked sessions
'; } + // Inline link picker — rendered when openSessionPicker() has set state flag + loaded data. + if (state._sessionPickerOpen) { + if (state._availableSessions === null) { + // Still loading — show spinner text; will re-render once fetch completes. + sessHtml += `
Loading sessions…
`; + } else { + const _linkedIds = new Set(item.session_ids || []); + const _available = state._availableSessions.filter(s => !_linkedIds.has(s.session_id)); + const _opts = _available.length === 0 + ? `` + : _available.map(s => ``).join(''); + sessHtml += `
+ +
+ + +
+
`; + } + } + html += section('Sessions', sessHtml, _linkBtn); if ($inspectorBody) $inspectorBody.innerHTML = html; } @@ -902,6 +942,83 @@ async function saveSpecEdit() { } } +// ── Session link / delink ───────────────────────────────────────────────────── + +// Open the inline session picker. Fetches /api/sessions and re-renders with the +// picker shown. Two-phase: immediate re-render with loading state, then again +// once the fetch resolves (mirrors the pattern of selectItem loading state). +async function openSessionPicker() { + state._sessionPickerOpen = true; + state._availableSessions = null; // triggers loading display + if (state._inspectorData) renderInspector(state._inspectorData); + try { + const res = await fetch('/api/sessions'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + state._availableSessions = body.sessions || []; + } catch (err) { + console.error('Failed to load sessions for picker:', err); + state._availableSessions = []; + } + if (state._sessionPickerOpen && state._inspectorData) { + renderInspector(state._inspectorData); + } +} + +function cancelSessionPicker() { + state._sessionPickerOpen = false; + state._availableSessions = null; + if (state._inspectorData) renderInspector(state._inspectorData); +} + +// Read the picker ${opts} +
+ + +
+
`; + } + } + + panelEl.innerHTML = html; + + // Wire events directly on the rendered buttons. + const sid = sessionId; + const linkBtn = panelEl.querySelector('#ops-lp-link-btn'); + if (linkBtn) { + linkBtn.addEventListener('click', () => + this._openPlanPickerInPanel(panelEl, plans, campaignNameMap, sid)); + } + panelEl.querySelectorAll('.ops-lp-delink').forEach(btn => { + btn.addEventListener('click', () => + this._delinkPlanItem(panelEl, btn.dataset.itemId, btn.dataset.campaignId, sid)); + }); + const submitBtn = panelEl.querySelector('#ops-lp-picker-link'); + if (submitBtn) { + submitBtn.addEventListener('click', () => this._submitPlanLink(panelEl, sid)); + } + const cancelBtn = panelEl.querySelector('#ops-lp-picker-cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + this._planPickerOpen = false; + this._pickerItems = null; + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sid); + }); + } + }, + + // Open the inline plan-item picker: set loading state, fetch /api/campaigns, + // flatten to items, re-render with the picker populated. + async _openPlanPickerInPanel(panelEl, plans, campaignNameMap, sessionId) { + this._planPickerOpen = true; + this._pickerItems = null; // show loading + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + try { + const res = await fetch('/api/campaigns', { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + this._pickerItems = this._flattenCampaignItems(data.campaigns || [], campaignNameMap); + } catch (err) { + console.error('[ExperimentOverview] plan picker fetch error:', err); + this._pickerItems = []; + } + if (this._planPickerOpen) { + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + } + }, + + // POST the selected plan item → session link, then refetch and re-render. + async _submitPlanLink(panelEl, sessionId) { + const select = panelEl.querySelector('#ops-lp-picker-sel'); + const value = select && select.value; + if (!value || !value.includes('::')) return; + const [campaignId, itemId] = value.split('::'); + if (!campaignId || !itemId) return; + + this._planPickerOpen = false; + this._pickerItems = null; + panelEl.innerHTML = '
Linking…
'; + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }), + }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error('[ExperimentOverview] plan link error:', err); + } + await this._refetchLinkedPlans(panelEl, sessionId); + }, + + // DELETE the plan-item → session edge, then refetch and re-render. + async _delinkPlanItem(panelEl, itemId, campaignId, sessionId) { + panelEl.innerHTML = '
Unlinking…
'; + try { + const res = await fetch( + `/api/campaigns/${encodeURIComponent(campaignId)}/items/${encodeURIComponent(itemId)}/sessions/${encodeURIComponent(sessionId)}`, + { method: 'DELETE' }, + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error('[ExperimentOverview] plan delink error:', err); + } + await this._refetchLinkedPlans(panelEl, sessionId); + }, + + // Refetch linked plans + campaigns after a link/delink op, then re-render. + async _refetchLinkedPlans(panelEl, sessionId) { + try { + const [linkedData, campaignsData] = await Promise.all([ + fetch(`/api/sessions/${encodeURIComponent(sessionId)}/plans`, { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { plans: [] }) + .catch(() => ({ plans: [] })), + fetch('/api/campaigns', { cache: 'no-store' }) + .then(r => r.ok ? r.json() : { campaigns: [] }) + .catch(() => ({ campaigns: [] })), + ]); + const plans = linkedData.plans || []; + const campaignNameMap = {}; + this._flattenCampaignNames(campaignsData.campaigns || [], campaignNameMap); + this._fillLinkedPlansPanel(panelEl, plans, campaignNameMap, sessionId); + } catch (e) { + console.warn('[ExperimentOverview] linked-plans refetch error:', e); + panelEl.innerHTML = '
Could not reload linked plans.
'; + } + }, + + _renderRulesView(root, s) { // Compact header echoing the session identity From e2b7e48d0f85b93bf0b9a1a8ad656a720ec51c19 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:11:01 +0530 Subject: [PATCH 243/314] fix(f): item-scoped POST response + delink-failure re-render + defensive split + back-compat test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST link_session_to_item now re-fetches the item and filters get_sessions_for_campaign by item.session_ids — identical scope to GET get_item_detail (contract consistency). handleSessionDelink catch block calls renderInspector to restore UI on failure. experiment-overview.js picker split limited to 2 parts for defensive id parsing. Back-compat store tests: legacy scalar-only session_id found by reverse query and correctly removed by unlink_plan_item_session. --- gently/ui/web/routes/campaigns.py | 7 ++- gently/ui/web/static/js/campaigns.js | 1 + .../ui/web/static/js/experiment-overview.js | 2 +- tests/test_session_plan_linking_routes.py | 12 +++++- tests/test_session_plan_linking_store.py | 43 +++++++++++++++++++ 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/gently/ui/web/routes/campaigns.py b/gently/ui/web/routes/campaigns.py index 7c463c96..4d9ee071 100644 --- a/gently/ui/web/routes/campaigns.py +++ b/gently/ui/web/routes/campaigns.py @@ -275,7 +275,12 @@ async def link_session_to_item(campaign_id: str, item_id: str, request: Request) cs.link_plan_item_session(item_id, session_id) cs.link_session_campaign(session_id, campaign.id) - sessions = cs.get_sessions_for_campaign(item.campaign_id) + # Re-fetch item so session_ids reflects the just-added link; then filter + # exactly as get_item_detail does — POST and GET return the same scope. + item = cs.get_plan_item(item_id) + item_sids = set(item.session_ids or []) + all_sessions = cs.get_sessions_for_campaign(item.campaign_id) + sessions = [s for s in all_sessions if s.session_id in item_sids] return {"sessions": [_serialize(s) for s in sessions]} @router.delete("/api/campaigns/{campaign_id}/items/{item_id}/sessions/{session_id}") diff --git a/gently/ui/web/static/js/campaigns.js b/gently/ui/web/static/js/campaigns.js index c465e3f9..d42cdbd4 100644 --- a/gently/ui/web/static/js/campaigns.js +++ b/gently/ui/web/static/js/campaigns.js @@ -1016,6 +1016,7 @@ async function handleSessionDelink(sessionId) { selectItem(item.id).catch(() => {}); } catch (err) { console.error('Failed to delink session:', err); + renderInspector(state._inspectorData); } } diff --git a/gently/ui/web/static/js/experiment-overview.js b/gently/ui/web/static/js/experiment-overview.js index 7f97941b..ce59a4a8 100644 --- a/gently/ui/web/static/js/experiment-overview.js +++ b/gently/ui/web/static/js/experiment-overview.js @@ -434,7 +434,7 @@ const ExperimentOverview = { const select = panelEl.querySelector('#ops-lp-picker-sel'); const value = select && select.value; if (!value || !value.includes('::')) return; - const [campaignId, itemId] = value.split('::'); + const [campaignId, itemId] = value.split('::', 2); if (!campaignId || !itemId) return; this._planPickerOpen = false; diff --git a/tests/test_session_plan_linking_routes.py b/tests/test_session_plan_linking_routes.py index b9542d4a..fce5fe52 100644 --- a/tests/test_session_plan_linking_routes.py +++ b/tests/test_session_plan_linking_routes.py @@ -103,7 +103,17 @@ def test_returns_sessions_list(self): assert isinstance(body["sessions"], list) def test_returns_serialized_session_intents(self): - item = _make_item("i1", "c1", "Step A") + # Item must have session_ids=["s1"] to simulate the post-link state + # (mock link_plan_item_session doesn't mutate the item; we configure + # get_plan_item to return the post-link view so the item-scoped filter works). + item = PlanItem( + id="i1", + campaign_id="c1", + type=PlanItemType.BENCH, + title="Step A", + status=PlanItemStatus.PLANNED, + session_ids=["s1"], + ) si = SessionIntent( session_id="s1", campaign_ids=["c1"], diff --git a/tests/test_session_plan_linking_store.py b/tests/test_session_plan_linking_store.py index 5cd72466..9158168c 100644 --- a/tests/test_session_plan_linking_store.py +++ b/tests/test_session_plan_linking_store.py @@ -165,3 +165,46 @@ def test_back_compat_set_to_most_recent_remaining(self, two_campaigns): assert sid2 in item.session_ids assert item.session_id == sid2 # most recent remaining assert sid1 not in item.session_ids + + +class TestBackCompatLegacyScalar: + """Back-compat path: items written before session_ids list existed (scalar only).""" + + def _inject_legacy_item(self, store, campaign_id: str, item_id: str, session_id: str): + """Overwrite a plan item's YAML record to simulate legacy format: + session_id set but session_ids absent/empty — as written by pre-list code.""" + loc = store._find_plan_item_location(item_id) + assert loc is not None, "item must exist before injecting legacy format" + cid, items, idx = loc + items[idx]["session_id"] = session_id + items[idx]["session_ids"] = [] # empty list = pre-list legacy state + store._write_plan_items(cid, items) + + def test_legacy_scalar_found_by_reverse_query(self, two_campaigns): + """get_plan_items_for_session finds an item with only scalar session_id set.""" + store, cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_legacy" + + # Inject legacy-format record (scalar session_id, empty session_ids) + self._inject_legacy_item(store, cid1, item1, session_id) + + items = store.get_plan_items_for_session(session_id) + assert any(it.id == item1 for it in items), ( + "expected item1 in results for legacy scalar session_id" + ) + + def test_legacy_scalar_unlinked_by_unlink(self, two_campaigns): + """unlink_plan_item_session removes a legacy-scalar item and returns True.""" + store, cid1, _cid2, item1, _item2 = two_campaigns + session_id = "sess_legacy2" + + self._inject_legacy_item(store, cid1, item1, session_id) + + result = store.unlink_plan_item_session(item1, session_id) + assert result is True + + # Must no longer appear in reverse query + remaining = store.get_plan_items_for_session(session_id) + assert not any(it.id == item1 for it in remaining), ( + "item1 should be removed after unlinking legacy scalar session" + ) From 99540bd039a5f7ad7b832328fa6371d6ed30ede8 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:13:00 +0530 Subject: [PATCH 244/314] docs: add F + B2 to the wrap-up PR plan stack --- docs/superpowers/PR-PLAN.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/PR-PLAN.md b/docs/superpowers/PR-PLAN.md index aeb217d3..042c5e5c 100644 --- a/docs/superpowers/PR-PLAN.md +++ b/docs/superpowers/PR-PLAN.md @@ -14,7 +14,9 @@ Each PR diffs cleanly against its parent in the stack; integrate the chain when | 3 | `feature/temp-change-tactic` (C) | Automated temp-change burst protocol (`wait_for_temperature_lock` + driver), burst-acquisition wiring, protocol events + agent tool | B1 | per-task + whole-branch review + fixes | | 4 | `feature/operations-tab` (D) | **Operations: the agent-authored Operation Plan** — typed declare tool, store, route, execution-linkage (tactic_id + updater), plan-item seeding, operation-spine renderer + live binding | C | 10 tasks + whole-branch (opus) review + 6 fixes; 105 tests; Chrome-audited | | 5 | `feature/tactics-library` (G) | Save / list / apply reusable typed tactics (on D's substrate), mirroring plan-templates; apply→Operation Plan | D | 3 tasks + whole-branch review + fixes; ~64 tests | -| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | TBD (SDD) | +| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | 4 tasks + whole-branch review + fix; 54 tests; Chrome-audited | +| 7 | `feature/session-plan-linking` (F) | Session↔**plans** link/delink: multi-plan model (`unlink_plan_item_session` + reverse-query), link/delink endpoints, Plans-tab controls + session Linked-plans panel | D2 | 4 tasks + whole-branch review + fix; ~70 tests; both surfaces Chrome-audited | +| 8 | `feature/manual-mode-dual-camera` (B2) | Dual-camera config + laser-preset browser + timelapse config form (extends B1 manual mode) | F | TBD (SDD) | Notes: - Each branch is the natural unit of "distinct enough to own a PR." Sub-parts (e.g. B1's safety fixes) From 1f743cebb82b5c0f190cc5c25f2f2d5d2c428314 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:18:24 +0530 Subject: [PATCH 245/314] docs(b2): dual-camera + laser-preset + timelapse-form spec + plan (3 tasks; live parts rig-deferred) --- .../2026-06-29-manual-mode-dual-camera.md | 38 +++++++++++ ...26-06-29-manual-mode-dual-camera-design.md | 63 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md create mode 100644 docs/superpowers/specs/2026-06-29-manual-mode-dual-camera-design.md diff --git a/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md new file mode 100644 index 00000000..96e4c141 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md @@ -0,0 +1,38 @@ +# Manual mode B2 — dual-camera + laser-preset browser + timelapse form Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`. + +**Goal:** Extend B1's single-camera manual mode with a laser-preset browser, dual-camera (side A/B) config, and a manual timelapse-config form — building the headless parts now (live dual-view + real acquisition are rig-deferred). + +**Architecture:** New `require_control` proxy routes wrapping existing client/device-layer + agent-tool paths; device_factory registers a second camera defensively; UI additions to `#devices-view-manual` / `devices.js`. + +## Global Constraints +- HEADLESS-buildable parts only; mark RIG-DEFERRED parts (live dual view, real acquisition/timelapse) as noted in the spec — don't fake hardware. +- Backward compatible: single-camera rigs must still start (defensive HamCam2 registration); the manual-view-entry laser-off safety (B1 I3) stays intact. +- Laser preset list already exists: `GET /api/devices/laser/configs` (data.py:526). Reuse it; add only the set proxy + UI. +- Proxy routes mirror the existing `routes/data.py` `require_control` pattern (e.g. `/api/devices/laser/off` :508). Device-layer/client calls mirror existing ones. +- UI extends `#devices-view-manual` (index.html ~:720-835) + `DevicesManager` in `devices.js`. +- Git hygiene: stage only your files by explicit path; never `git add -A`. + +--- + +### Task 1: Laser-preset browser +**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/laser/config` `require_control` → `client.set_laser_config(name)`, mirror `/laser/off` :508); `gently/ui/web/templates/index.html` (the Illumination group ~:800 — replace the static `#devices-ls-laser-status` indicator with a `` populated from `GET /api/devices/laser/configs`; on change POST the + chosen preset. Keep "ALL OFF" the safe default + the existing manual-view-entry laser-off safety (don't + remove the I3 guard — selecting a preset is an explicit user action). +- **Rig-deferred:** the actual laser firing (the preset just calls `setConfig` on the rig). + +## 2. Dual-camera config +- **Backend (headless):** register a second `DiSPIMCamera("HamCam2")` as `devices["camera_b"]` in + `device_factory.py` — DEFENSIVELY (only if the camera is in the core's loaded devices; skip + log + otherwise, so single-camera rigs still start). Add a `side` field ('A'|'B') to `_ls_params` + + `handle_lightsheet_params`; `_ensure_lightsheet_sequence_sync` picks `camera` vs `camera_b` by side and + restarts the sequence on side change (reuse the exposure-change restart path). New `GET /api/devices/cameras` + endpoint listing available camera roles (A always; B if registered). +- **UI (headless):** a "Side A / B" selector in the manual rail → carries `side` on the live/params POST. +- **Rig-deferred:** live DUAL view via the "Multi Camera" fusion device (live-only) + dual-side acquisition + (two parallel `startSequenceAcquisition` + tag demux). v1 = single live stream, switchable side. + +## 3. Timelapse config form +- **Backend (headless):** new `POST /api/devices/timelapse/start` (`require_control`) proxy wrapping the + agent path `start_adaptive_timelapse(embryo_ids, stop_condition, interval_seconds, condition_value, + monitoring_mode)` (validate params; resolve the orchestrator like the agent tool does). The volume + geometry (num_slices/exposure/galvo±/piezo±/laser_config/power) is captured in the form + passed through + / persisted to per-embryo calibration where applicable. +- **UI (headless):** a collapsible "Timelapse" panel in the manual rail gathering cadence/stop/embryos/ + monitoring_mode + the volume geometry, reading `GET /api/devices/scan_geometry` + `/api/devices/laser/configs` + for defaults. A "Start timelapse" submit → the new proxy. +- **Rig-deferred:** the actual timelapse run + galvo/piezo motion. + +## 4. Out of scope / deferred +- Live Multi-Camera dual view + dual-side acquisition demux (rig). +- Saving timelapse configs as reusable presets (could reuse the tactic-library/plan-template later). +- Per-line laser power UI beyond the preset (the clamps in `optical.py` still apply). + +## 5. Testing +- Laser-preset: the POST proxy (TestClient + mock client asserts `set_laser_config(name)`); `node --check` + + Chrome audit of the dropdown populated from a stubbed configs endpoint. +- Dual-camera: device_factory registers camera_b against a FAKE core that has HamCam2 (and skips when + absent); the `side` param threads into `_ls_params` + selects the camera; `/api/devices/cameras` lists + roles; `node --check` + Chrome audit of the side selector. +- Timelapse: the start proxy validates + calls the orchestrator path (mock); the form gathers + posts the + params; `node --check` + Chrome audit of the form. +- All three: backward compatible (single-camera rig unaffected; the laser-off safety intact). From 340ab96811dc7715f8712171b151c5734aede2b2 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:24:20 +0530 Subject: [PATCH 246/314] feat(b2): laser-preset browser (set proxy + manual-mode dropdown) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/devices/laser/config proxy (require_control, 400 on missing/empty config, mirrors /laser/off client+error pattern). Add — populated on manual-view entry let _lsTempInput, _lsTempSet; // Room-light toggle (header). Drives the SwitchBot Bot that switches the @@ -201,6 +202,7 @@ const DevicesManager = (function () { _lsLastcap = document.getElementById('devices-ls-lastcap'); _lsLastcapRef = document.getElementById('devices-ls-lastcap-ref'); _lsLaserStatus = document.getElementById('devices-ls-laser-status'); + _lsLaserPreset = document.getElementById('devices-laser-preset'); _lsTempInput = document.getElementById('devices-ls-temp-input'); _lsTempSet = document.getElementById('devices-ls-temp-set'); @@ -1365,6 +1367,59 @@ const DevicesManager = (function () { } } + /** Fetch laser config-group presets and populate the #devices-laser-preset select. + * Selects "ALL OFF" by default (entry safety preset). + * Wires the change handler to POST the selected preset. + * Fire-and-forget safe — failure leaves the fallback "ALL OFF" option in place. */ + async function populateLaserPresets() { + cacheDom(); + if (!_lsLaserPreset) return; + try { + const res = await fetch('/api/devices/laser/configs'); + if (!res.ok) return; + const data = await res.json(); + // data may be an array of preset names or {configs: [...]} + const presets = Array.isArray(data) ? data : (data.configs || []); + if (!presets.length) return; + // Rebuild option list + _lsLaserPreset.innerHTML = ''; + for (const name of presets) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + _lsLaserPreset.appendChild(opt); + } + // Default to "ALL OFF" — entry safety state + if (presets.includes('ALL OFF')) _lsLaserPreset.value = 'ALL OFF'; + // Wire change handler (idempotent — replace via re-assignment) + _lsLaserPreset.onchange = () => setLaserPreset(_lsLaserPreset.value); + } catch (err) { + console.debug('laser preset fetch failed:', err); + } + } + + /** POST a named laser preset to the device layer. + * Updates the status indicator on success. + * Fire-and-forget safe — failure shows a warning, never throws. */ + async function setLaserPreset(config) { + cacheDom(); + if (!config) return; + try { + const res = await fetch('/api/devices/laser/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config }), + }); + if (_lsLaserStatus) { + _lsLaserStatus.textContent = res.ok ? config : 'warning: state unknown'; + } + if (!res.ok) console.debug('laser preset set failed:', await res.text()); + } catch (err) { + if (_lsLaserStatus) _lsLaserStatus.textContent = 'warning: state unknown'; + console.debug('laser preset set failed:', err); + } + } + async function toggleLightsheetStream() { if (!_lsToggle) return; _lsToggle.disabled = true; @@ -1972,7 +2027,9 @@ const DevicesManager = (function () { } } // Entering Manual view — gate lasers off immediately (brightfield-safe). - if (viewName === 'manual') setLaserOff(); + // populateLaserPresets() runs after setLaserOff() so the select is always + // seeded with the entry-safety state first. + if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); } } function setupViewSwitcher() { diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 1f09ba06..c491eea3 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -797,6 +797,13 @@

Properties

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

Properties

+ + +
diff --git a/tests/test_dual_camera_factory.py b/tests/test_dual_camera_factory.py new file mode 100644 index 00000000..5476184e --- /dev/null +++ b/tests/test_dual_camera_factory.py @@ -0,0 +1,107 @@ +"""Tests for B2 Task 2 — dual-camera device_factory (HamCam2 defensive registration). + +Verifies: + - FakeCore with "HamCam2" in getLoadedDevices() → devices["camera_b"] created (name == "HamCam2") + - FakeCore without "HamCam2" → camera_b absent, no crash, devices["camera"] still created + - camera_b registration uses core.getLoadedDevices() as the presence check +""" +import sys +from unittest.mock import MagicMock + +# Patch heavy hardware deps before importing device_factory. +# All device classes use pymmcore / ophyd.status only (no ophyd base classes), +# so patching at the module level is sufficient. +for _mod in ( + "pymmcore", + "ophyd", + "ophyd.status", + "bluesky", + "bluesky.run_engine", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_factory import create_devices_from_mmcore # noqa: E402 + + +class FakeCore: + """Minimal CMMCore stand-in with configurable loaded-device list.""" + + def __init__(self, loaded_devices=None): + self._loaded = list(loaded_devices or []) + + def getLoadedDevices(self): + return self._loaded + + # stubs that DiSPIMCamera.__init__ may indirectly trigger via ophyd proxies + def getProperty(self, dev, prop): + return "0" + + def setProperty(self, dev, prop, val): + pass + + def getCameraDevice(self): + return "" + + def setCameraDevice(self, name): + pass + + +# --------------------------------------------------------------------------- +# Happy path — HamCam2 present +# --------------------------------------------------------------------------- + + +def test_camera_b_created_when_hamcam2_loaded(): + """HamCam2 in getLoadedDevices → devices['camera_b'] created with name 'HamCam2'.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" in devices, "camera_b must be registered when HamCam2 is in loaded devices" + assert devices["camera_b"].name == "HamCam2" + + +def test_camera_b_name_is_hamcam2(): + """The camera_b name attribute is exactly the configured camera_b_name.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:AB:33"]) + devices = create_devices_from_mmcore(core) + assert devices["camera_b"].name == "HamCam2" + + +# --------------------------------------------------------------------------- +# Single-camera rig — HamCam2 absent +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_when_hamcam2_not_loaded(): + """HamCam2 NOT in getLoadedDevices → camera_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices, "camera_b must be absent on single-camera rigs" + + +def test_camera_still_created_without_hamcam2(): + """Primary camera (HamCam1) is created regardless of HamCam2 presence.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "camera" in devices + assert devices["camera"].name == "HamCam1" + + +# --------------------------------------------------------------------------- +# Edge case — empty loaded-device list +# --------------------------------------------------------------------------- + + +def test_camera_b_absent_on_empty_device_list(): + """Empty loaded-device list → camera_b not attempted, no extra error.""" + core = FakeCore(loaded_devices=[]) + # DiSPIMCamera.__init__ only stores name/core; it succeeds even with no devices. + # So "camera" is still created; "camera_b" is skipped. + devices = create_devices_from_mmcore(core) + assert "camera_b" not in devices + assert "camera" in devices diff --git a/tests/test_lightsheet_side_param.py b/tests/test_lightsheet_side_param.py new file mode 100644 index 00000000..25009334 --- /dev/null +++ b/tests/test_lightsheet_side_param.py @@ -0,0 +1,223 @@ +"""Tests for B2 Task 2 — lightsheet side param (handle_lightsheet_params + +_ensure_lightsheet_sequence_sync + handle_get_cameras). + +Verifies: + - handle_lightsheet_params accepts 'side' ('A'|'B'); invalid values ignored + - side change in _ensure_lightsheet_sequence_sync selects the correct camera + and triggers a sequence restart (mirroring the exposure-change path) + - side B without camera_b falls back to A + no crash + - handle_get_cameras returns ["A"] or ["A","B"] based on devices dict +""" +import json +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeCore: + """Minimal CMMCore stub tracking sequence-start/stop calls.""" + + def __init__(self): + self.running = False + self.cam = None + self.started = 0 + self.stopped = 0 + self.exposure = None + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam + + def setExposure(self, n, ms): + self.exposure = ms + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def isSequenceRunning(self): + return self.running + + +def _make_dl(with_camera_b: bool = True) -> DeviceLayerServer: + """Build a minimal DeviceLayerServer with faked core + devices.""" + dl = DeviceLayerServer.__new__(DeviceLayerServer) + dl.system = type("S", (), {"core": FakeCore()})() + cam_a = type("C", (), {"name": "HamCam1"})() + cam_b = type("C", (), {"name": "HamCam2"})() + dl.devices = {"camera": cam_a} + if with_camera_b: + dl.devices["camera_b"] = cam_b + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# _ensure_lightsheet_sequence_sync — camera selection by side +# --------------------------------------------------------------------------- + + +def test_side_a_selects_camera_a(): + """Side A → sequence started on HamCam1.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_b_selects_camera_b(): + """Side B with camera_b present → sequence started on HamCam2.""" + dl = _make_dl(with_camera_b=True) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam2" + assert dl.system.core.started == 1 + + +def test_side_b_fallback_to_a_when_no_camera_b(): + """Side B without camera_b → graceful fallback to A (HamCam1), no crash.""" + dl = _make_dl(with_camera_b=False) + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.cam == "HamCam1" + assert dl.system.core.started == 1 + + +def test_side_change_triggers_restart(): + """Switching from A to B stops the running sequence and restarts on HamCam2.""" + dl = _make_dl(with_camera_b=True) + # First call — start on A + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + assert dl.system.core.cam == "HamCam1" + + # Switch to B — must restart + dl._ls_params["side"] = "B" + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.stopped >= 1 + assert dl.system.core.started >= 2 + assert dl.system.core.cam == "HamCam2" + + +def test_no_restart_when_side_unchanged(): + """Same side, same exposure → sequence already running, no extra restart.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._ensure_lightsheet_sequence_sync() + starts_after_first = dl.system.core.started + + # Manually mark the applied state (as the real code does) + dl._ls_applied["exposure"] = 20.0 + dl._ls_applied["side"] = "A" + + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == starts_after_first + + +# --------------------------------------------------------------------------- +# handle_lightsheet_params — side accepted / validated +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_b(): + """POST {side: 'B'} → _ls_params['side'] updated to 'B'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "B" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_side_a(): + """POST {side: 'A'} when already A → _ls_params['side'] stays 'A'.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"side": "A"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_ignores_invalid_side(): + """Invalid side value (not 'A'|'B') → _ls_params['side'] unchanged.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + req = MagicMock() + req.json = AsyncMock(return_value={"side": "C"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["side"] == "A" + + +@pytest.mark.asyncio +async def test_handle_lightsheet_params_accepts_exposure_and_side(): + """POST with both exposure and side → both applied.""" + dl = _make_dl() + req = MagicMock() + req.json = AsyncMock(return_value={"exposure": 50.0, "side": "B"}) + await dl.handle_lightsheet_params(req) + assert dl._ls_params["exposure"] == 50.0 + assert dl._ls_params["side"] == "B" + + +# --------------------------------------------------------------------------- +# handle_get_cameras — roles endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_handle_get_cameras_with_camera_b(): + """Both A and B listed when camera_b is registered.""" + dl = _make_dl(with_camera_b=True) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A", "B"] + + +@pytest.mark.asyncio +async def test_handle_get_cameras_without_camera_b(): + """Only A listed when camera_b is absent.""" + dl = _make_dl(with_camera_b=False) + req = MagicMock() + resp = await dl.handle_get_cameras(req) + body = json.loads(resp.body) + assert body["cameras"] == ["A"] From 5f7011e7cf62219264497b80566678ffd3fa1615 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 08:51:07 +0530 Subject: [PATCH 248/314] feat(b2): manual timelapse-config form (start proxy + rail panel) Adds POST /api/devices/timelapse/start (require_control) that validates params and calls orchestrator.start via server.agent_bridge.agent.timelapse_orchestrator. Adds a collapsible Timelapse panel to #devices-view-manual with cadence, stop condition, embryo IDs, monitoring mode, and volume geometry fields. devices.js populates defaults from GET /api/devices/scan_geometry + /api/devices/laser/configs on manual-view entry and POSTs on submit. 16 TDD tests; node --check passes; galvo/piezo motion is rig-deferred. --- gently/ui/web/routes/data.py | 112 +++++++++++ gently/ui/web/static/js/devices.js | 168 ++++++++++++++++- gently/ui/web/templates/index.html | 130 +++++++++++++ tests/test_timelapse_start_route.py | 275 ++++++++++++++++++++++++++++ 4 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 tests/test_timelapse_start_route.py diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 80ce9fd0..8342fe09 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -671,6 +671,118 @@ async def acquire_volume(payload: dict = Body(...)): # noqa: B008 logger.exception("Volume acquisition failed") raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc + # ------------------------------------------------------------------ + # Timelapse + # ------------------------------------------------------------------ + + @router.post("/api/devices/timelapse/start", dependencies=[Depends(require_control)]) + async def timelapse_start(payload: dict = Body(...)): # noqa: B008 + """Start an adaptive timelapse from the manual UI. + + Body fields (all optional except interval_seconds has a default): + interval_seconds (float, default 120) — cadence; must be > 0 + stop_condition (str, default "manual") — "manual", "timepoints", "duration" + embryo_ids (list[str] | null) — null = all active embryos + condition_value (int | null) — timepoints count or duration hours + monitoring_mode (str | null) — "idle" / "expression_monitoring" / + "pre_terminal_monitoring" + num_slices (int, default 50) — must be >= 1 if provided + exposure_ms (float, default 10.0) + galvo_amplitude (float, default 0.5) + galvo_center (float, default 0.0) + piezo_amplitude (float, default 25.0) + piezo_center (float, default 50.0) + laser_config (str | null) + + Validation: + - interval_seconds must be > 0 + - num_slices must be >= 1 + + Orchestrator access: server.agent_bridge.agent.timelapse_orchestrator + RIG-DEFERRED: the actual acquisition + galvo/piezo motion. + """ + # --- Validate --- + raw_interval = payload.get("interval_seconds", 120.0) + try: + interval_seconds = float(raw_interval) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="interval_seconds must be a number") + if interval_seconds <= 0: + raise HTTPException(status_code=400, detail="interval_seconds must be > 0") + + raw_slices = payload.get("num_slices") + if raw_slices is not None: + try: + num_slices = int(raw_slices) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="num_slices must be an integer") + if num_slices < 1: + raise HTTPException(status_code=400, detail="num_slices must be >= 1") + else: + num_slices = 50 + + stop_condition = str(payload.get("stop_condition") or "manual") + embryo_ids = payload.get("embryo_ids") or None + condition_value = payload.get("condition_value") + monitoring_mode = payload.get("monitoring_mode") or None + + # Volume geometry — passed through for context / future calibration write; + # not forwarded to orchestrator.start (which owns its own geometry via the + # per-embryo calibration). RIG-DEFERRED: real acquisition uses these. + volume_geometry = { + "num_slices": num_slices, + "exposure_ms": float(payload.get("exposure_ms", 10.0)), + "galvo_amplitude": float(payload.get("galvo_amplitude", 0.5)), + "galvo_center": float(payload.get("galvo_center", 0.0)), + "piezo_amplitude": float(payload.get("piezo_amplitude", 25.0)), + "piezo_center": float(payload.get("piezo_center", 50.0)), + "laser_config": payload.get("laser_config") or None, + } + + # --- Resolve orchestrator --- + bridge = getattr(server, "agent_bridge", None) + agent = bridge.agent if bridge is not None else None + orchestrator = getattr(agent, "timelapse_orchestrator", None) if agent else None + if orchestrator is None: + raise HTTPException( + status_code=503, + detail="Timelapse orchestrator not initialised (agent not running or no session)", + ) + + # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + try: + result = await orchestrator.start( + embryo_ids=embryo_ids, + stop_condition=stop_condition, + base_interval_seconds=interval_seconds, + condition_value=condition_value, + ) + except Exception as exc: + logger.exception("Timelapse start failed") + raise HTTPException(status_code=502, detail=f"timelapse start failed: {exc}") from exc + + # Optionally install a monitoring mode at startup (mirrors start_adaptive_timelapse) + mode_result = None + if monitoring_mode and monitoring_mode != "idle": + try: + mode_result = orchestrator.enable_monitoring_mode(monitoring_mode) + except Exception as exc: + mode_result = f"warning: failed to enable monitoring mode: {exc}" + + return { + "started": True, + "result": result, + "monitoring_mode_result": mode_result, + "config": { + "interval_seconds": interval_seconds, + "stop_condition": stop_condition, + "embryo_ids": embryo_ids, + "condition_value": condition_value, + "monitoring_mode": monitoring_mode, + "volume_geometry": volume_geometry, + }, + } + @router.get("/api/calibration") async def list_calibration(embryo_id: str | None = None): """Get calibration images""" diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index a5ce39dd..72f1e7b9 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -104,6 +104,13 @@ const DevicesManager = (function () { let _lsSideSelect; // + s +
+ + +
+ + +
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + ms +
+ +
+ + + ° +
+ +
+ + + ° +
+ +
+ + + µm +
+ +
+ + + µm +
+ +
+ + +
+
+ + +
+ +
+ + +
+

+
diff --git a/tests/test_timelapse_start_route.py b/tests/test_timelapse_start_route.py new file mode 100644 index 00000000..9f5c3c1b --- /dev/null +++ b/tests/test_timelapse_start_route.py @@ -0,0 +1,275 @@ +"""Tests for B2 Task 3 — POST /api/devices/timelapse/start (manual timelapse proxy). + +Verifies: + - POST with valid params calls orchestrator.start with the right args → 200 + - monitoring_mode is forwarded to orchestrator.enable_monitoring_mode + - interval_seconds <= 0 → 400 + - num_slices < 1 → 400 + - missing interval_seconds uses default 120.0 (no error) + - orchestrator not initialised → 503 + - require_control gate (403 without override) + +Orchestrator access path: + server.agent_bridge.agent.timelapse_orchestrator + (mirroring the `require_timelapse_orchestrator(agent)` helper in harness/tools/helpers.py) +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import gently.ui.web.auth as auth +from gently.ui.web.routes.data import create_router + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _app(orchestrator=None): + """Build a TestClient wired with the data routes. + + The mock server exposes `agent_bridge.agent.timelapse_orchestrator`. + `require_control` is overridden so route logic is isolated from auth. + """ + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orchestrator + # Satisfy other routes that call _resolve_client() or store + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + return TestClient(app) + + +def _make_orchestrator(start_return="Timelapse started."): + """Return a mock orchestrator with an async start and sync enable_monitoring_mode.""" + orch = MagicMock() + orch.start = AsyncMock(return_value=start_return) + orch.enable_monitoring_mode = MagicMock(return_value="Monitoring mode enabled.") + return orch + + +# --------------------------------------------------------------------------- +# Happy path — minimal valid payload +# --------------------------------------------------------------------------- + +def test_timelapse_start_minimal(): + """POST with just interval_seconds calls orchestrator.start; returns 200 with started=True.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 200 + body = r.json() + assert body["started"] is True + orch.start.assert_awaited_once() + + +def test_timelapse_start_calls_start_with_correct_args(): + """orchestrator.start receives the right interval, stop_condition, embryo_ids, condition_value.""" + orch = _make_orchestrator() + _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 60, + "stop_condition": "timepoints", + "embryo_ids": ["e1", "e2"], + "condition_value": 10, + }, + ) + orch.start.assert_awaited_once_with( + embryo_ids=["e1", "e2"], + stop_condition="timepoints", + base_interval_seconds=60.0, + condition_value=10, + ) + + +def test_timelapse_start_uses_default_interval(): + """Omitting interval_seconds uses the default (120.0) without error.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={}, + ) + assert r.status_code == 200 + _, kwargs = orch.start.call_args + assert kwargs["base_interval_seconds"] == 120.0 + + +def test_timelapse_start_result_in_response(): + """The orchestrator.start return value appears in the response.""" + orch = _make_orchestrator(start_return="Timelapse running — 3 embryos.") + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 90}, + ) + assert r.json()["result"] == "Timelapse running — 3 embryos." + + +# --------------------------------------------------------------------------- +# Monitoring mode +# --------------------------------------------------------------------------- + +def test_timelapse_start_enables_monitoring_mode(): + """monitoring_mode != 'idle' triggers enable_monitoring_mode on the orchestrator.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "expression_monitoring"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_called_once_with("expression_monitoring") + assert r.json()["monitoring_mode_result"] == "Monitoring mode enabled." + + +def test_timelapse_start_idle_mode_skips_enable(): + """monitoring_mode='idle' does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "monitoring_mode": "idle"}, + ) + assert r.status_code == 200 + orch.enable_monitoring_mode.assert_not_called() + + +def test_timelapse_start_no_mode_skips_enable(): + """Omitting monitoring_mode does NOT call enable_monitoring_mode.""" + orch = _make_orchestrator() + _app(orch).post("/api/devices/timelapse/start", json={"interval_seconds": 120}) + orch.enable_monitoring_mode.assert_not_called() + + +# --------------------------------------------------------------------------- +# Volume geometry passed through in response config +# --------------------------------------------------------------------------- + +def test_timelapse_start_volume_geometry_in_config(): + """Volume geometry fields appear in response['config']['volume_geometry'].""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={ + "interval_seconds": 120, + "num_slices": 80, + "exposure_ms": 15.0, + "galvo_amplitude": 0.7, + "galvo_center": 0.1, + "piezo_amplitude": 30.0, + "piezo_center": 55.0, + "laser_config": "488 only", + }, + ) + assert r.status_code == 200 + vg = r.json()["config"]["volume_geometry"] + assert vg["num_slices"] == 80 + assert vg["exposure_ms"] == 15.0 + assert vg["laser_config"] == "488 only" + + +# --------------------------------------------------------------------------- +# 400 — validation failures +# --------------------------------------------------------------------------- + +def test_timelapse_start_interval_zero_returns_400(): + """interval_seconds = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 0}, + ) + assert r.status_code == 400 + assert "interval_seconds" in r.json()["detail"] + + +def test_timelapse_start_negative_interval_returns_400(): + """interval_seconds < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": -10}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_num_slices_zero_returns_400(): + """num_slices = 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": 0}, + ) + assert r.status_code == 400 + assert "num_slices" in r.json()["detail"] + + +def test_timelapse_start_num_slices_negative_returns_400(): + """num_slices < 0 → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120, "num_slices": -5}, + ) + assert r.status_code == 400 + + +def test_timelapse_start_non_numeric_interval_returns_400(): + """Non-numeric interval_seconds → 400.""" + orch = _make_orchestrator() + r = _app(orch).post( + "/api/devices/timelapse/start", + json={"interval_seconds": "fast"}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# 503 — orchestrator not reachable +# --------------------------------------------------------------------------- + +def test_timelapse_start_no_orchestrator_returns_503(): + """orchestrator is None (agent not running / no session) → 503.""" + r = _app(orchestrator=None).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +def test_timelapse_start_no_agent_bridge_returns_503(): + """agent_bridge missing entirely → 503.""" + server = MagicMock(spec=[]) # no agent_bridge attribute + app = FastAPI() + app.include_router(create_router(server)) + app.dependency_overrides[auth.require_control] = lambda: True + r = TestClient(app).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 503 + + +# --------------------------------------------------------------------------- +# require_control gate +# --------------------------------------------------------------------------- + +def test_timelapse_start_requires_control(): + """POST /api/devices/timelapse/start is gated by require_control (403 without override).""" + orch = _make_orchestrator() + server = MagicMock() + server.agent_bridge.agent.timelapse_orchestrator = orch + server.agent_bridge.agent.client = MagicMock() + server.agent_bridge.agent.lightsheet_monitor = None + app = FastAPI() + app.include_router(create_router(server)) + # No dependency override — require_control will reject non-loopback hosts + r = TestClient(app, raise_server_exceptions=False).post( + "/api/devices/timelapse/start", + json={"interval_seconds": 120}, + ) + assert r.status_code == 403 From 65525e94878afe723c002cb259aa45cd0ca3dbbf Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 09:04:24 +0530 Subject: [PATCH 249/314] fix(b2): style the timelapse panel (define ls-collapsible/ls-sub-* in app CSS) Add 8 CSS classes to main.css that the timelapse panel (#devices-view-manual) references but were never defined in the app: ls-collapsible-head/body/arrow, ls-sub-row/label/section/section-label, ls-tl-status. Panel was rendering unstyled in the real app; scratchpad harness had masked this with inline CSS. Classes mirror the existing .ls-row/.ls-group/.ls-label pattern (same CSS variables, spacing, dark instrument aesthetic). [hidden] fights prevented via explicit [hidden]{display:none} scoped rules. Verified via Chrome MCP harness loading the real main.css with no harness-local ls-* overrides. Also: TODO comment in timelapse_start route noting UI path skips plan auto-linking. --- gently/ui/web/routes/data.py | 2 + gently/ui/web/static/css/main.css | 90 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 8342fe09..99dd07d8 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -750,6 +750,8 @@ async def timelapse_start(payload: dict = Body(...)): # noqa: B008 ) # --- Start timelapse (RIG-DEFERRED: real acquisition) --- + # TODO: UI-initiated timelapses skip the agent tool's plan auto-linking; + # this is intentional — the agent path wires the plan, this route does not. try: result = await orchestrator.start( embryo_ids=embryo_ids, diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index ff964886..7d3a0f7f 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10843,3 +10843,93 @@ body.modal-open { color: var(--map-ink); word-break: break-all; } + +/* ── Timelapse collapsible panel (B2 Task 3) ─────────────────────────────── */ + +/* Clickable header button (also carries .ls-label for font/color) */ +.ls-collapsible-head { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +/* ▶/▼ caret — rotates when expanded */ +.ls-collapsible-arrow { + font-size: 0.5rem; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: transform 0.15s; +} + +.ls-collapsible-head[aria-expanded="true"] .ls-collapsible-arrow { + transform: rotate(90deg); +} + +/* Collapsible body — hidden via [hidden] attribute; explicit rule prevents + display overrides from fighting the attribute (mirrors .ls-lastcap[hidden]) */ +.ls-collapsible-body { + display: flex; + flex-direction: column; + gap: 0.32rem; + margin-top: 0.35rem; +} + +.ls-collapsible-body[hidden] { display: none; } + +/* Sub-rows: like .ls-row but slightly indented for the body interior */ +.ls-sub-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding-left: 0.25rem; +} + +/* Sub-label: like .ls-label but fixed-width to align inputs column */ +.ls-sub-label { + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + min-width: 4.5rem; + flex-shrink: 0; +} + +/* Volume-Geometry inset sub-group */ +.ls-sub-section { + display: flex; + flex-direction: column; + gap: 0.28rem; + padding: 0.4rem 0.5rem; + background: var(--map-paper-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 4px; + margin: 0.15rem 0; +} + +/* "VOLUME GEOMETRY" sub-header */ +.ls-sub-section-label { + font-size: 0.52rem; + font-weight: 600; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--map-ink-mute); + margin-bottom: 0.1rem; +} + +/* Start-result / status line — mono, muted */ +.ls-tl-status { + font-family: 'JetBrains Mono', monospace; + font-size: 0.62rem; + color: var(--map-ink-mute); + padding: 0.2rem 0.25rem; + word-break: break-all; +} + +.ls-tl-status[hidden] { display: none; } From 860f5f2927a7e40807b598824e219467a6925ad6 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 10:09:33 +0530 Subject: [PATCH 250/314] fix(manual): single LED toggle + laser on/off toggle (replace two-button LED, add laser on/off) --- gently/ui/web/static/css/main.css | 4 ++ gently/ui/web/static/js/devices.js | 70 ++++++++++++++++++++++++++---- gently/ui/web/templates/index.html | 16 ++++--- 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 7d3a0f7f..9215466a 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10725,6 +10725,10 @@ body.modal-open { flex-shrink: 0; } +.ls-laser-dot--on { + background: var(--map-accent); +} + /* Laser preset row (B2) */ .ls-laser-row { display: flex; diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 72f1e7b9..2319d9b2 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -96,8 +96,11 @@ const DevicesManager = (function () { // Lightsheet control inputs (rail) let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; - let _lsLedOpen, _lsLedClosed, _lsCamLed, _lsRoomLightBtn; + let _lsLedToggle, _lsCamLed, _lsRoomLightBtn; + let _lsLedIsOpen = false; // LED toggle state: false = Closed (safe default) let _lsCamLedOn = false; + let _lsLaserToggle; + let _lsLaserOn = false; // Laser toggle state: false = OFF (entry-safe default) let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; let _lsLaserStatus; // span inside .ls-laser-indicator — driven by actual laser/off calls let _lsLaserPreset; // From 225de14f927b778470a519d6cbbf26a3070763e5 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:00:43 +0530 Subject: [PATCH 251/314] feat(operations): pin plan kind/scope/state schema in guidance + normalize in validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OPERATION_PLAN_GUIDANCE now enumerates all six kind values with glosses, the scope object shape (three modes), tactic state enum, phase state enum, and a concrete JSON example tactic — replacing vague prose that let a real model produce wrong shapes. _validate_tactics now normalizes before validating: - kind: monitor/reactive→reactive_monitor; timelapse/standing→standing_timelapse; protocol/scripted→scripted_protocol; burst→exclusive_burst; reference→standing_timelapse; unknown→custom - tactic state: running/in_progress→active; pending/queued→planned; complete/completed→done - scope: bare list→{mode:embryos,embryo_ids:[…]}; "global"/"all"→{mode:global}; dict missing mode inferred from role/embryo_ids keys - live.phases[].state: pending/queued→todo; running/in_progress→active; complete/completed→done @tool schema constrained: SKIPPED — ToolParameter.type is a bare string; no items/sub-schema field exists; guidance + normalization cover it. 25 new/updated tests; 0 new failures in full suite. --- gently/app/tools/operation_plan_tools.py | 96 ++++++++++- gently/harness/prompts/templates.py | 43 +++++ tests/test_operation_plan_tool.py | 204 ++++++++++++++++++++++- 3 files changed, 338 insertions(+), 5 deletions(-) diff --git a/gently/app/tools/operation_plan_tools.py b/gently/app/tools/operation_plan_tools.py index 93998a03..b23664b6 100644 --- a/gently/app/tools/operation_plan_tools.py +++ b/gently/app/tools/operation_plan_tools.py @@ -28,13 +28,83 @@ ) _VALID_STATES = frozenset({"planned", "active", "done", "paused"}) +# Synonym maps — normalize model variation to canonical values +_KIND_SYNONYMS: dict[str, str] = { + "monitor": "reactive_monitor", + "reactive": "reactive_monitor", + "timelapse": "standing_timelapse", + "standing": "standing_timelapse", + "protocol": "scripted_protocol", + "scripted": "scripted_protocol", + "burst": "exclusive_burst", + "reference": "standing_timelapse", +} + +_STATE_SYNONYMS: dict[str, str] = { + "in_progress": "active", + "running": "active", + "pending": "planned", + "queued": "planned", + "complete": "done", + "completed": "done", +} + +_PHASE_STATE_SYNONYMS: dict[str, str] = { + "pending": "todo", + "queued": "todo", + "todo": "todo", + "running": "active", + "in_progress": "active", + "active": "active", + "complete": "done", + "completed": "done", + "done": "done", +} + + +def _normalize_scope(scope: object) -> object: + """Normalize scope to canonical {mode, ...} object form.""" + if isinstance(scope, list): + return {"mode": "embryos", "embryo_ids": scope} + if isinstance(scope, str): + if scope in ("global", "all"): + return {"mode": "global"} + # unknown string — wrap as global fallback + return {"mode": "global"} + if isinstance(scope, dict): + if "mode" not in scope: + scope = dict(scope) + if "role" in scope: + scope["mode"] = "role" + elif "embryo_ids" in scope: + scope["mode"] = "embryos" + return scope + # unexpected type — pass through unchanged so we don't lose data + return scope + + +def _normalize_phases(phases: object) -> object: + """Normalize phase state values inside a phases list.""" + if not isinstance(phases, list): + return phases + result = [] + for phase in phases: + if isinstance(phase, dict) and "state" in phase: + raw = phase["state"] + phase = dict(phase) + phase["state"] = _PHASE_STATE_SYNONYMS.get(raw, raw) + result.append(phase) + return result + def _validate_tactics(tactics: list) -> list[dict]: """Validate and normalise a tactics list. Each tactic must have id, name, kind, state. - Unknown kinds are clamped to 'custom'. - Invalid states raise ValueError. + Kind synonyms are mapped to canonical values; unknown kinds are clamped to + 'custom'. State synonyms are mapped to canonical values; unknown states + raise ValueError. Scope is normalized to {mode, ...} object form. + Phase states inside live.phases are normalized to todo/active/done. """ validated = [] for i, t in enumerate(tactics): @@ -44,20 +114,40 @@ def _validate_tactics(tactics: list) -> list[dict]: if not t.get(required_field): raise ValueError(f"Tactic at index {i} missing required field '{required_field}'") tactic = dict(t) + + # --- normalize kind --- kind = tactic["kind"] + kind = _KIND_SYNONYMS.get(kind, kind) if kind not in _VALID_KINDS: logger.warning( "Tactic '%s' has unknown kind '%s' — clamped to 'custom'", tactic.get("id"), kind, ) - tactic["kind"] = "custom" + kind = "custom" + tactic["kind"] = kind + + # --- normalize tactic state --- state = tactic["state"] + state = _STATE_SYNONYMS.get(state, state) if state not in _VALID_STATES: raise ValueError( f"Tactic '{tactic.get('id')}' has invalid state '{state}'. " f"Must be one of: {sorted(_VALID_STATES)}" ) + tactic["state"] = state + + # --- normalize scope --- + if "scope" in tactic: + tactic["scope"] = _normalize_scope(tactic["scope"]) + + # --- normalize phase states inside live.phases --- + live = tactic.get("live") + if isinstance(live, dict) and "phases" in live: + live = dict(live) + live["phases"] = _normalize_phases(live["phases"]) + tactic["live"] = live + validated.append(tactic) return validated diff --git a/gently/harness/prompts/templates.py b/gently/harness/prompts/templates.py index 536064b4..957ec0ad 100644 --- a/gently/harness/prompts/templates.py +++ b/gently/harness/prompts/templates.py @@ -237,6 +237,49 @@ `last_fired`, `new_phase`, …) are merged in by the updater as live telemetry arrives; you can seed them at declaration time if the value is already known. +### Allowed values — use these exact strings (renderer dispatches on them) + +**`kind`** ∈ one of: +| value | use when | +|---|---| +| `standing_timelapse` | continuous / periodic imaging running throughout | +| `reactive_monitor` | armed watcher that fires on a condition (signal, threshold) | +| `scripted_protocol` | fixed sequence of named phases (ramp, hold, recovery, …) | +| `exclusive_burst` | short high-cadence burst that blocks other acquisition | +| `oneshot` | single action (z-stack, snapshot, one-off step) | +| `custom` | anything that doesn't fit the above | + +**`state`** (tactic) ∈ `planned | active | done | paused` +Start every tactic as `"planned"`; advance to `"active"` when it begins, +`"done"` when it finishes, `"paused"` if suspended. + +**`scope`** — always an object with a `mode` key (never a bare list or string): +- `{"mode": "global"}` — applies to every embryo in the session +- `{"mode": "embryos", "embryo_ids": ["E01", "E02"]}` — specific embryo IDs +- `{"mode": "role", "role": "test"}` — all embryos carrying the named role + +**`live.phases[].state`** ∈ `todo | active | done` + +### Minimal tactic example + +```json +{ + "id": "t2", + "name": "Temperature ramp", + "kind": "scripted_protocol", + "state": "planned", + "scope": {"mode": "embryos", "embryo_ids": ["E01", "E02"]}, + "rationale": "25 → 16 °C step to trigger stress response", + "live": { + "readouts": [{"label": "setpoint", "value": "25 °C"}], + "phases": [ + {"name": "ramp", "state": "todo", "count": 0, "pips": []}, + {"name": "hold", "state": "todo", "count": 0, "pips": []} + ] + } +} +``` + Re-call `declare_operation_plan` (patch) whenever a tactic's state changes: `"planned"` → `"active"` when you start it, `"active"` → `"done"` when it finishes. This keeps the Operations view in the UI synchronized with reality. diff --git a/tests/test_operation_plan_tool.py b/tests/test_operation_plan_tool.py index ad6804d6..7f2255b6 100644 --- a/tests/test_operation_plan_tool.py +++ b/tests/test_operation_plan_tool.py @@ -365,7 +365,7 @@ async def test_missing_tactic_id_returns_error_no_write(): @pytest.mark.asyncio async def test_invalid_state_returns_error_no_write(): - """A tactic with an invalid state causes an error; nothing is persisted.""" + """A tactic with a completely unrecognised state causes an error; nothing is persisted.""" from gently.app.tools.operation_plan_tools import declare_operation_plan ctx = _make_context() @@ -374,7 +374,7 @@ async def test_invalid_state_returns_error_no_write(): "id": "t1", "name": "Bad state", "kind": "oneshot", - "state": "running", # not a valid state + "state": "completely_unknown_xyz", # not a valid state and not a known synonym } ] result = await declare_operation_plan( @@ -388,6 +388,206 @@ async def test_invalid_state_returns_error_no_write(): assert ctx["agent"].context_store.calls == [] +# --------------------------------------------------------------------------- +# Normalization tests (Fix 2) +# --------------------------------------------------------------------------- + + +def test_normalize_kind_synonyms(): + """Kind synonyms are mapped to canonical values before validation.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + cases = [ + ("monitor", "reactive_monitor"), + ("reactive", "reactive_monitor"), + ("timelapse", "standing_timelapse"), + ("standing", "standing_timelapse"), + ("protocol", "scripted_protocol"), + ("scripted", "scripted_protocol"), + ("burst", "exclusive_burst"), + ("reference", "standing_timelapse"), + ] + for raw_kind, expected in cases: + tactics = [{"id": "t1", "name": "T", "kind": raw_kind, "state": "planned"}] + result = _validate_tactics(tactics) + assert result[0]["kind"] == expected, f"kind '{raw_kind}' should map to '{expected}'" + + +def test_normalize_kind_unknown_to_custom(): + """An unrecognised kind that is not a synonym is clamped to 'custom'.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [{"id": "t1", "name": "T", "kind": "future_kind_v99", "state": "planned"}] + result = _validate_tactics(tactics) + assert result[0]["kind"] == "custom" + + +def test_normalize_scope_bare_list(): + """A bare list scope is converted to {mode:'embryos', embryo_ids:[...]}.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": ["E01", "E02"], + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + + +def test_normalize_scope_global_string(): + """The string 'global' scope is converted to {mode:'global'}.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": "global", + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == {"mode": "global"} + + +def test_normalize_scope_dict_missing_mode_with_embryo_ids(): + """A scope dict with embryo_ids but no mode gets mode:'embryos' injected.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"embryo_ids": ["E01"]}, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"]["mode"] == "embryos" + assert result[0]["scope"]["embryo_ids"] == ["E01"] + + +def test_normalize_scope_dict_missing_mode_with_role(): + """A scope dict with role but no mode gets mode:'role' injected.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": {"role": "test"}, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"]["mode"] == "role" + assert result[0]["scope"]["role"] == "test" + + +def test_normalize_scope_well_formed_dict_unchanged(): + """A correctly formed scope dict passes through unchanged.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + scope = {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "standing_timelapse", + "state": "planned", + "scope": scope, + } + ] + result = _validate_tactics(tactics) + assert result[0]["scope"] == scope + + +def test_normalize_phase_state_pending_to_todo(): + """Phase state 'pending' is normalized to 'todo'.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "T", + "kind": "scripted_protocol", + "state": "planned", + "live": { + "readouts": [], + "phases": [ + {"name": "ramp", "state": "pending", "count": 0, "pips": []}, + {"name": "hold", "state": "running", "count": 0, "pips": []}, + {"name": "done_phase", "state": "completed", "count": 3, "pips": []}, + ], + }, + } + ] + result = _validate_tactics(tactics) + phases = result[0]["live"]["phases"] + assert phases[0]["state"] == "todo" + assert phases[1]["state"] == "active" + assert phases[2]["state"] == "done" + + +def test_normalize_tactic_state_synonyms(): + """Tactic state synonyms are mapped to canonical values.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + cases = [ + ("in_progress", "active"), + ("running", "active"), + ("pending", "planned"), + ("queued", "planned"), + ("complete", "done"), + ("completed", "done"), + ] + for raw_state, expected in cases: + tactics = [{"id": "t1", "name": "T", "kind": "oneshot", "state": "planned"}] + # Override state directly + tactics[0]["state"] = raw_state + result = _validate_tactics(tactics) + assert result[0]["state"] == expected, ( + f"state '{raw_state}' should map to '{expected}'" + ) + + +def test_normalize_full_scenario(): + """Combined: kind synonym + bare-list scope + pending phase state + running tactic state.""" + from gently.app.tools.operation_plan_tools import _validate_tactics + + tactics = [ + { + "id": "t1", + "name": "Temp monitor", + "kind": "monitor", # synonym → reactive_monitor + "state": "in_progress", # synonym → active + "scope": ["E01", "E02"], # bare list → {mode:embryos, embryo_ids:[...]} + "rationale": "Watch temp", + "live": { + "readouts": [{"label": "temp", "value": "25 °C"}], + "phases": [ + {"name": "watch", "state": "pending", "count": 0, "pips": []}, + ], + }, + } + ] + result = _validate_tactics(tactics) + t = result[0] + assert t["kind"] == "reactive_monitor" + assert t["state"] == "active" + assert t["scope"] == {"mode": "embryos", "embryo_ids": ["E01", "E02"]} + assert t["live"]["phases"][0]["state"] == "todo" + assert t["live"]["readouts"] == [{"label": "temp", "value": "25 °C"}] + + # --------------------------------------------------------------------------- # Registration test # --------------------------------------------------------------------------- From a1c81705822d7bf1fbf7720a66d7de8a9fbfa489 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:06:53 +0530 Subject: [PATCH 252/314] fix(device): bound live-view memory (cap+drain+snap mode) + side-B galvo/piezo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX 1 — buffer hardening: - Add snap-loop mode (default): _grab_lightsheet_frame_sync uses snapImage()+getImage() per frame; no circular buffer, hard memory ceiling. - Continuous mode (opt-in via _ls_params["mode"]="continuous"): cap buffer with setCircularBufferMemoryFootprint(256) before start; drain with clearCircularBuffer() after each getLastImage() peek. - _stop_lightsheet_sequence_sync: always calls clearCircularBuffer() on every exit path (disconnect, error, pause, view-exit via finally block). FIX 2 — side-B galvo/piezo: - device_factory.py: defensively register scanner_b (Scanner:CD:33) and piezo_b (PiezoStage:Q:35) when present in getLoadedDevices(); skip+log on single-side rigs. - _park_lightsheet_sync: select scanner_b/piezo_b for side B, fall back to A with warning when absent; single-camera rigs unaffected. TDD: 12 tests red → green; 40 target tests pass; 0 new failures in full suite. --- gently/hardware/dispim/device_factory.py | 37 ++++ gently/hardware/dispim/device_layer.py | 137 ++++++++++-- tests/test_dual_camera_factory.py | 56 +++++ tests/test_lightsheet_buffer.py | 252 +++++++++++++++++++++++ tests/test_lightsheet_side_param.py | 131 +++++++++++- tests/test_lightsheet_streamer.py | 6 +- 6 files changed, 593 insertions(+), 26 deletions(-) create mode 100644 tests/test_lightsheet_buffer.py diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index 96b0d276..cfef7d24 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -67,6 +67,9 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non "camera_b_name": "HamCam2", "scanner_name": "Scanner:AB:33", "piezo_name": "PiezoStage:P:34", + # Side-B optics: registered defensively (absent on single-side rigs) + "scanner_b_name": "Scanner:CD:33", + "piezo_b_name": "PiezoStage:Q:35", "fdrive_name": "ZStage:V:37", "bottom_camera_name": "Bottom PCO", "led_name": "LED:X:31", @@ -129,6 +132,40 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create camera_b: %s", e) + # Defensively register the side-B galvo scanner (Scanner:CD:33). + # Absent on single-side rigs — skip + log, do not crash. + try: + scanner_b_name = cfg.get("scanner_b_name", "Scanner:CD:33") + loaded_devices = list(core.getLoadedDevices()) + if scanner_b_name in loaded_devices: + scanner_b = DiSPIMScanner(name=scanner_b_name, core=core) + devices["scanner_b"] = scanner_b + logger.info("Created scanner_b (side B): %s", scanner_b_name) + else: + logger.warning( + "scanner_b (%s) not in loaded devices — single-side rig or device absent; skipping", + scanner_b_name, + ) + except Exception as e: + logger.warning("Could not create scanner_b: %s", e) + + # Defensively register the side-B imaging piezo (PiezoStage:Q:35). + # Absent on single-side rigs — skip + log, do not crash. + try: + piezo_b_name = cfg.get("piezo_b_name", "PiezoStage:Q:35") + loaded_devices = list(core.getLoadedDevices()) + if piezo_b_name in loaded_devices: + piezo_b = DiSPIMPiezo(name=piezo_b_name, core=core) + devices["piezo_b"] = piezo_b + logger.info("Created piezo_b (side B): %s", piezo_b_name) + else: + logger.warning( + "piezo_b (%s) not in loaded devices — single-side rig or device absent; skipping", + piezo_b_name, + ) + except Exception as e: + logger.warning("Could not create piezo_b: %s", e) + try: from .devices import DiSPIMLightSource diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 69b51581..2c5127a3 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -174,7 +174,15 @@ def __init__( self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows self._ls_target_max_dim: int = 512 self._ls_jpeg_quality: int = 70 - self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + self._ls_params: dict = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + # "snap" → per-frame snapImage()+getImage() — hard memory ceiling, safe default. + # "continuous" → startContinuousSequenceAcquisition (opt-in, higher FPS). + "mode": "snap", + } self._ls_seq_started: bool = False self._ls_applied: dict = {} # last-applied galvo/piezo/exposure self._ls_parked: dict = {} # last-parked galvo/piezo setPosition values @@ -981,6 +989,11 @@ async def _broadcast_camera(self, payload: dict[str, Any]): def _park_lightsheet_sync(self) -> None: """Park scanner galvo + imaging piezo at the current live params (static sheet). + Side 'A' drives devices["scanner"] / devices["piezo"] (Scanner:AB:33 / + PiezoStage:P:34). Side 'B' drives devices["scanner_b"] / devices["piezo_b"] + (Scanner:CD:33 / PiezoStage:Q:35) when registered. Falls back to side A + with a warning on single-side rigs so they are never broken. + Guards: - set_spim_state("Idle") fires once per stream session, not per frame (4 serial round-trips on first call → 0 on every subsequent frame). @@ -988,8 +1001,32 @@ def _park_lightsheet_sync(self) -> None: last-applied value (no-op on steady-state frames). """ p = self._ls_params - scanner = self.devices.get("scanner") - piezo = self.devices.get("piezo") + side = p.get("side", "A") + + # Resolve scanner for the requested side (with fallback to A). + if side == "B": + if "scanner_b" in self.devices: + scanner = self.devices["scanner_b"] + else: + logger.warning( + "Side B requested but scanner_b not registered; falling back to side A for scanner" + ) + scanner = self.devices.get("scanner") + else: + scanner = self.devices.get("scanner") + + # Resolve piezo for the requested side (with fallback to A). + if side == "B": + if "piezo_b" in self.devices: + piezo = self.devices["piezo_b"] + else: + logger.warning( + "Side B requested but piezo_b not registered; falling back to side A for piezo" + ) + piezo = self.devices.get("piezo") + else: + piezo = self.devices.get("piezo") + # SPIM Idle state machine: drive both devices Idle once per session. if not self._ls_spim_idle: if scanner is not None: @@ -1016,7 +1053,18 @@ def _park_lightsheet_sync(self) -> None: self._ls_parked["piezo"] = want def _ensure_lightsheet_sequence_sync(self) -> None: - """Start (or restart on exposure/side change) the continuous sequence on the SPIM camera. + """Configure (or reconfigure on exposure/side change) the SPIM camera. + + Two modes gated by ``_ls_params["mode"]``: + + "snap" (default, safe) — per-frame snapImage()+getImage(). No circular + buffer at all; memory is bounded to one frame. Sets camera device + and exposure only when a reconfiguration is needed; never calls + startContinuousSequenceAcquisition. + + "continuous" (opt-in) — startContinuousSequenceAcquisition with a hard + footprint cap (256 MB) so the buffer can't balloon on a production + box. The grab loop drains with clearCircularBuffer() after each peek. Side 'A' uses devices["camera"] (HamCam1); side 'B' uses devices["camera_b"] (HamCam2) when registered. If side B is requested but camera_b is absent, @@ -1025,10 +1073,11 @@ def _ensure_lightsheet_sequence_sync(self) -> None: core = self.system.core p = self._ls_params side = p.get("side", "A") + mode = p.get("mode", "snap") # Resolve the camera for the requested side if side == "B" and "camera_b" in self.devices: - cam = self.devices.get("camera_b") + cam = self.devices["camera_b"] else: if side == "B": logger.warning( @@ -1039,33 +1088,76 @@ def _ensure_lightsheet_sequence_sync(self) -> None: if cam is None: raise RuntimeError("No lightsheet camera configured") - need_restart = ( + need_reconfigure = ( not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] or self._ls_applied.get("side") != side ) - if need_restart: - if core.isSequenceRunning(): - core.stopSequenceAcquisition() - if core.getCameraDevice() != cam.name: - core.setCameraDevice(cam.name) - core.setExposure(cam.name, float(p["exposure"])) - core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) - self._ls_seq_started = True - self._ls_applied["exposure"] = p["exposure"] - self._ls_applied["side"] = side + + if mode == "snap": + # Snap mode: configure camera/exposure on change; no continuous sequence. + if need_reconfigure: + # Stop any lingering continuous sequence before snap takes over. + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + try: + core.clearCircularBuffer() + except Exception: + pass + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + self._ls_seq_started = True # "camera configured" flag; no sequence running + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side + else: + # Continuous mode: cap buffer footprint then start sequence. + if need_reconfigure: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + # Hard cap: prevent buffer balloon that crashed the production box. + try: + core.setCircularBufferMemoryFootprint(256) + except Exception: + logger.debug( + "setCircularBufferMemoryFootprint not available on this core", + exc_info=True, + ) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + self._ls_applied["side"] = side def _grab_lightsheet_frame_sync(self): - """Park → ensure sequence running → peek the latest frame (never drain).""" + """Park → configure camera → grab one frame. + + Snap mode (default): snapImage()+getImage() — no circular buffer, + hard memory ceiling, safe for long live-view sessions. + + Continuous mode (opt-in): peeks via getLastImage() then drains the + buffer with clearCircularBuffer() to prevent it sitting full. + """ try: - self._park_lightsheet_sync() # galvo/piezo applied live - self._ensure_lightsheet_sequence_sync() # start / restart on exposure + self._park_lightsheet_sync() + self._ensure_lightsheet_sequence_sync() try: from gently.hardware.dispim.devices.acquisition import _safe_obtain except (ImportError, AttributeError): _safe_obtain = None core = self.system.core - img = core.getLastImage() + mode = self._ls_params.get("mode", "snap") + if mode == "snap": + core.snapImage() + img = core.getImage() + else: + img = core.getLastImage() + try: + core.clearCircularBuffer() + except Exception: + pass if _safe_obtain is not None: try: img = _safe_obtain(img) @@ -1082,6 +1174,11 @@ def _stop_lightsheet_sequence_sync(self) -> None: self.system.core.stopSequenceAcquisition() except Exception: logger.debug("stop lightsheet sequence failed", exc_info=True) + # Drain the buffer on every stop path (snap mode: no-op; continuous: release memory). + try: + self.system.core.clearCircularBuffer() + except Exception: + pass self._ls_seq_started = False self._ls_applied = {} # Reset park guard so the next stream session re-idles the state diff --git a/tests/test_dual_camera_factory.py b/tests/test_dual_camera_factory.py index 5476184e..7149f82e 100644 --- a/tests/test_dual_camera_factory.py +++ b/tests/test_dual_camera_factory.py @@ -105,3 +105,59 @@ def test_camera_b_absent_on_empty_device_list(): devices = create_devices_from_mmcore(core) assert "camera_b" not in devices assert "camera" in devices + + +# --------------------------------------------------------------------------- +# Side-B optics: scanner_b / piezo_b defensive registration +# --------------------------------------------------------------------------- + + +def test_scanner_b_created_when_present(): + """Scanner:CD:33 in getLoadedDevices → devices['scanner_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "Scanner:CD:33"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices, "scanner_b must be registered when Scanner:CD:33 is loaded" + assert devices["scanner_b"].name == "Scanner:CD:33" + + +def test_scanner_b_absent_when_not_loaded(): + """Scanner:CD:33 NOT loaded → scanner_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + + +def test_piezo_b_created_when_present(): + """PiezoStage:Q:35 in getLoadedDevices → devices['piezo_b'] with that name.""" + core = FakeCore(loaded_devices=["HamCam1", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" in devices, "piezo_b must be registered when PiezoStage:Q:35 is loaded" + assert devices["piezo_b"].name == "PiezoStage:Q:35" + + +def test_piezo_b_absent_when_not_loaded(): + """PiezoStage:Q:35 NOT loaded → piezo_b absent, no crash.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "piezo_b" not in devices + + +def test_scanner_b_piezo_b_both_present_on_dual_side_rig(): + """Full dual-side rig: both scanner_b and piezo_b created; side A unchanged.""" + core = FakeCore(loaded_devices=["HamCam1", "HamCam2", "Scanner:CD:33", "PiezoStage:Q:35"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" in devices + assert "piezo_b" in devices + # Side-A optics must still be present + assert "scanner" in devices + assert "piezo" in devices + + +def test_scanner_b_piezo_b_both_absent_on_single_side_rig(): + """Single-side rig: neither scanner_b nor piezo_b created; A-side and camera OK.""" + core = FakeCore(loaded_devices=["HamCam1"]) + devices = create_devices_from_mmcore(core) + assert "scanner_b" not in devices + assert "piezo_b" not in devices + assert "scanner" in devices + assert "piezo" in devices diff --git a/tests/test_lightsheet_buffer.py b/tests/test_lightsheet_buffer.py new file mode 100644 index 00000000..de9f9235 --- /dev/null +++ b/tests/test_lightsheet_buffer.py @@ -0,0 +1,252 @@ +"""Tests for live-view buffer hardening. + +Verifies: + - snap mode (default) uses snapImage()+getImage() — no circular buffer + - snap mode does NOT call startContinuousSequenceAcquisition + - continuous mode calls setCircularBufferMemoryFootprint(256) before starting + - continuous mode calls clearCircularBuffer() after each getLastImage() + - _stop_lightsheet_sequence_sync calls clearCircularBuffer() in both modes +""" +import sys +from unittest.mock import MagicMock + +import numpy as np +import pytest + +# Patch heavy hardware deps before importing device_layer +for _mod in ( + "bluesky", + "bluesky.run_engine", + "ophyd", + "ophyd.status", + "pymmcore", + "gently.hardware.console_ui", +): + if _mod not in sys.modules: + sys.modules[_mod] = MagicMock() + +import bluesky as _bs # noqa: E402 + +_bs.RunEngine = MagicMock(name="RunEngine") + +from gently.hardware.dispim.device_layer import DeviceLayerServer # noqa: E402 + + +# --------------------------------------------------------------------------- +# Full-featured FakeCore tracking every buffer-related call +# --------------------------------------------------------------------------- + + +class BufferFakeCore: + """CMMCore stub that records every buffer/snap/sequence call.""" + + def __init__(self): + self.cam = None + self.running = False + self.started = 0 + self.stopped = 0 + self.exposure = None + self.footprint_set: int | None = None # value passed to setCircularBufferMemoryFootprint + self.clear_called = 0 + self.snap_called = 0 + self.get_image_called = 0 + self.get_last_image_called = 0 + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam or "" + + def setExposure(self, n, ms): + self.exposure = ms + + def isSequenceRunning(self): + return self.running + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def setCircularBufferMemoryFootprint(self, mb: int): + self.footprint_set = mb + + def clearCircularBuffer(self): + self.clear_called += 1 + + def snapImage(self): + self.snap_called += 1 + + def getImage(self): + self.get_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + def getLastImage(self): + self.get_last_image_called += 1 + return np.zeros((4, 4), dtype=np.uint16) + + +class FakeAxisOffset: + def __init__(self): + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + +class FakeScanner: + def __init__(self, name): + self.name = name + self.sa_offset_y = FakeAxisOffset() + + def set_spim_state(self, state): + pass + + +class FakePiezo: + def __init__(self, name): + self.name = name + self.last_pos = None + + def setPosition(self, val): + self.last_pos = val + + def set_spim_state(self, state): + pass + + +def _make_dl_buffer(mode: str = "snap") -> DeviceLayerServer: + """Build a minimal DL for buffer-hardening tests.""" + dl = DeviceLayerServer.__new__(DeviceLayerServer) + core = BufferFakeCore() + dl.system = type("S", (), {"core": core})() + cam_a = type("C", (), {"name": "HamCam1"})() + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": mode, + } + dl._ls_seq_started = False + dl._ls_applied = {} + dl._ls_interval_sec = 0.0 + dl._ls_parked = {} + dl._ls_spim_idle = False + return dl + + +# --------------------------------------------------------------------------- +# Snap mode — snapImage + getImage, no circular buffer +# --------------------------------------------------------------------------- + + +def test_snap_mode_calls_snapimage_and_getimage(): + """snap mode: _grab_lightsheet_frame_sync calls snapImage() + getImage().""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.snap_called >= 1, "snapImage() must be called in snap mode" + assert core.get_image_called >= 1, "getImage() must be called in snap mode" + + +def test_snap_mode_does_not_call_get_last_image(): + """snap mode: getLastImage() is NOT called (avoids the never-draining buffer).""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.get_last_image_called == 0 + + +def test_snap_mode_does_not_start_continuous_sequence(): + """snap mode: startContinuousSequenceAcquisition is never called.""" + dl = _make_dl_buffer(mode="snap") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 0, ( + "startContinuousSequenceAcquisition must not be called in snap mode" + ) + + +def test_snap_mode_does_not_start_continuous_on_grab(): + """snap mode grab loop never starts continuous acquisition.""" + dl = _make_dl_buffer(mode="snap") + dl._grab_lightsheet_frame_sync() + assert dl.system.core.started == 0 + + +# --------------------------------------------------------------------------- +# Continuous mode — buffer cap + drain +# --------------------------------------------------------------------------- + + +def test_continuous_mode_sets_circular_buffer_footprint(): + """continuous mode: setCircularBufferMemoryFootprint(256) called before start.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + core = dl.system.core + assert core.footprint_set is not None, ( + "setCircularBufferMemoryFootprint must be called in continuous mode" + ) + assert core.footprint_set == 256, ( + f"footprint must be 256 MB, got {core.footprint_set}" + ) + + +def test_continuous_mode_calls_clear_after_grab(): + """continuous mode: clearCircularBuffer() called after each getLastImage().""" + dl = _make_dl_buffer(mode="continuous") + dl._grab_lightsheet_frame_sync() + core = dl.system.core + assert core.clear_called >= 1, ( + "clearCircularBuffer() must be called after getLastImage() in continuous mode" + ) + + +def test_continuous_mode_starts_sequence(): + """continuous mode: startContinuousSequenceAcquisition is called.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl.system.core.started == 1 + + +# --------------------------------------------------------------------------- +# Stop sequence — clearCircularBuffer on every exit path +# --------------------------------------------------------------------------- + + +def test_stop_calls_clear_circular_buffer(): + """_stop_lightsheet_sequence_sync always calls clearCircularBuffer().""" + dl = _make_dl_buffer(mode="continuous") + # Start a sequence first so there's something to stop + dl._ensure_lightsheet_sequence_sync() + clear_before = dl.system.core.clear_called + dl._stop_lightsheet_sequence_sync() + assert dl.system.core.clear_called > clear_before, ( + "clearCircularBuffer must be called in _stop_lightsheet_sequence_sync" + ) + + +def test_stop_resets_seq_started(): + """_stop_lightsheet_sequence_sync resets _ls_seq_started to False.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_seq_started is True + dl._stop_lightsheet_sequence_sync() + assert dl._ls_seq_started is False + + +def test_stop_clears_applied_state(): + """_stop_lightsheet_sequence_sync clears _ls_applied so next start does a full restart.""" + dl = _make_dl_buffer(mode="continuous") + dl._ensure_lightsheet_sequence_sync() + assert dl._ls_applied != {} + dl._stop_lightsheet_sequence_sync() + assert dl._ls_applied == {} diff --git a/tests/test_lightsheet_side_param.py b/tests/test_lightsheet_side_param.py index 25009334..e4679c47 100644 --- a/tests/test_lightsheet_side_param.py +++ b/tests/test_lightsheet_side_param.py @@ -68,17 +68,82 @@ def stopSequenceAcquisition(self): def isSequenceRunning(self): return self.running + def setCircularBufferMemoryFootprint(self, mb): + pass # no-op for camera-selection tests -def _make_dl(with_camera_b: bool = True) -> DeviceLayerServer: - """Build a minimal DeviceLayerServer with faked core + devices.""" + def clearCircularBuffer(self): + pass # no-op for camera-selection tests + + +class FakeAxisOffset: + """Stub for DiSPIMScanner.sa_offset_y tracking setPosition calls.""" + + def __init__(self): + self.last_pos = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + +class FakeScanner: + """Stub scanner with sa_offset_y and set_spim_state.""" + + def __init__(self, name: str): + self.name = name + self.sa_offset_y = FakeAxisOffset() + self.spim_state = None + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +class FakePiezo: + """Stub piezo tracking setPosition and set_spim_state calls.""" + + def __init__(self, name: str): + self.name = name + self.last_pos = None + self.spim_state = None + + def setPosition(self, val: float) -> None: + self.last_pos = val + + def set_spim_state(self, state: str) -> None: + self.spim_state = state + + +def _make_dl( + with_camera_b: bool = True, + with_scanner_b: bool = False, + with_piezo_b: bool = False, +) -> DeviceLayerServer: + """Build a minimal DeviceLayerServer with faked core + devices. + + mode is set to "continuous" so existing sequence-start assertions remain + meaningful (snap mode is the production default but tested separately). + """ dl = DeviceLayerServer.__new__(DeviceLayerServer) dl.system = type("S", (), {"core": FakeCore()})() cam_a = type("C", (), {"name": "HamCam1"})() cam_b = type("C", (), {"name": "HamCam2"})() - dl.devices = {"camera": cam_a} + dl.devices = { + "camera": cam_a, + "scanner": FakeScanner("Scanner:AB:33"), + "piezo": FakePiezo("PiezoStage:P:34"), + } if with_camera_b: dl.devices["camera_b"] = cam_b - dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0, "side": "A"} + if with_scanner_b: + dl.devices["scanner_b"] = FakeScanner("Scanner:CD:33") + if with_piezo_b: + dl.devices["piezo_b"] = FakePiezo("PiezoStage:Q:35") + dl._ls_params = { + "galvo": 0.0, + "piezo": 50.0, + "exposure": 20.0, + "side": "A", + "mode": "continuous", # explicit — snap mode tested in test_lightsheet_buffer.py + } dl._ls_seq_started = False dl._ls_applied = {} dl._ls_interval_sec = 0.0 @@ -221,3 +286,61 @@ async def test_handle_get_cameras_without_camera_b(): resp = await dl.handle_get_cameras(req) body = json.loads(resp.body) assert body["cameras"] == ["A"] + + +# --------------------------------------------------------------------------- +# _park_lightsheet_sync — side selects correct scanner/piezo +# --------------------------------------------------------------------------- + + +def test_side_a_parks_scanner_a(): + """Side A → _park_lightsheet_sync drives scanner A's galvo.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_b_parks_scanner_b(): + """Side B with scanner_b present → _park_lightsheet_sync drives scanner B.""" + dl = _make_dl(with_scanner_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["scanner_b"].sa_offset_y.last_pos is not None + # Side A scanner must be untouched + assert dl.devices["scanner"].sa_offset_y.last_pos is None + + +def test_side_b_scanner_b_absent_falls_back_to_scanner_a(): + """Side B without scanner_b → graceful fallback to scanner A, no crash.""" + dl = _make_dl(with_scanner_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + # Fallback: scanner A gets the park + assert dl.devices["scanner"].sa_offset_y.last_pos is not None + + +def test_side_a_parks_piezo_a(): + """Side A → _park_lightsheet_sync drives piezo A.""" + dl = _make_dl() + dl._ls_params["side"] = "A" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None + + +def test_side_b_parks_piezo_b(): + """Side B with piezo_b present → _park_lightsheet_sync drives piezo B.""" + dl = _make_dl(with_scanner_b=True, with_piezo_b=True) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo_b"].last_pos is not None + # Side A piezo must be untouched + assert dl.devices["piezo"].last_pos is None + + +def test_side_b_piezo_b_absent_falls_back_to_piezo_a(): + """Side B without piezo_b → graceful fallback to piezo A, no crash.""" + dl = _make_dl(with_piezo_b=False) + dl._ls_params["side"] = "B" + dl._park_lightsheet_sync() + assert dl.devices["piezo"].last_pos is not None diff --git a/tests/test_lightsheet_streamer.py b/tests/test_lightsheet_streamer.py index c36377a1..9091ea13 100644 --- a/tests/test_lightsheet_streamer.py +++ b/tests/test_lightsheet_streamer.py @@ -64,7 +64,8 @@ async def test_grab_parks_and_peeks(monkeypatch): dl = _make_dl() dl._state_pause_counter = 0 dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 - dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0} + # Explicit continuous mode: this test verifies the sequence-start + getLastImage path. + dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0, "mode": "continuous"} dl._ls_seq_started = False; dl._ls_applied = {} dl._ls_interval_sec = 0.0 img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) @@ -79,7 +80,8 @@ async def test_exposure_change_restarts_sequence(): dl = _make_dl() dl._state_pause_counter = 0 dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 - dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0} + # Explicit continuous mode: this test verifies restart on exposure change. + dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0, "mode": "continuous"} dl._ls_seq_started = False; dl._ls_applied = {} dl._ls_interval_sec = 0.0 await asyncio.to_thread(dl._grab_lightsheet_frame_sync) From 656e97496f3851f11030f823cc76d07a8b2e42c4 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Mon, 29 Jun 2026 11:19:36 +0530 Subject: [PATCH 253/314] feat(manual): timelapse config as an accordion with active/inactive section states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the flat timelapse panel into 3 independently collapsible accordion sections — Schedule (open by default), Targets, Volume geometry. Active/inactive state: - Each section header starts muted (hollow 5-px dot, dimmed label). - Any user input/change event in a section flips it active: dot fills accent cyan, label turns accent, a one-line summary appears inline. - Summaries: "60 s · 5 frames" / "all · Idle (plain imaging)" / "80 sl · 10 ms · ALL OFF". - The outer Timelapse panel header shows an accent dot once any section is set; the Start Timelapse button turns accent-bordered when ready. - Re-entering the manual view (initTlForm) resets all touched state and clears visual active state. Payload unchanged: all field IDs preserved, POST body identical. node --check: exit 0. --- gently/ui/web/static/css/main.css | 132 ++++++++++++++++ gently/ui/web/static/js/devices.js | 121 +++++++++++++-- gently/ui/web/templates/index.html | 235 ++++++++++++++++------------- 3 files changed, 366 insertions(+), 122 deletions(-) diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 9215466a..7e04b232 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10937,3 +10937,135 @@ body.modal-open { } .ls-tl-status[hidden] { display: none; } + +/* ── Timelapse accordion (active/inactive section states — B3 UX) ─────────── */ + +/* Wrapper for each accordion section */ +.ls-acc-section { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--map-rule-soft); + padding-bottom: 0.12rem; + margin-bottom: 0.08rem; +} +.ls-acc-section:last-of-type { + border-bottom: none; + margin-bottom: 0; +} + +/* Section header button */ +.ls-acc-head { + display: flex; + align-items: center; + gap: 0.32rem; + background: transparent; + border: none; + padding: 0.26rem 0; + cursor: pointer; + width: 100%; + text-align: left; + font-family: inherit; +} + +/* Status dot — hollow/muted when inactive */ +.ls-acc-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid var(--map-ink-mute); + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} + +/* Section label */ +.ls-acc-title { + font-size: 0.575rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--map-ink-mute); + flex-shrink: 0; + transition: color 0.15s; +} + +/* One-line summary — shown only when section is touched */ +.ls-acc-summary { + flex: 1; + font-size: 0.57rem; + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.02em; + color: var(--map-ink-mute); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.ls-acc-summary[hidden] { display: none; } + +/* Caret arrow */ +.ls-acc-arrow { + font-size: 0.44rem; + color: var(--map-ink-mute); + flex-shrink: 0; + margin-left: auto; + transition: transform 0.15s, color 0.15s; +} +.ls-acc-head[aria-expanded="true"] .ls-acc-arrow { + transform: rotate(90deg); +} + +/* ── Active / touched state ────────────────────────────────────────────────── */ +.ls-acc-head.is-active .ls-acc-dot { + background: var(--map-accent); + border-color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-title { + color: var(--map-accent); +} +.ls-acc-head.is-active .ls-acc-summary { + color: var(--map-accent); + opacity: 0.85; +} +.ls-acc-head.is-active .ls-acc-arrow { + color: var(--map-accent); +} + +/* Section body */ +.ls-acc-body { + display: flex; + flex-direction: column; + gap: 0.26rem; + padding: 0.18rem 0 0.32rem 0.5rem; +} +.ls-acc-body[hidden] { display: none; } + +/* Outer timelapse panel header dot — signals any section is set */ +.ls-tl-outer-dot { + width: 5px; + height: 5px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} +.ls-tl-outer-dot.is-active { + background: var(--map-accent); + border-color: var(--map-accent); +} + +/* Start button reads "ready" (accent) once any section is configured */ +.ls-tl-start-btn { + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ls-tl-start-btn.is-ready { + border-color: var(--map-accent); + color: var(--map-accent); + background: rgba(34, 211, 238, 0.08); +} + +/* Submit row — give the button its full row width */ +.ls-tl-submit-row { + margin-top: 0.3rem; +} diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 2319d9b2..67f8ca5d 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -113,6 +113,8 @@ const DevicesManager = (function () { let _tlEmbryos, _tlMode; let _tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr, _tlLaser; let _tlStart, _tlStatus, _tlStatusText; + // Accordion active-state per section: { sched, targets, geom } + let _tlTouched = { sched: false, targets: false, geom: false }; // Room-light toggle (header). Drives the SwitchBot Bot that switches the // diSPIM room light. State is the bot's cached on/off; hidden until the @@ -1486,12 +1488,71 @@ const DevicesManager = (function () { // Timelapse config form (Manual view) // ===================================================================== - /** Wire the timelapse panel's collapsible toggle and stop-condition selector. - * Safe to call multiple times (idempotent via re-assignment of event handlers). */ + // ── Accordion section summary builders ─────────────────────────────────── + + function _tlSchedSummary() { + const interval = (_tlInterval && _tlInterval.value) ? _tlInterval.value : '120'; + const stop = (_tlStop && _tlStop.value) ? _tlStop.value : 'manual'; + const condVal = (_tlCondVal && _tlCondVal.value) ? _tlCondVal.value : '10'; + if (stop === 'timepoints') return `${interval} s · ${condVal} frames`; + if (stop === 'duration') return `${interval} s · ${condVal} h`; + return `${interval} s · manual`; + } + + function _tlTargetsSummary() { + const embryos = (_tlEmbryos && _tlEmbryos.value.trim()) + ? _tlEmbryos.value.trim() + : 'all'; + const modeEl = _tlMode; + const modeText = (modeEl && modeEl.value) + ? modeEl.options[modeEl.selectedIndex].text + : 'none'; + return `${embryos} · ${modeText}`; + } + + function _tlGeomSummary() { + const slices = (_tlSlices && _tlSlices.value) ? _tlSlices.value : '50'; + const exposure = (_tlExposure && _tlExposure.value) ? _tlExposure.value : '10'; + const laser = (_tlLaser && _tlLaser.value) ? _tlLaser.value : 'ALL OFF'; + return `${slices} sl · ${exposure} ms · ${laser}`; + } + + /** Update a section's header active state and summary text, then sync the + * outer panel dot and the start button. sec = 'sched'|'targets'|'geom'. */ + function _tlUpdateSection(sec) { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const summary = document.getElementById(`devices-tlacc-${sec}-sum`); + const touched = _tlTouched[sec]; + + if (head) head.classList.toggle('is-active', touched); + if (summary) { + summary.hidden = !touched; + if (touched) { + if (sec === 'sched') summary.textContent = _tlSchedSummary(); + else if (sec === 'targets') summary.textContent = _tlTargetsSummary(); + else if (sec === 'geom') summary.textContent = _tlGeomSummary(); + } + } + + // Outer panel dot + start button "ready" state + const anyActive = Object.values(_tlTouched).some(Boolean); + const outerDot = document.getElementById('devices-tl-outer-dot'); + if (outerDot) outerDot.classList.toggle('is-active', anyActive); + if (_tlStart) _tlStart.classList.toggle('is-ready', anyActive); + } + + /** Wire the timelapse panel: outer collapsible toggle, accordion section + * toggles, touch listeners, and the submit button. + * Safe to call multiple times (re-assigns handlers idempotently). */ function initTlForm() { cacheDom(); - // Collapsible head + // Reset touched state on each init (re-entering the manual view = fresh) + _tlTouched = { sched: false, targets: false, geom: false }; + // Clear any leftover active-state visuals from a previous visit + ['sched', 'targets', 'geom'].forEach(sec => _tlUpdateSection(sec)); + + // ── Outer collapsible toggle ────────────────────────────────────────── if (_tlToggle && _tlBody) { _tlToggle.onclick = () => { const open = _tlBody.hidden; @@ -1502,22 +1563,52 @@ const DevicesManager = (function () { }; } - // Conditional stop-value row: show for timepoints / duration, hide for manual - if (_tlStop && _tlCondRow) { - _tlStop.onchange = () => { + // ── Accordion section toggles ───────────────────────────────────────── + ['sched', 'targets', 'geom'].forEach(sec => { + const head = document.getElementById(`devices-tlacc-${sec}-head`); + const body = document.getElementById(`devices-tlacc-${sec}-body`); + if (!head || !body) return; + head.onclick = () => { + const open = body.hidden; + body.hidden = !open; + head.setAttribute('aria-expanded', String(open)); + const arrow = head.querySelector('.ls-acc-arrow'); + if (arrow) arrow.textContent = open ? '▼' : '▶'; + }; + }); + + // ── Touch listeners ─────────────────────────────────────────────────── + const markTouched = sec => { + _tlTouched[sec] = true; + _tlUpdateSection(sec); + }; + + // Schedule — interval and stop condition drive summary; cond-row visibility unchanged + [_tlInterval, _tlCondVal].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('sched')); + }); + if (_tlStop) { + _tlStop.addEventListener('change', () => { const v = _tlStop.value; const show = v === 'timepoints' || v === 'duration'; - _tlCondRow.hidden = !show; - if (_tlCondLabel) { - _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; - } - }; + if (_tlCondRow) _tlCondRow.hidden = !show; + if (_tlCondLabel) _tlCondLabel.textContent = v === 'duration' ? 'Hours' : 'Count'; + markTouched('sched'); + }); } - // Submit button - if (_tlStart) { - _tlStart.onclick = startTimelapse; - } + // Targets + if (_tlEmbryos) _tlEmbryos.addEventListener('input', () => markTouched('targets')); + if (_tlMode) _tlMode.addEventListener('change', () => markTouched('targets')); + + // Volume geometry + [_tlSlices, _tlExposure, _tlGalvoAmp, _tlGalvoCtr, _tlPiezoAmp, _tlPiezoCtr].forEach(el => { + if (el) el.addEventListener('input', () => markTouched('geom')); + }); + if (_tlLaser) _tlLaser.addEventListener('change', () => markTouched('geom')); + + // ── Submit ──────────────────────────────────────────────────────────── + if (_tlStart) _tlStart.onclick = startTimelapse; } /** Populate timelapse volume-geometry defaults from GET /api/devices/scan_geometry, diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 55090248..b069c156 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -849,127 +849,148 @@

Properties

- +
- -
- + +
+ +
+ + +
+
+
+

+ + +
From 3887f979b77334d90dea16c1dc597c5f3685711b Mon Sep 17 00:00:00 2001 From: Kesavan Date: Mon, 29 Jun 2026 13:12:51 -0400 Subject: [PATCH 266/314] refactor(manual-view): remove dead CAM LED button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CAM LED toggle posted use_led to the bottom camera, but camera.py's trigger() intentionally ignores that flag ("the bottom camera NEVER drives the LED — imaging is done under room light only"). The control was a no-op on the rig and crowded the Illumination row. Remove the button and its now-unused JS wiring (lookup, listener, the toggleCamLedMode handler, and the _lsCamLed/_lsCamLedOn state). LED and Room Light now share the top illumination row with more room. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/js/devices.js | 20 +------------------- gently/ui/web/templates/index.html | 2 -- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index a5dc270d..5467f39d 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -104,9 +104,8 @@ const DevicesManager = (function () { // Lightsheet control inputs (rail) let _lsGalvoSlider, _lsGalvoNum, _lsPiezoSlider, _lsPiezoNum, _lsExposureNum; - let _lsLedToggle, _lsCamLed, _lsRoomLightBtn; + let _lsLedToggle, _lsRoomLightBtn; let _lsLedIsOpen = false; // LED toggle state: false = Closed (safe default) - let _lsCamLedOn = false; let _lsLaserToggle; let _lsLaserOn = false; // Laser toggle state: false = OFF (entry-safe default) let _lsSnapVolBtn, _lsBurstBtn, _lsLastcap, _lsLastcapRef; @@ -216,7 +215,6 @@ const DevicesManager = (function () { _lsPiezoNum = document.getElementById('devices-ls-piezo'); _lsExposureNum = document.getElementById('devices-ls-exposure'); _lsLedToggle = document.getElementById('devices-ls-led-toggle'); - _lsCamLed = document.getElementById('devices-ls-cam-led'); _lsRoomLightBtn = document.getElementById('devices-ls-room-light-btn'); _lsLaserToggle = document.getElementById('devices-ls-laser-toggle'); _lsSnapVolBtn = document.getElementById('devices-ls-snap-volume'); @@ -2044,21 +2042,6 @@ const DevicesManager = (function () { } } - 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) { @@ -2165,7 +2148,6 @@ const DevicesManager = (function () { // Illumination if (_lsLedToggle) _lsLedToggle.addEventListener('click', toggleLedPreset); - if (_lsCamLed) _lsCamLed.addEventListener('click', toggleCamLedMode); if (_lsRoomLightBtn) _lsRoomLightBtn.addEventListener('click', toggleManualRoomLight); if (_lsLaserToggle) _lsLaserToggle.addEventListener('click', toggleLaser); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 5b669a68..da7f500c 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -801,8 +801,6 @@

Properties

- From eaa1d507b6326993a0561d7f6a9914e841e2e7cb Mon Sep 17 00:00:00 2001 From: Kesavan Date: Mon, 29 Jun 2026 13:19:10 -0400 Subject: [PATCH 267/314] fix(web): stop acquire routes returning raw numpy arrays (500 on Snap/Burst) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snap Volume / Burst in the Manual view hit /api/devices/acquire/volume|burst, which returned client.acquire_*()'s dict verbatim. That dict carries the pixel data under "volume" as a numpy array (internal callers like the timelapse orchestrator need it), and FastAPI's jsonable_encoder can't serialize an ndarray — it falls back to dict(arr) and raises "dictionary update sequence element #0 has length 512; 2 is required", returning a 500. Add _json_safe() in the data routes and apply it to both acquire responses: raw arrays become a small {shape, dtype} hint, numpy scalars coerce to native types, and it recurses so the burst "frames" list is covered. The web UI only reads volume_path/metadata (never the pixels), so nothing downstream changes. The client return shape is untouched, so non-web callers keep their arrays. Verified: volume + burst sample results now json.dumps cleanly with volume_path preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/data.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 075117be..5281e290 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -16,6 +16,30 @@ _HARDWARE_CONFIG_PATH = Path(__file__).resolve().parents[4] / "config" / "hardware.yaml" +def _json_safe(obj): + """Make an acquisition result JSON-encodable for FastAPI. + + ``client.acquire_volume``/``acquire_burst`` return the pixel data under + ``volume``/``image`` as numpy arrays (internal callers like the timelapse + orchestrator need them). FastAPI's ``jsonable_encoder`` can't serialize a + raw ndarray — it tries ``dict(arr)`` and blows up — and the web UI only + needs paths + metadata anyway. Replace arrays with a small shape/dtype + hint and coerce numpy scalars to native types; recurse so the burst + ``frames`` list is covered too. + """ + import numpy as np + + if isinstance(obj, np.ndarray): + return {"shape": list(obj.shape), "dtype": str(obj.dtype)} + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, dict): + return {k: _json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_safe(v) for v in obj] + return obj + + def create_router(server) -> APIRouter: router = APIRouter() @@ -633,13 +657,14 @@ async def acquire_burst(payload: dict = Body(...)): # noqa: B008 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( + result = 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, ) + return _json_safe(result) except Exception as exc: logger.exception("Burst acquisition failed") raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc @@ -665,11 +690,12 @@ async def acquire_volume(payload: dict = Body(...)): # noqa: B008 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( + result = await client.acquire_volume( num_slices=int(payload.get("num_slices", 50)), exposure_ms=float(payload.get("exposure_ms", 10.0)), **kw, ) + return _json_safe(result) except Exception as exc: logger.exception("Volume acquisition failed") raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc From 60b948e188841dda3c08a40e910060068ec0d57b Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 17:54:15 +0530 Subject: [PATCH 268/314] feat(devices): Map-view embryo detect + single-source-of-truth list + per-embryo Center + bottom-cam enlarge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/devices/detect_embryos (bottom-cam SAM) — interim auto-register - Device-tab Embryos card renders the canonical experiment.embryos list (EMBRYOS_UPDATE); per-row Center calls /api/devices/stage/move; per-row remove - Bottom-camera enlarge toggle (height-capped, fits the map) - Right-side rail stacks XY readout + Embryos card (no overlap) Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/routes/data.py | 84 ++++++++++++ gently/ui/web/static/css/main.css | 202 +++++++++++++++++++++++++++++ gently/ui/web/static/js/devices.js | 197 +++++++++++++++++++++++++++- gently/ui/web/templates/index.html | 30 ++++- 4 files changed, 509 insertions(+), 4 deletions(-) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 075117be..e21a3422 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -608,6 +608,90 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 logger.exception("Stage move command failed") raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + @router.post("/api/devices/detect_embryos", dependencies=[Depends(require_control)]) + async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 + """Run bottom-camera embryo detection and register the hits into the + canonical experiment embryo list — the single source of truth. + + Registration goes through ExperimentState.add_embryo(), which fires + EMBRYOS_UPDATE (and EMBRYO_DETECTED), so the device-tab Map view and + every other embryo consumer refresh automatically — there is no second + list. Raw SAM hits are added as role 'unassigned'; the operator prunes + false positives from the same list (DELETE /api/embryos/{id}). Each + Detect run appends fresh hits with new ids. + + Body (all optional): {exposure_ms, min_confidence, brightness_percentile, + min_area, max_area, use_claude_review}. Claude review defaults OFF so the + manual flow is fast and needs no API key; brightness+SAM detections + already carry stage coordinates. + + Returns: {success, detected, registered: [embryo_id, ...]}. + """ + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + if not getattr(client, "has_sam", False): + raise HTTPException( + status_code=503, detail="SAM detection not available on device layer" + ) + agent = _require_agent_with_experiment() + + kw: dict = {"use_claude_review": bool(payload.get("use_claude_review", False))} + for key, cast in ( + ("exposure_ms", float), + ("min_confidence", float), + ("brightness_percentile", float), + ("min_area", int), + ("max_area", int), + ): + if payload.get(key) is not None: + kw[key] = cast(payload[key]) + try: + result = await client.detect_embryos(**kw) + except Exception as exc: + logger.exception("Embryo detection failed") + raise HTTPException(status_code=502, detail=f"detection failed: {exc}") from exc + + if not result.get("success"): + detail = result.get("error", "detection failed") + raise HTTPException(status_code=502, detail=str(detail)) + + import uuid + + def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + + def _next_embryo_id() -> str: + n = 1 + while f"embryo_{n}" in agent.experiment.embryos: + n += 1 + return f"embryo_{n}" + + detections = result.get("embryos", []) or [] + registered: list[str] = [] + for emb in detections: + sx, sy = _num(emb.get("stage_x_um")), _num(emb.get("stage_y_um")) + if sx is None or sy is None: + continue + emb_id = _next_embryo_id() + agent.experiment.add_embryo( + embryo_id=emb_id, + position={"x": sx, "y": sy}, + confidence=_num(emb.get("confidence")) or 0.0, + uid=str(uuid.uuid4()), + role="unassigned", + ) + registered.append(emb_id) + + return { + "success": True, + "detected": len(detections), + "registered": registered, + } + # ------------------------------------------------------------------ # Acquisition # ------------------------------------------------------------------ diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index d7b51a37..f4fcdccc 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -10219,6 +10219,208 @@ body.modal-open { color: var(--map-paper); border-color: var(--map-accent); } + +/* ---- Top-right rail: XY readout stacked above the detect panel ---- */ +.devices-map-rail { + position: absolute; + top: 0.85rem; + right: 0.85rem; + width: 230px; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 4; +} +/* Readout drops its own absolute positioning inside the rail and fills it. */ +.devices-map-rail .devices-map-readout { + position: static; + top: auto; + right: auto; + width: 100%; + min-width: 0; +} + +/* ---- Detected-embryo list panel (Map view, standalone detect→centre) ---- */ +.devices-detect-panel { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.5rem 0.6rem 0.55rem; + background: var(--map-overlay-bg-2); + border: 1px solid var(--map-overlay-edge); + border-radius: 3px; + box-shadow: 0 12px 32px rgba(0,0,0,0.32); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + font-family: 'Inter Tight', system-ui, sans-serif; +} +.devices-detect-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.devices-detect-title { + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--map-ink); +} +.devices-detect-btn { + 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.08em; + text-transform: uppercase; + padding: 0.18rem 0.6rem; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.devices-detect-btn:hover:not(:disabled) { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} +.devices-detect-btn:disabled { opacity: 0.5; cursor: progress; } +.devices-detect-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-height: 220px; + overflow-y: auto; +} +.devices-detect-empty { + font-size: 0.66rem; + color: var(--map-ink-mute); + padding: 0.35rem 0.1rem; + line-height: 1.35; +} +.devices-detect-row { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.28rem 0.3rem; + border-radius: 3px; + transition: background 0.12s; +} +.devices-detect-row:hover { background: rgba(255,255,255,0.05); } +.devices-detect-dot { + flex: 0 0 auto; + width: 18px; height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + font-weight: 700; + color: var(--map-paper); + background: var(--map-warm); +} +.devices-detect-coord { + flex: 1 1 auto; + font-size: 0.64rem; + font-variant-numeric: tabular-nums; + color: var(--map-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.devices-detect-center { + flex: 0 0 auto; + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.16rem 0.45rem; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.devices-detect-center:hover:not(:disabled) { + background: var(--map-accent); + border-color: var(--map-accent); + color: var(--map-paper); +} +.devices-detect-center:disabled { opacity: 0.5; cursor: progress; } +/* Per-row remove (prune false positives from the canonical list) */ +.devices-detect-remove { + flex: 0 0 auto; + background: transparent; + border: none; + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.7rem; + line-height: 1; + padding: 0.18rem 0.25rem; + border-radius: 3px; + cursor: pointer; + transition: color 0.15s, background 0.15s; +} +.devices-detect-remove:hover:not(:disabled) { + color: var(--map-warm); + background: rgba(255,255,255,0.06); +} +.devices-detect-remove:disabled { opacity: 0.5; cursor: progress; } +.devices-detect-foot { + font-size: 0.62rem; + color: var(--map-ink-mute); + padding-top: 0.1rem; +} + +.devices-camera-head-btns { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.devices-camera-expand { + background: transparent; + border: 1px solid var(--map-overlay-edge); + color: var(--map-ink); + font-family: inherit; + font-size: 0.72rem; + line-height: 1; + padding: 0.16rem 0.4rem; + border-radius: 999px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.devices-camera-expand:hover { + background: rgba(255,255,255,0.06); + border-color: var(--map-accent); + color: var(--map-accent); +} +.devices-camera-expand.active { + background: var(--map-accent); + color: var(--map-paper); + border-color: var(--map-accent); +} +/* Enlarged bottom-camera view — centred over the map, toggled by the + expand button. Reuses the same streaming ; sized by height so the + whole panel (header + stage + foot) stays within the map area. */ +.devices-camera-panel.expanded { + bottom: auto; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: auto; + max-width: calc(100% - 2rem); + z-index: 9; +} +.devices-camera-panel.expanded .devices-camera-stage { + height: min(64vh, 640px); + width: auto; + max-width: 100%; +} .devices-camera-stage { position: relative; width: 100%; diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 8c1c8733..905a7dc3 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -52,8 +52,16 @@ const DevicesManager = (function () { // Backspace removes it (with a confirm), Escape clears the selection. let _selectedEmbryoId = null; + // Bottom-camera detection (Map view). Detection registers hits into the + // canonical _embryos list (server-side, via the experiment) — there is no + // separate detected list. The Embryos card renders _embryos and centres the + // stage on each via /api/devices/stage/move. _detecting only gates the + // Detect button while a run is in flight. + let _detecting = false; + let _detectBtn, _detectList; + // Bottom-camera panel DOM + state - let _camPanel, _camToggle, _camImg, _camPlaceholder, _camLed, _camMeta; + let _camPanel, _camToggle, _camExpand, _camImg, _camPlaceholder, _camLed, _camMeta; let _camStage, _camCrosshair, _camCrosshairGroup; let _camStreaming = false; let _camLastFrameTs = 0; @@ -176,6 +184,8 @@ const DevicesManager = (function () { _mapOrigin = document.getElementById('devices-map-origin'); _mapAxes = document.getElementById('devices-map-axes'); _mapEmbryos = document.getElementById('devices-map-embryos'); + _detectBtn = document.getElementById('devices-detect-btn'); + _detectList = document.getElementById('devices-detect-list'); _mapMarker = document.getElementById('devices-map-marker'); _mapMarkerPulse = document.getElementById('devices-map-marker-pulse'); _mapMarkerRing = document.getElementById('devices-map-marker-ring'); @@ -187,6 +197,7 @@ const DevicesManager = (function () { _camPanel = document.getElementById('devices-camera-panel'); _camToggle = document.getElementById('devices-camera-toggle'); + _camExpand = document.getElementById('devices-camera-expand'); _camImg = document.getElementById('devices-camera-img'); _camPlaceholder = document.getElementById('devices-camera-placeholder'); _camStage = _camPanel ? _camPanel.querySelector('.devices-camera-stage') : null; @@ -416,6 +427,7 @@ const DevicesManager = (function () { } else { renderEmbryos(); } + renderEmbryoListPanel(); } // ===================================================================== @@ -646,7 +658,7 @@ const DevicesManager = (function () { } function handleEmbryoDetected(payload) { - if (_upsertEmbryo(payload)) renderEmbryos(); + if (_upsertEmbryo(payload)) { renderEmbryos(); renderEmbryoListPanel(); } } function handleStatusChanged(payload) { @@ -659,6 +671,7 @@ const DevicesManager = (function () { if (idx >= 0) { _embryos[idx] = Object.assign({}, _embryos[idx], { role: newRole }); renderEmbryos(); + renderEmbryoListPanel(); } else { // Embryo we haven't seen yet — refetch to be safe. loadEmbryos(); @@ -1364,9 +1377,21 @@ const DevicesManager = (function () { } } + function toggleCameraExpand() { + if (!_camPanel) return; + const expanded = _camPanel.classList.toggle('expanded'); + if (_camExpand) { + _camExpand.classList.toggle('active', expanded); + _camExpand.setAttribute('aria-pressed', expanded ? 'true' : 'false'); + _camExpand.title = expanded ? 'Shrink view' : 'Enlarge view'; + _camExpand.textContent = expanded ? '⤡' : '⤢'; + } + } + function setupCameraWiring() { if (!_camToggle) return; _camToggle.addEventListener('click', toggleCameraStream); + if (_camExpand) _camExpand.addEventListener('click', toggleCameraExpand); applyCameraState(false); if (typeof ClientEventBus !== 'undefined') { ClientEventBus.on('BOTTOM_CAMERA_FRAME', handleCameraFrame); @@ -2420,11 +2445,179 @@ const DevicesManager = (function () { scheduleStaleCheck(); } + // ===================================================================== + // Embryo detection + per-embryo stage centring (Map view) + // ===================================================================== + // Single source of truth: the canonical _embryos list (fed by + // EMBRYOS_UPDATE / /api/embryos/current). Detect registers hits server-side + // into the experiment, which broadcasts EMBRYOS_UPDATE back here — the same + // list that imported / timelapse / manually-marked embryos live in. The + // panel below renders that one list with a Centre + remove action per row. + + // Render the Embryos card list from _embryos. Called from renderEmbryos() + // so the card and the map dots always reflect the same canonical state. + function renderEmbryoListPanel() { + if (!_detectList) return; + _detectList.innerHTML = ''; + if (!_embryos || !_embryos.length) { + const empty = document.createElement('div'); + empty.className = 'devices-detect-empty'; + empty.textContent = _detecting + ? 'Detecting…' + : 'No embryos yet — press Detect'; + _detectList.appendChild(empty); + return; + } + _embryos.forEach((emb, i) => { + const xy = embryoResolvedXY(emb); + const row = document.createElement('div'); + row.className = 'devices-detect-row'; + row.setAttribute('data-embryo-id', emb.id || ''); + + const dot = document.createElement('span'); + dot.className = 'devices-detect-dot'; + dot.style.background = _ROLE_COLOR[emb.role] || _ROLE_COLOR.unassigned; + dot.textContent = embryoLabelText(emb.id, i); + row.appendChild(dot); + + const coord = document.createElement('span'); + coord.className = 'devices-detect-coord'; + coord.textContent = xy + ? `(${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}) µm` + : 'no position'; + row.appendChild(coord); + + const center = document.createElement('button'); + center.className = 'devices-detect-center'; + center.type = 'button'; + center.textContent = 'Center'; + center.disabled = !xy; + center.addEventListener('click', () => centerOnEmbryo(emb, center)); + row.appendChild(center); + + const remove = document.createElement('button'); + remove.className = 'devices-detect-remove'; + remove.type = 'button'; + remove.title = 'Remove this embryo'; + remove.setAttribute('aria-label', 'Remove embryo'); + remove.textContent = '✕'; + remove.addEventListener('click', () => removeEmbryo(emb, remove)); + row.appendChild(remove); + + _detectList.appendChild(row); + }); + } + + async function runDetection() { + if (_detecting || !_detectBtn) return; + _detecting = true; + _detectBtn.disabled = true; + _detectBtn.textContent = 'Detecting…'; + renderEmbryoListPanel(); + try { + const res = await fetch('/api/devices/detect_embryos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!res.ok) { + const detail = await res.text(); + console.error('Embryo detection failed:', detail); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Detection failed (${res.status})`); + } + return; + } + const data = await res.json(); + // The canonical list refreshes via EMBRYOS_UPDATE; just report. + if (typeof showGentlyToast === 'function') { + const n = Array.isArray(data.registered) ? data.registered.length : 0; + showGentlyToast(`Detected ${n} embryo${n === 1 ? '' : 's'}`); + } + } catch (err) { + console.error('Detection request failed:', err); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Detection error: ${err.message}`); + } + } finally { + _detecting = false; + _detectBtn.disabled = false; + _detectBtn.textContent = 'Detect'; + renderEmbryoListPanel(); + } + } + + async function centerOnEmbryo(emb, btn) { + const xy = emb ? embryoResolvedXY(emb) : null; + if (!xy) return; + const num = embryoNumberFor(emb); + if (btn) { btn.disabled = true; btn.textContent = '…'; } + try { + const res = await fetch('/api/devices/stage/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x: xy.x, y: xy.y }), + }); + if (!res.ok) { + console.error('Centre failed:', await res.text()); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Centre failed (${res.status})`); + } + return; + } + if (typeof showGentlyToast === 'function') { + showGentlyToast( + `Centred on embryo ${num} — (${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}) µm` + ); + } + } catch (err) { + console.error('Centre request failed:', err); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Centre error: ${err.message}`); + } + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Center'; } + } + } + + async function removeEmbryo(emb, btn) { + if (!emb || !emb.id) return; + const num = embryoNumberFor(emb); + if (!window.confirm(`Remove embryo ${num}?`)) return; + if (btn) btn.disabled = true; + try { + const res = await fetch(`/api/embryos/${encodeURIComponent(emb.id)}`, { + method: 'DELETE', + }); + if (!res.ok) { + console.error('Remove failed:', await res.text()); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Remove failed (${res.status})`); + } + if (btn) btn.disabled = false; + return; + } + // EMBRYOS_UPDATE refreshes the list + map dots. + } catch (err) { + console.error('Remove request failed:', err); + if (typeof showGentlyToast === 'function') { + showGentlyToast(`Remove error: ${err.message}`); + } + if (btn) btn.disabled = false; + } + } + + function setupDetectWiring() { + if (_detectBtn) _detectBtn.addEventListener('click', runDetection); + renderEmbryoListPanel(); + } + function init() { cacheDom(); setupViewSwitcher(); setupCameraWiring(); setupManualWiring(); + setupDetectWiring(); setupRoomLight(); setupTemperature(); loadCoverslip(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 109d3be8..62c79e24 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -533,7 +533,10 @@

Device - + +
@@ -552,6 +555,24 @@

Device

+ +
+
+ Embryos + +
+
+
+ No embryos yet — press Detect +
+
+ +
+
+
@@ -575,7 +596,12 @@

Device Bottom camera - +
+ + +

bottom camera live frame From 6ffe1f96a852322e346727790c0f0079ebd9d867 Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 17:54:15 +0530 Subject: [PATCH 269/314] =?UTF-8?q?docs(spec):=20bottom-cam=E2=86=92SPIM?= =?UTF-8?q?=20Operator=20surface=20design=20(sub-project=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-30-bottom-cam-operator-surface-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md diff --git a/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md b/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md new file mode 100644 index 00000000..4228e84b --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-bottom-cam-operator-surface-design.md @@ -0,0 +1,123 @@ +# Bottom-cam → SPIM Operator Surface ("Operate" view) — Design + +Date: 2026-06-30 +Status: Approved (proceed to implementation) +Branch: feature/temperature-operations-all + +## Purpose + +A single, professional, guided operator surface for the manual bottom-camera → +SPIM acquisition workflow, replacing the scattered current UX (bottom cam on the +Map view, SPIM controls on the Manual view, embryo marking on the Embryos page). +Mirrors how the operator physically works the rig: + +1. Focus the bottom objective on the embryos. +2. Mark **all** embryos in one pass (a single FOV holds them). +3. Per embryo: center → lower the SPIM head → focus the SPIM (LED on) → acquire. + +This is sub-project **A** (the spine). Two data flywheels hang off it and are +specified separately: **B** marking→localization labels (retire SAM), **C** +manual-focus→autofocus-validation. A exposes the hook points (confirm, focus +score) they will tap, but does not implement them. + +## Settled decisions + +- **Home:** a new `Operate` view in the device tab, alongside Map/Details/3D/Manual. + One purpose per view: Map stays a passive spatial monitor; Manual stays raw + knobs. The Detect/Center/enlarge controls already added to Map migrate to Operate; + Map keeps only read-only embryo dots. +- **Focus control:** software **nudge** buttons (± fixed steps), hard-fenced to + the axis limits (F-drive floor 30 µm). **No autofocus** (objective-crash risk). + Live focus-score readout to assist. +- **Marking:** batch — mark all embryos on one frozen full-res frame. **Positions + only, no roles.** Roles are a separate, later, optional concern. +- **SPIM focus step:** inline in Operate (lightsheet live + galvo/piezo/LED nudges), + not a handoff to Manual. +- **Single source of truth:** the canonical `experiment.embryos` list (already wired + via EMBRYOS_UPDATE / /api/embryos/current). Detect feeds it through a + human-confirm step, not a side list. +- **UI quality:** treat as a design pass (frontend-design), not a port of the + amateur marking canvas. + +## Architecture + +New device-tab view `operate` (devices.js view list becomes +`['operate','map','details','optical3d','manual']`), three vertical zones: + +1. **Survey** — enlarged bottom-cam live; bottom-Z focus nudge (fenced) + live + focus score; Detect (SAM → candidates) or click-to-mark on a frozen frame; + Confirm. +2. **Embryos** — the one canonical list, each row with a state chip and select. +3. **Acquire** (selected embryo) — Center → Lower SPIM head (F-drive, fenced) → + inline lightsheet live + galvo/piezo/LED nudges + focus score → Acquire volume. + +Per-embryo state machine (client-side, keyed by embryo id; persistence deferred): + +``` +marked ──Center──▶ centered ──Lower SPIM + focus──▶ focused ──Acquire──▶ imaged +``` + +### Component reuse + +| Need | Reuse | New | +|---|---|---| +| Bottom-cam live + enlarge | camera panel (built) | move into Operate | +| Mark-all on frozen frame | MarkingManager interaction logic | re-homed canvas, redesigned, positions-only | +| Embryo list + Center | SSOT list + stage/move (built) | per-embryo state chips | +| SPIM focus | Manual galvo/piezo/LED/lightsheet-live endpoints | inline placement | +| Acquire | /api/devices/acquire/volume | — | +| Bottom-Z + F-drive nudge | DiSPIMZstage / DiSPIMFDrive device classes | device-factory wiring, polling, fenced endpoints | +| Focus score | analysis/core.calculate_focus_score | inject into camera stream payloads | +| Register marks (agent-free) | experiment.add_embryo | register-on-confirm endpoint | + +## New backend endpoints (web routes proxy → device layer) + +- `GET /api/devices/stage/bottom_z` · `POST /api/devices/stage/bottom_z/nudge {delta}` + — read + fenced nudge of the bottom-camera focus Z (DiSPIMZstage). +- `GET /api/devices/spim/fdrive` · `POST /api/devices/spim/fdrive/nudge {delta}` + — read + fenced nudge of the SPIM-head F-drive (floor 30 µm; report distance-to-floor). +- `POST /api/devices/detect_embryos` (revised) — return SAM candidates + `{embryos:[{pixel_x,pixel_y,stage_x_um,stage_y_um,confidence}], stage_position}`; + no auto-register. +- `POST /api/devices/embryos/confirm {markers:[{pixel_x,pixel_y}], stage_position, + pixel_size_um, objective_mag}` — pixel→stage, register each into experiment.embryos + (role 'unassigned'), fire EMBRYOS_UPDATE. Agent-free. +- Focus score injected into existing bottom-cam + lightsheet SSE payloads + (`focus_score` field), computed server-side on the full frame. + +Device-layer additions: instantiate DiSPIMZstage in the device factory when +present; add bottom-Z and F-drive to the slow position poller; fenced nudge +handlers (clamp/reject out-of-range). + +## Safety / error handling + +- All Z moves fenced server-side; device classes hard-enforce limits; out-of-range + → 4xx, surfaced in UI. Nudges are bounded single steps — no autonomous/repeated + moves. +- Device layer down → 503; controls disabled with clear state. +- Frozen-frame capture failure → error toast, stay in Survey. Confirm with zero + markers → disabled. +- F-drive: never below floor; show distance-to-floor. + +## Testing + +- Backend (TDD): pixel→stage in register-on-confirm (reuse coordinate tests); + fenced Z endpoints reject out-of-range; focus-score payload shape. +- Frontend: launch with the gently_perception shim; Chrome MCP drives + detect→mark→confirm→EMBRYOS_UPDATE, per-embryo state transitions, out-of-range + nudge blocked; screenshots + UI audit against the professional bar. +- Rig-only: real Z moves, real SPIM focus, SAM on a live frame. + +## Out of scope (separate sub-projects) + +- B: persist (frame + pixel markers + roles) as localization labels; benchmark + classical/trained detectors vs SAM. +- C: poll missing Z axes for passive focus-trace logging + offline validator. + (A wires the Z axes for read/move, which C extends to logging.) + +## MVP boundary + +Ship the Operate view end-to-end with maximal reuse: Survey (live + fenced bottom-Z ++ Detect/mark-all + Confirm), the SSOT list with state chips + Center, Acquire zone +(F-drive nudge + inline SPIM focus controls + Acquire). Per-embryo state client-side. +Defer label/focus-trace persistence (B/C). From 223812c7c9c7699c9cbbfa2fb5878116f90a712c Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 18:49:47 +0530 Subject: [PATCH 270/314] =?UTF-8?q?feat(operate):=20backend=20=E2=80=94=20?= =?UTF-8?q?fenced=20Z=20read/nudge,=20agent-free=20marking=20confirm,=20de?= =?UTF-8?q?tect=20candidates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device layer + client: - Register bottom-cam focus Z (DiSPIMZstage, z_stage) in device_factory; poll it + the SPIM-head F-drive in the slow position stream. - Fenced read+nudge handlers for bottom_z and fdrive (reject out-of-range; the device classes hard-enforce limits; F-drive floor is sacrosanct). - Inject a focus score (analysis.core.calculate_focus_score on the full frame) into the bottom-cam + lightsheet stream payloads. - Client: get_bottom_z/nudge_bottom_z/get_fdrive/nudge_fdrive. Web routes (data.py): - detect_embryos now RETURNS candidates (no auto-register) for the marking canvas. - POST /api/devices/embryos/confirm — agent-free register of operator-confirmed markers into experiment.embryos (single source of truth), fires EMBRYOS_UPDATE. - Proxies: stage/bottom_z[/nudge], spim/fdrive[/nudge]. RIG-NOTE: z_stage MMCore axis assumed ZStage:Z:32 + limits (50,250)µm — rig owner to confirm. Nudges use direct device.set() (mirrors camera-exposure handler). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/hardware/dispim/client.py | 22 ++++ gently/hardware/dispim/device_factory.py | 21 +++ gently/hardware/dispim/device_layer.py | 160 ++++++++++++++++++++++- gently/ui/web/routes/data.py | 158 +++++++++++++++++----- 4 files changed, 326 insertions(+), 35 deletions(-) diff --git a/gently/hardware/dispim/client.py b/gently/hardware/dispim/client.py index b4b38557..b4461436 100644 --- a/gently/hardware/dispim/client.py +++ b/gently/hardware/dispim/client.py @@ -1050,6 +1050,28 @@ async def get_bottom_camera_exposure(self) -> dict: """Get current bottom camera exposure time.""" return await self._api_get("/api/camera/exposure") + # ------------------------------------------------------------------ + # Fenced focus axes — bottom-camera focus Z and SPIM-head F-drive. + # Read + relative nudge only (no autofocus). Out-of-range nudges come + # back as a non-success dict (the device layer returns 400). + # ------------------------------------------------------------------ + + async def get_bottom_z(self) -> dict: + """Current bottom-camera focus Z position + limits.""" + return await self._api_get("/api/stage/bottom_z") + + async def nudge_bottom_z(self, delta: float) -> dict: + """Fenced relative move of the bottom-camera focus Z by ``delta`` µm.""" + return await self._api_post("/api/stage/bottom_z/nudge", {"delta": float(delta)}) + + async def get_fdrive(self) -> dict: + """Current SPIM-head F-drive position + limits + distance to floor.""" + return await self._api_get("/api/spim/fdrive") + + async def nudge_fdrive(self, delta: float) -> dict: + """Fenced relative move of the SPIM-head F-drive by ``delta`` µm.""" + return await self._api_post("/api/spim/fdrive/nudge", {"delta": float(delta)}) + async def capture_bottom_image( self, use_led: bool = False, exposure_ms: float | None = None ) -> dict: diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index b0bfb81c..503cb491 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -71,6 +71,9 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non "scanner_b_name": "Scanner:CD:33", "piezo_b_name": "PiezoStage:Q:35", "fdrive_name": "ZStage:V:37", + # Bottom-camera focus Z (sample Z). ASI Tiger "ZStage:Z:32" — distinct + # from the SPIM-head F-drive (ZStage:V:37). Registered defensively below. + "z_stage_name": "ZStage:Z:32", "bottom_camera_name": "Bottom PCO", "led_name": "LED:X:31", } @@ -195,6 +198,24 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, config: dict | None = Non except Exception as e: logger.warning("Could not create F-drive (SPIM head): %s", e) + # Bottom-camera focus Z (sample Z). Registered defensively: rigs that don't + # expose the axis under z_stage_name simply skip it (focus stays manual). + try: + from .devices import DiSPIMZstage + + z_stage_name = cfg.get("z_stage_name", "ZStage:Z:32") + loaded_devices = list(core.getLoadedDevices()) + if z_stage_name in loaded_devices: + devices["z_stage"] = DiSPIMZstage(name=z_stage_name, core=core) + logger.info("Created bottom-camera focus Z (z_stage): %s", z_stage_name) + else: + logger.warning( + "z_stage (%s) not in loaded devices — bottom-cam focus Z absent; skipping", + z_stage_name, + ) + except Exception as e: + logger.warning("Could not create z_stage (bottom-cam focus Z): %s", e) + try: if cfg.get("led_name"): from .devices import DiSPIMLED diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 0042f5af..8383c486 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -621,6 +621,31 @@ def _read_slow_positions(self) -> dict[str, Any]: except Exception as exc: logger.debug("Galvo position read failed: %s", exc) + # SPIM-head F-drive — not on its own knob loop, so 1 Hz is plenty. + # Streamed so the Operate view can show head height + distance-to-floor. + fdrive = self.devices.get("fdrive") + if fdrive is not None: + try: + data = fdrive.read() + out[fdrive.name] = { + "Position": float(data[fdrive.name]["value"]), + "kind": "fdrive", + } + except Exception as exc: + logger.debug("F-drive position read failed: %s", exc) + + # Bottom-camera focus Z (sample Z) — present only on rigs that expose it. + z_stage = self.devices.get("z_stage") + if z_stage is not None: + try: + data = z_stage.read() + out[z_stage.name] = { + "Position": float(data[z_stage.name]["value"]), + "kind": "bottom_z", + } + except Exception as exc: + logger.debug("Bottom-Z position read failed: %s", exc) + return out def _read_full_state(self) -> dict[str, dict[str, str]]: @@ -894,6 +919,18 @@ def _encode_frame_for_stream( 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 + # Focus score on the FULL frame (before downsampling) so the Operate + # view's focus readout reflects true sharpness, not the JPEG thumbnail. + # Best-effort: any failure just omits the field. + focus_score: float | None = None + try: + from gently.analysis.core import calculate_focus_score + + focus_score = float(calculate_focus_score(img, algorithm="volath")) + except Exception as exc: + logger.debug("Focus score computation failed: %s", exc) + focus_score = None + h, w = img.shape[:2] # Stride slicing — no interpolation, just take every Nth pixel. factor = max(1, max(h, w) // target_max_dim) @@ -922,13 +959,16 @@ def _encode_frame_for_stream( if not ok: return None b64 = base64.b64encode(jpeg.tobytes()).decode("ascii") - return { + payload: dict[str, Any] = { "t": time.time(), "shape": [int(small.shape[0]), int(small.shape[1])], "downsample": factor, "mime": "image/jpeg", "jpeg_b64": b64, } + if focus_score is not None: + payload["focus_score"] = focus_score + return payload async def _bottom_camera_streamer(self): """Continuous grab/encode/broadcast loop. Runs while a subscriber lives. @@ -2239,6 +2279,119 @@ async def handle_get_camera_exposure(self, request): status=500, ) + # ------------------------------------------------------------------ + # Fenced focus axes — bottom-camera focus Z (z_stage) + SPIM-head F-drive. + # Read + relative nudge only; the device classes enforce hard limits, and + # the handlers reject out-of-range targets with 400. No autofocus. + # ------------------------------------------------------------------ + + def _nudge_axis_blocking(self, device, target: float) -> float: + """Blocking: move a fenced ophyd positioner to ``target`` and return the + new position. ``device.set()`` enforces the hardware limits.""" + status = device.set(target) + try: + status.wait() + except Exception: + logger.exception("Axis move to %.2f did not complete cleanly", target) + return float(device.read()[device.name]["value"]) + + def _axis_status_response(self, device_key: str, label: str): + device = self.devices.get(device_key) + if device is None: + return web.json_response( + {"success": False, "error": f"{label} device not found"}, status=503 + ) + lo, hi = device.limits + try: + pos = float(device.read()[device.name]["value"]) + except Exception as exc: + return web.json_response( + {"success": False, "error": f"position read failed: {exc}"}, status=502 + ) + return web.json_response( + { + "success": True, + "position": pos, + "min": float(lo), + "max": float(hi), + "distance_to_floor": pos - float(lo), + } + ) + + async def _handle_axis_nudge(self, request, device_key: str, label: str): + try: + data = await request.json() + delta = float(data.get("delta", 0.0)) + except Exception: + return web.json_response( + {"success": False, "error": "numeric 'delta' required"}, status=400 + ) + device = self.devices.get(device_key) + if device is None: + return web.json_response( + {"success": False, "error": f"{label} device not found"}, status=503 + ) + lo, hi = device.limits + try: + cur = float(device.read()[device.name]["value"]) + except Exception as exc: + return web.json_response( + {"success": False, "error": f"position read failed: {exc}"}, status=502 + ) + target = round(cur + delta, 2) + if not (float(lo) <= target <= float(hi)): + return web.json_response( + { + "success": False, + "error": f"target {target} outside limits [{lo}, {hi}]", + "position": cur, + "min": float(lo), + "max": float(hi), + }, + status=400, + ) + try: + async with self.pause_state_updates(): + new_pos = await asyncio.to_thread(self._nudge_axis_blocking, device, target) + except ValueError as exc: # device-level fence (defensive) + return web.json_response({"success": False, "error": str(exc)}, status=400) + except Exception as exc: + import traceback + + return web.json_response( + { + "success": False, + "error": str(exc), + "traceback": traceback.format_exc(), + }, + status=500, + ) + return web.json_response( + { + "success": True, + "position": new_pos, + "min": float(lo), + "max": float(hi), + "distance_to_floor": new_pos - float(lo), + } + ) + + async def handle_get_bottom_z(self, request): + """GET /api/stage/bottom_z — bottom-camera focus Z position + limits.""" + return self._axis_status_response("z_stage", "Bottom-Z (z_stage)") + + async def handle_nudge_bottom_z(self, request): + """POST /api/stage/bottom_z/nudge — fenced relative move of the bottom-camera focus Z.""" + return await self._handle_axis_nudge(request, "z_stage", "Bottom-Z (z_stage)") + + async def handle_get_fdrive(self, request): + """GET /api/spim/fdrive — SPIM-head F-drive position + limits + distance to floor.""" + return self._axis_status_response("fdrive", "F-drive") + + async def handle_nudge_fdrive(self, request): + """POST /api/spim/fdrive/nudge — fenced relative move of the SPIM-head F-drive.""" + return await self._handle_axis_nudge(request, "fdrive", "F-drive") + async def handle_get_plan_log(self, request): """GET /api/plan_log - Get recent plan execution log with timing""" try: @@ -3205,6 +3358,11 @@ async def on_start(self): self._app.router.add_post("/api/camera/led_mode", self.handle_set_camera_led_mode) self._app.router.add_post("/api/camera/exposure", self.handle_set_camera_exposure) self._app.router.add_get("/api/camera/exposure", self.handle_get_camera_exposure) + # Fenced focus axes (read + relative nudge; no autofocus) + self._app.router.add_get("/api/stage/bottom_z", self.handle_get_bottom_z) + self._app.router.add_post("/api/stage/bottom_z/nudge", self.handle_nudge_bottom_z) + self._app.router.add_get("/api/spim/fdrive", self.handle_get_fdrive) + self._app.router.add_post("/api/spim/fdrive/nudge", self.handle_nudge_fdrive) 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) diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index e21a3422..d9867c2a 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -608,24 +608,26 @@ async def stage_move(payload: dict = Body(...)): # noqa: B008 logger.exception("Stage move command failed") raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + def _num(v): + try: + return float(v) + except (TypeError, ValueError): + return None + @router.post("/api/devices/detect_embryos", dependencies=[Depends(require_control)]) async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 - """Run bottom-camera embryo detection and register the hits into the - canonical experiment embryo list — the single source of truth. - - Registration goes through ExperimentState.add_embryo(), which fires - EMBRYOS_UPDATE (and EMBRYO_DETECTED), so the device-tab Map view and - every other embryo consumer refresh automatically — there is no second - list. Raw SAM hits are added as role 'unassigned'; the operator prunes - false positives from the same list (DELETE /api/embryos/{id}). Each - Detect run appends fresh hits with new ids. + """Run bottom-camera embryo detection and RETURN the candidates for the + Operate-view marking canvas. Does NOT register — the operator confirms + (add/remove/relocate) on a frozen frame, then POSTs /api/devices/embryos/ + confirm. This keeps the human in the loop and the canonical embryo list + clean (a marking step, not a blind auto-register). Body (all optional): {exposure_ms, min_confidence, brightness_percentile, - min_area, max_area, use_claude_review}. Claude review defaults OFF so the - manual flow is fast and needs no API key; brightness+SAM detections - already carry stage coordinates. + min_area, max_area, use_claude_review}. Claude review defaults OFF. - Returns: {success, detected, registered: [embryo_id, ...]}. + Returns: {success, count, stage_position: [x, y] | null, + embryos: [{embryo_id, pixel_x, pixel_y, stage_x_um, stage_y_um, + confidence, area_pixels, bbox_pixel}]}. """ client = _resolve_client() if client is None: @@ -634,7 +636,6 @@ async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 raise HTTPException( status_code=503, detail="SAM detection not available on device layer" ) - agent = _require_agent_with_experiment() kw: dict = {"use_claude_review": bool(payload.get("use_claude_review", False))} for key, cast in ( @@ -653,44 +654,133 @@ async def detect_embryos(payload: dict = Body(default={})): # noqa: B008 raise HTTPException(status_code=502, detail=f"detection failed: {exc}") from exc if not result.get("success"): - detail = result.get("error", "detection failed") - raise HTTPException(status_code=502, detail=str(detail)) + raise HTTPException( + status_code=502, detail=str(result.get("error", "detection failed")) + ) - import uuid + embryos = [] + for emb in result.get("embryos", []) or []: + bbox = emb.get("bbox_pixel") + embryos.append( + { + "embryo_id": emb.get("embryo_id"), + "pixel_x": _num(emb.get("pixel_x")), + "pixel_y": _num(emb.get("pixel_y")), + "stage_x_um": _num(emb.get("stage_x_um")), + "stage_y_um": _num(emb.get("stage_y_um")), + "confidence": _num(emb.get("confidence")), + "area_pixels": emb.get("area_pixels"), + "bbox_pixel": list(bbox) if bbox is not None else None, + } + ) + pos = result.get("stage_position") + return { + "success": True, + "count": len(embryos), + "stage_position": list(pos) if pos is not None else None, + "embryos": embryos, + } - def _num(v): - try: - return float(v) - except (TypeError, ValueError): - return None + @router.post("/api/devices/embryos/confirm", dependencies=[Depends(require_control)]) + async def confirm_embryos(payload: dict = Body(...)): # noqa: B008 + """Register operator-confirmed markers into the canonical embryo list. - def _next_embryo_id() -> str: + Agent-free commit step for the Operate-view marking canvas. The client + computes each marker's stage XY from the frozen frame (SAM candidates + carry server-computed stage coords; clicked markers are converted with + the frame's downsample-aware transform), so the server just registers + via ExperimentState.add_embryo(role='unassigned'), firing EMBRYOS_UPDATE. + + Body: {markers: [{stage_x_um, stage_y_um, confidence?}]}. + Returns {success, registered: [embryo_id, ...]}. + """ + agent = _require_agent_with_experiment() + markers = payload.get("markers") or [] + + def _next_embryo_id(taken: set) -> str: n = 1 - while f"embryo_{n}" in agent.experiment.embryos: + while f"embryo_{n}" in agent.experiment.embryos or f"embryo_{n}" in taken: n += 1 return f"embryo_{n}" - detections = result.get("embryos", []) or [] + import uuid + registered: list[str] = [] - for emb in detections: - sx, sy = _num(emb.get("stage_x_um")), _num(emb.get("stage_y_um")) + taken: set = set() + for m in markers: + sx, sy = _num(m.get("stage_x_um")), _num(m.get("stage_y_um")) if sx is None or sy is None: continue - emb_id = _next_embryo_id() + emb_id = _next_embryo_id(taken) + taken.add(emb_id) agent.experiment.add_embryo( embryo_id=emb_id, position={"x": sx, "y": sy}, - confidence=_num(emb.get("confidence")) or 0.0, + confidence=_num(m.get("confidence")) or 0.0, uid=str(uuid.uuid4()), role="unassigned", ) registered.append(emb_id) - return { - "success": True, - "detected": len(detections), - "registered": registered, - } + return {"success": True, "registered": registered} + + # ------------------------------------------------------------------ + # Focus Z axes (Operate view) — fenced read + nudge + # ------------------------------------------------------------------ + + @router.get("/api/devices/stage/bottom_z") + async def get_bottom_z(): + """Bottom-camera focus Z position + limits (read-only).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_bottom_z() + except Exception as exc: + logger.debug("bottom_z read failed: %s", exc) + raise HTTPException(status_code=502, detail=f"bottom_z read failed: {exc}") from exc + + @router.post("/api/devices/stage/bottom_z/nudge", dependencies=[Depends(require_control)]) + async def nudge_bottom_z(payload: dict = Body(...)): # noqa: B008 + """Nudge the bottom-camera focus Z by {delta} µm (fenced to its limits).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + delta = _num(payload.get("delta")) + if delta is None: + raise HTTPException(status_code=400, detail="delta required") + try: + return await client.nudge_bottom_z(delta) + except Exception as exc: + logger.exception("bottom_z nudge failed") + raise HTTPException(status_code=502, detail=f"bottom_z nudge failed: {exc}") from exc + + @router.get("/api/devices/spim/fdrive") + async def get_fdrive(): + """SPIM-head F-drive position + limits + distance-to-floor (read-only).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.get_fdrive() + except Exception as exc: + logger.debug("fdrive read failed: %s", exc) + raise HTTPException(status_code=502, detail=f"fdrive read failed: {exc}") from exc + + @router.post("/api/devices/spim/fdrive/nudge", dependencies=[Depends(require_control)]) + async def nudge_fdrive(payload: dict = Body(...)): # noqa: B008 + """Nudge the SPIM-head F-drive by {delta} µm (fenced; never below floor).""" + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + delta = _num(payload.get("delta")) + if delta is None: + raise HTTPException(status_code=400, detail="delta required") + try: + return await client.nudge_fdrive(delta) + except Exception as exc: + logger.exception("fdrive nudge failed") + raise HTTPException(status_code=502, detail=f"fdrive nudge failed: {exc}") from exc # ------------------------------------------------------------------ # Acquisition From 7a95f049c46e5c2c192bb47136edddc9a18efefe Mon Sep 17 00:00:00 2001 From: P S Kesavan Date: Tue, 30 Jun 2026 18:49:47 +0530 Subject: [PATCH 271/314] =?UTF-8?q?feat(operate):=20Operate=20view=20?= =?UTF-8?q?=E2=80=94=20guided=20bottom-cam=20=E2=86=92=20SPIM=20operator?= =?UTF-8?q?=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New device-tab 'Operate' view (1st in the switcher, hotkey 'o'): a single guided flow mirroring the physical workflow, replacing the scattered detect/mark/SPIM UX. - Zone 1 Survey & mark: enlarged bottom-cam live + fenced bottom-Z focus nudge + live focus score; Detect (SAM candidates) or click-to-mark on a FROZEN frame; positions only (no forced roles); Confirm → register into the canonical list. - Zone 2 Embryos: the single source of truth (EMBRYOS_UPDATE) with per-embryo state chips (marked→centered→focused→imaged) + select. - Zone 3 Acquire: Center (stage move) → lower SPIM head (F-drive, fenced) → inline SPIM focus (lightsheet live + galvo/piezo/LED nudge + focus score) → Acquire volume. Professional, theme-aware styling (operate.css). DevicesManager activates/ deactivates OperateManager on view switch (streams only run while visible). Verified in-browser end-to-end (detect→mark→confirm→list→per-embryo cycle). Co-Authored-By: Claude Opus 4.8 (1M context) --- gently/ui/web/static/css/operate.css | 194 ++++++++++++ gently/ui/web/static/js/devices.js | 11 +- gently/ui/web/static/js/operate.js | 451 +++++++++++++++++++++++++++ gently/ui/web/templates/index.html | 97 ++++++ 4 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 gently/ui/web/static/css/operate.css create mode 100644 gently/ui/web/static/js/operate.js diff --git a/gently/ui/web/static/css/operate.css b/gently/ui/web/static/css/operate.css new file mode 100644 index 00000000..29ba5f90 --- /dev/null +++ b/gently/ui/web/static/css/operate.css @@ -0,0 +1,194 @@ +/* Operate view — guided bottom-cam → SPIM operator surface. + Theme-aware via the global tokens (--bg-card, --border, --text, --accent …). */ + +.operate { + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 0.25rem 0.1rem 1.5rem; + font-family: 'Inter Tight', system-ui, sans-serif; + color: var(--text); +} + +.op-zone { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.85rem 1rem 1rem; +} +.op-zone-head { + display: flex; + align-items: center; + gap: 0.55rem; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; + margin-bottom: 0.85rem; + color: var(--text); +} +.op-step { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; height: 22px; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-size: 0.74rem; + font-weight: 700; +} +.op-count, .op-sel { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-muted); +} + +/* survey + acquire share a camera|rail two-column body. + Fixed body height keeps all three zones on screen; the camera fills it and + the image letterboxes (object-fit: contain) so any frame aspect fits. */ +.op-survey-body, .op-acquire-body { + display: flex; + gap: 1rem; + align-items: stretch; + height: 340px; +} +.op-cam { + position: relative; + flex: 1 1 auto; + min-width: 0; + height: 100%; + background: var(--img-bg); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} +.op-cam-img { + width: 100%; height: 100%; + object-fit: contain; + display: block; + opacity: 0; + transition: opacity 0.2s; +} +.op-cam-img.has-frame { opacity: 1; } +.op-mark-canvas { + position: absolute; inset: 0; + width: 100%; height: 100%; + pointer-events: none; +} +.op-cam.op-marking { cursor: crosshair; box-shadow: inset 0 0 0 2px var(--accent-green); } +.op-cam.op-marking .op-mark-canvas { pointer-events: auto; } +.op-cam-ph { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + color: var(--text-muted); + font-size: 0.8rem; + letter-spacing: 0.04em; +} + +/* control rail */ +.op-rail { + flex: 0 0 248px; + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.op-field { display: flex; flex-direction: column; gap: 0.4rem; } +.op-label { + font-size: 0.66rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); +} +.op-readout { + font-size: 0.82rem; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.op-readout b { font-weight: 600; } +.op-sep { color: var(--text-muted); margin: 0 0.4rem; } + +/* buttons */ +.op-btn { + appearance: none; + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text); + font: 600 0.8rem/1 'Inter Tight', system-ui, sans-serif; + padding: 0.5rem 0.75rem; + border-radius: 7px; + cursor: pointer; + transition: background 0.13s, border-color 0.13s, color 0.13s, opacity 0.13s; +} +.op-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.op-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.op-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.op-btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; } +.op-btn-ghost { background: transparent; } +.op-btn-on, .op-btn-toggle[aria-pressed="true"] { background: var(--accent-green); border-color: var(--accent-green); color: #06281a; } + +.op-mark-actions, .op-spim-controls { display: flex; gap: 0.5rem; } +.op-mark-actions .op-btn, .op-spim-controls .op-btn { flex: 1; } +.op-hint { font-size: 0.72rem; color: var(--text-muted); line-height: 1.4; } + +/* nudge clusters */ +.op-nudge { display: flex; gap: 0.35rem; } +.op-nbtn { + flex: 1; + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text); + font: 600 0.74rem/1 'Inter Tight', system-ui, sans-serif; + font-variant-numeric: tabular-nums; + padding: 0.4rem 0.3rem; + border-radius: 6px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.op-nbtn:hover { border-color: var(--accent); color: var(--accent); } +.op-micro { + display: flex; align-items: center; gap: 0.4rem; + font-size: 0.74rem; color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.op-micro b { color: var(--text); min-width: 2.2em; text-align: center; } +.op-micro .op-nbtn { flex: 0 0 auto; width: 26px; } + +/* embryo list */ +.op-embryo-list { display: flex; flex-direction: column; gap: 0.3rem; max-height: 220px; overflow-y: auto; } +.op-empty { color: var(--text-muted); font-size: 0.82rem; padding: 0.4rem 0.1rem; } +.op-erow { + display: flex; align-items: center; gap: 0.6rem; + padding: 0.45rem 0.55rem; + border: 1px solid transparent; + border-radius: 7px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.op-erow:hover { background: var(--bg-hover); } +.op-erow-sel { background: var(--bg-hover); border-color: var(--accent); } +.op-edot { + flex: 0 0 auto; + width: 22px; height: 22px; + border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + color: #fff; font-size: 0.7rem; font-weight: 700; +} +.op-ecoord { flex: 1 1 auto; font-size: 0.8rem; font-variant-numeric: tabular-nums; color: var(--text); } +.op-echip { + flex: 0 0 auto; + font-size: 0.64rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.06em; + padding: 0.16rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-muted); +} +.op-echip-centered { color: var(--accent); border-color: var(--accent); } +.op-echip-focused { color: var(--accent-orange); border-color: var(--accent-orange); } +.op-echip-imaged { color: var(--accent-green); border-color: var(--accent-green); } + +@media (max-width: 920px) { + .op-survey-body, .op-acquire-body { flex-direction: column; } + .op-rail { flex-basis: auto; } +} diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 905a7dc3..e7cb9b1b 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', 'manual']; + const VIEWS = ['operate', 'map', 'details', 'optical3d', 'manual']; const SVG_NS = 'http://www.w3.org/2000/svg'; // Status / details DOM @@ -2403,6 +2403,12 @@ const DevicesManager = (function () { // populateLaserPresets() runs after setLaserOff() so the select is always // seeded with the entry-safety state first. if (viewName === 'manual') { setLaserOff(); populateLaserPresets(); populateCameraRoles(); initTlForm(); populateTlDefaults(); } + // The Operate view owns its own module; activate/deactivate it so its + // camera + SPIM streams only run while it's the visible view. + if (typeof OperateManager !== 'undefined') { + if (viewName === 'operate') OperateManager.activate(); + else OperateManager.deactivate(); + } } function setupViewSwitcher() { @@ -2412,7 +2418,8 @@ const DevicesManager = (function () { document.addEventListener('keydown', (e) => { if (typeof state !== 'undefined' && typeof TABS !== 'undefined' && state.tab !== TABS.DEVICES) return; if (e.target.matches('input, textarea, select, [contenteditable]')) return; - if (e.key === 'm') { e.preventDefault(); switchView('map'); } + if (e.key === 'o') { e.preventDefault(); switchView('operate'); } + else if (e.key === 'm') { e.preventDefault(); switchView('map'); } else if (e.key === 'd') { e.preventDefault(); switchView('details'); } else if (e.key === 'v') { e.preventDefault(); switchView('optical3d'); } }); diff --git a/gently/ui/web/static/js/operate.js b/gently/ui/web/static/js/operate.js new file mode 100644 index 00000000..9a7071c1 --- /dev/null +++ b/gently/ui/web/static/js/operate.js @@ -0,0 +1,451 @@ +/** + * Operate view — guided bottom-cam → SPIM operator surface. + * + * Three zones, mirroring the physical workflow: + * 1. Survey & mark — live bottom cam, fenced bottom-Z focus nudge, Detect / + * click-to-mark all embryos on a frozen frame, Confirm. + * 2. Embryos — the single canonical experiment.embryos list (EMBRYOS_UPDATE), + * each row a per-embryo state chip + select. + * 3. Acquire — for the selected embryo: Center → lower SPIM head (F-drive, + * fenced) → focus SPIM (live + galvo/piezo/LED nudge) → Acquire. + * + * Single source of truth: marking confirms into experiment.embryos via the + * agent-free /api/devices/embryos/confirm endpoint; the list refreshes from the + * canonical EMBRYOS_UPDATE event. No autonomous Z moves — every focus move is a + * bounded, server-fenced nudge. + */ +const OperateManager = (function () { + // Camera transform constants (match gently/core/coordinates.py defaults). + const BASE_UM_PER_PX = 6.5 / 10.0; // pixel_size_um / objective_mag + const MARK_HIT_PX = 14; // click-to-remove radius (canvas px) + + let _wired = false; + let _active = false; + + // DOM + let _camImg, _camPh, _camStage, _markCanvas, _camToggle; + let _bzPos, _bzScore, _bzNudge; + let _detectBtn, _confirmBtn, _clearBtn, _markCount, _markHint; + let _embryoList, _embryoCount; + let _selLabel, _spimImg, _spimPh, _spimToggle, _ledBtn; + let _fdPos, _fdFloor, _fdNudge, _centerBtn, _acquireBtn; + let _gvVal, _pzVal, _spimScore; + + // State + let _camOn = false, _spimOn = false; + let _lastFrame = null; // last live bottom-cam payload {jpeg_b64, shape, downsample, focus_score} + let _marking = false; + let _frozenSrc = null; // frozen image data URL + let _frozenFrame = null; // {w, h, downsample} of the frozen frame (frame-space) + let _captureStage = [0, 0]; // stage XY at freeze time (µm) + let _markers = []; // [{fx, fy, stageX, stageY, source}] fx/fy in frame-space px + let _embryos = []; // canonical list (EMBRYOS_UPDATE shape) + let _selected = null; // selected embryo id + const _states = {}; // embryo_id -> 'marked'|'centered'|'focused'|'imaged' + let _lastXY = null; // {X, Y} from DEVICE_STATE_UPDATE + let _galvo = 0.0, _piezo = 50.0, _ledOn = false; + + const _ROLE_COLOR = { test: '#ff66cc', calibration: '#00cccc', unassigned: '#8a8f98' }; + const _STATE_LABEL = { marked: 'marked', centered: 'centered', focused: 'focused', imaged: 'imaged' }; + + function $(id) { return document.getElementById(id); } + function toast(msg) { if (typeof showGentlyToast === 'function') showGentlyToast(msg); } + + async function postJSON(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + const err = new Error(`${res.status} ${detail}`); + err.status = res.status; + throw err; + } + return res.json().catch(() => ({})); + } + async function getJSON(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + return res.json(); + } + + function cacheDom() { + _camImg = $('op-cam-img'); _camPh = $('op-cam-ph'); _camStage = $('op-cam-stage'); + _markCanvas = $('op-mark-canvas'); _camToggle = $('op-cam-toggle'); + _bzPos = $('op-bz-pos'); _bzScore = $('op-bz-score'); _bzNudge = $('op-bz-nudge'); + _detectBtn = $('op-detect'); _confirmBtn = $('op-confirm'); _clearBtn = $('op-clear'); + _markCount = $('op-mark-count'); _markHint = $('op-mark-hint'); + _embryoList = $('op-embryo-list'); _embryoCount = $('op-embryo-count'); + _selLabel = $('op-sel-label'); _spimImg = $('op-spim-img'); _spimPh = $('op-spim-ph'); + _spimToggle = $('op-spim-toggle'); _ledBtn = $('op-led'); + _fdPos = $('op-fd-pos'); _fdFloor = $('op-fd-floor'); _fdNudge = $('op-fd-nudge'); + _centerBtn = $('op-center'); _acquireBtn = $('op-acquire'); + _gvVal = $('op-gv'); _pzVal = $('op-pz'); _spimScore = $('op-spim-score'); + } + + // ---- Bottom-camera survey ------------------------------------------- + async function toggleCamera() { + _camToggle.disabled = true; + try { + const ep = _camOn ? '/api/devices/bottom_camera/stream/stop' + : '/api/devices/bottom_camera/stream/start'; + const data = await postJSON(ep, {}); + applyCameraState(!!data.streaming); + } catch (err) { + toast(`Camera toggle failed (${err.status || err.message})`); + } finally { + _camToggle.disabled = false; + } + } + function applyCameraState(on) { + _camOn = on; + _camToggle.textContent = on ? 'Stop camera' : 'Start camera'; + _camToggle.classList.toggle('op-btn-on', on); + if (!on && !_marking) { _camImg.classList.remove('has-frame'); _camPh.style.display = ''; _camPh.textContent = 'Camera off'; } + else if (on && !_marking) { _camPh.textContent = 'waiting…'; } + } + function onBottomFrame(p) { + if (!p || !p.jpeg_b64) return; + _lastFrame = p; + if (p.focus_score != null) _bzScore.textContent = Number(p.focus_score).toFixed(3); + if (_marking) return; // keep the frozen frame visible while marking + _camImg.src = `data:${p.mime || 'image/jpeg'};base64,${p.jpeg_b64}`; + if (!_camImg.classList.contains('has-frame')) { _camImg.classList.add('has-frame'); _camPh.style.display = 'none'; } + } + + // ---- Marking -------------------------------------------------------- + function umPerPxDisplay() { + const ds = (_frozenFrame && _frozenFrame.downsample) || 1; + return BASE_UM_PER_PX * ds; + } + // frame-space (downsampled px) -> stage µm, using the frozen capture pose. + function frameToStage(fx, fy) { + const u = umPerPxDisplay(); + const cx = _frozenFrame.w / 2, cy = _frozenFrame.h / 2; + return [_captureStage[0] + (fx - cx) * u, _captureStage[1] - (fy - cy) * u]; + } + function enterMarking(candidates) { + if (!_lastFrame) { toast('Start the camera first'); return false; } + _marking = true; + _frozenFrame = { w: _lastFrame.shape[1], h: _lastFrame.shape[0], downsample: _lastFrame.downsample || 1 }; + _captureStage = _lastFrame.stage_position || (_lastXY ? [_lastXY.X, _lastXY.Y] : [0, 0]); + _frozenSrc = `data:${_lastFrame.mime || 'image/jpeg'};base64,${_lastFrame.jpeg_b64}`; + _camImg.src = _frozenSrc; _camImg.classList.add('has-frame'); _camPh.style.display = 'none'; + _markers = []; + (candidates || []).forEach(c => { + const ds = _frozenFrame.downsample; + const fx = (c.pixel_x != null) ? c.pixel_x / ds : _frozenFrame.w / 2; + const fy = (c.pixel_y != null) ? c.pixel_y / ds : _frozenFrame.h / 2; + const stage = (c.stage_x_um != null && c.stage_y_um != null) + ? [c.stage_x_um, c.stage_y_um] : frameToStage(fx, fy); + _markers.push({ fx, fy, stageX: stage[0], stageY: stage[1], source: 'sam' }); + }); + _camStage.classList.add('op-marking'); + _markHint.textContent = 'Click to add an embryo · click a marker to remove · Confirm when done.'; + drawMarkers(); updateMarkActions(); + return true; + } + function exitMarking() { + _marking = false; _markers = []; _frozenSrc = null; _frozenFrame = null; + _camStage.classList.remove('op-marking'); + clearCanvas(); + updateMarkActions(); + _markHint.textContent = 'Start the camera, then Detect — or click the image to mark embryos.'; + if (_camOn && _lastFrame) onBottomFrame(_lastFrame); + } + // rendered image rect (object-fit: contain) within the stage box. + function renderedRect() { + const sb = _camStage.getBoundingClientRect(); + const fw = _frozenFrame ? _frozenFrame.w : (_lastFrame ? _lastFrame.shape[1] : sb.width); + const fh = _frozenFrame ? _frozenFrame.h : (_lastFrame ? _lastFrame.shape[0] : sb.height); + const ar = fw / fh, sar = sb.width / sb.height; + let w, h; + if (ar > sar) { w = sb.width; h = sb.width / ar; } else { h = sb.height; w = sb.height * ar; } + return { x: (sb.width - w) / 2, y: (sb.height - h) / 2, w, h, fw, fh, sb }; + } + function clearCanvas() { + if (!_markCanvas) return; + const ctx = _markCanvas.getContext('2d'); + ctx && ctx.clearRect(0, 0, _markCanvas.width, _markCanvas.height); + } + function drawMarkers() { + if (!_markCanvas || !_marking) return; + const r = renderedRect(); + _markCanvas.width = Math.round(r.sb.width); _markCanvas.height = Math.round(r.sb.height); + const ctx = _markCanvas.getContext('2d'); + ctx.clearRect(0, 0, _markCanvas.width, _markCanvas.height); + _markers.forEach((m, i) => { + const cx = r.x + (m.fx / r.fw) * r.w; + const cy = r.y + (m.fy / r.fh) * r.h; + ctx.beginPath(); ctx.arc(cx, cy, 11, 0, 7); + ctx.lineWidth = 2; ctx.strokeStyle = '#34d399'; ctx.stroke(); + ctx.beginPath(); ctx.moveTo(cx - 6, cy); ctx.lineTo(cx + 6, cy); + ctx.moveTo(cx, cy - 6); ctx.lineTo(cx, cy + 6); ctx.stroke(); + ctx.fillStyle = '#34d399'; ctx.font = '600 11px Inter Tight, system-ui, sans-serif'; + ctx.fillText(String(i + 1), cx + 13, cy - 8); + }); + } + function onCanvasClick(e) { + if (!_marking) return; + const r = renderedRect(); + const rect = _markCanvas.getBoundingClientRect(); + const cxv = e.clientX - rect.left, cyv = e.clientY - rect.top; + // hit-test existing markers (remove) + for (let i = 0; i < _markers.length; i++) { + const mx = r.x + (_markers[i].fx / r.fw) * r.w; + const my = r.y + (_markers[i].fy / r.fh) * r.h; + if (Math.hypot(cxv - mx, cyv - my) <= MARK_HIT_PX) { + _markers.splice(i, 1); drawMarkers(); updateMarkActions(); return; + } + } + // add — convert canvas px -> frame-space px -> stage µm + if (cxv < r.x || cxv > r.x + r.w || cyv < r.y || cyv > r.y + r.h) return; + const fx = ((cxv - r.x) / r.w) * r.fw; + const fy = ((cyv - r.y) / r.h) * r.fh; + const stage = frameToStage(fx, fy); + _markers.push({ fx, fy, stageX: stage[0], stageY: stage[1], source: 'manual' }); + drawMarkers(); updateMarkActions(); + } + function updateMarkActions() { + const n = _markers.length; + _markCount.textContent = `(${n})`; + _confirmBtn.disabled = !_marking || n === 0; + _clearBtn.disabled = !_marking || n === 0; + } + async function runDetect() { + _detectBtn.disabled = true; _detectBtn.textContent = 'Detecting…'; + try { + const data = await postJSON('/api/devices/detect_embryos', {}); + const cands = Array.isArray(data.embryos) ? data.embryos : []; + if (_lastFrame && data.stage_position) _lastFrame.stage_position = data.stage_position; + if (enterMarking(cands)) toast(`Detected ${cands.length} candidate${cands.length === 1 ? '' : 's'} — review & confirm`); + } catch (err) { + toast(`Detect failed (${err.status || err.message})`); + } finally { + _detectBtn.disabled = false; _detectBtn.textContent = 'Detect'; + } + } + async function confirmMarks() { + if (!_markers.length) return; + _confirmBtn.disabled = true; + try { + const markers = _markers.map(m => ({ stage_x_um: m.stageX, stage_y_um: m.stageY })); + const data = await postJSON('/api/devices/embryos/confirm', { markers }); + const n = (data.registered || []).length; + toast(`Registered ${n} embryo${n === 1 ? '' : 's'}`); + exitMarking(); // list refreshes via EMBRYOS_UPDATE + } catch (err) { + toast(`Confirm failed (${err.status || err.message})`); + _confirmBtn.disabled = false; + } + } + + // ---- Embryo list (single source of truth) --------------------------- + function resolveXY(emb) { + const f = emb && emb.position_fine; + if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) return { x: f.x, y: f.y }; + const c = emb && emb.position_coarse; + if (c && Number.isFinite(c.x) && Number.isFinite(c.y)) return { x: c.x, y: c.y }; + return null; + } + function labelFor(emb, i) { + const m = emb.id && String(emb.id).match(/(\d+)/); + return m ? m[1] : String(i + 1); + } + function onEmbryosUpdate(p) { + _embryos = (p && Array.isArray(p.embryos)) ? p.embryos : []; + // prune state for embryos that no longer exist + const ids = new Set(_embryos.map(e => e.id)); + Object.keys(_states).forEach(id => { if (!ids.has(id)) delete _states[id]; }); + _embryos.forEach(e => { if (!_states[e.id]) _states[e.id] = 'marked'; }); + if (_selected && !ids.has(_selected)) selectEmbryo(null); + renderList(); + } + function renderList() { + if (!_embryoList) return; + _embryoCount.textContent = _embryos.length ? `(${_embryos.length})` : ''; + _embryoList.innerHTML = ''; + if (!_embryos.length) { + const e = document.createElement('div'); e.className = 'op-empty'; + e.textContent = 'No embryos yet — mark some in step 1.'; + _embryoList.appendChild(e); return; + } + _embryos.forEach((emb, i) => { + const xy = resolveXY(emb); + const row = document.createElement('div'); + row.className = 'op-erow' + (emb.id === _selected ? ' op-erow-sel' : ''); + row.addEventListener('click', () => selectEmbryo(emb.id)); + + const dot = document.createElement('span'); + dot.className = 'op-edot'; dot.textContent = labelFor(emb, i); + dot.style.background = _ROLE_COLOR[emb.role] || _ROLE_COLOR.unassigned; + row.appendChild(dot); + + const coord = document.createElement('span'); + coord.className = 'op-ecoord'; + coord.textContent = xy ? `(${xy.x.toFixed(0)}, ${xy.y.toFixed(0)}) µm` : 'no position'; + row.appendChild(coord); + + const chip = document.createElement('span'); + const st = _states[emb.id] || 'marked'; + chip.className = `op-echip op-echip-${st}`; + chip.textContent = _STATE_LABEL[st] || st; + row.appendChild(chip); + + _embryoList.appendChild(row); + }); + } + function setState(id, st) { if (id) { _states[id] = st; renderList(); } } + + // ---- Acquire (selected embryo) -------------------------------------- + function selectEmbryo(id) { + _selected = id; + const emb = _embryos.find(e => e.id === id); + _selLabel.textContent = emb ? `— embryo ${labelFor(emb, _embryos.indexOf(emb))}` : '— select an embryo'; + _centerBtn.disabled = !emb; + _acquireBtn.disabled = !emb || (_states[id] !== 'focused' && _states[id] !== 'imaged'); + renderList(); + } + async function centerOnSelected() { + const emb = _embryos.find(e => e.id === _selected); + const xy = emb ? resolveXY(emb) : null; + if (!xy) return; + _centerBtn.disabled = true; _centerBtn.textContent = 'Centering…'; + try { + await postJSON('/api/devices/stage/move', { x: xy.x, y: xy.y }); + setState(_selected, 'centered'); + toast(`Centred on embryo ${labelFor(emb, _embryos.indexOf(emb))}`); + } catch (err) { + toast(`Center failed (${err.status || err.message})`); + } finally { + _centerBtn.disabled = false; _centerBtn.textContent = 'Center stage on embryo'; + } + } + async function acquireSelected() { + if (!_selected) return; + _acquireBtn.disabled = true; _acquireBtn.textContent = 'Acquiring…'; + try { + await postJSON('/api/devices/acquire/volume', { num_slices: 50, exposure_ms: 10.0 }); + setState(_selected, 'imaged'); + toast('Volume acquired'); + } catch (err) { + toast(`Acquire failed (${err.status || err.message})`); + } finally { + _acquireBtn.disabled = false; _acquireBtn.textContent = 'Acquire volume'; + } + } + + // ---- Focus Z nudges (fenced) ---------------------------------------- + async function nudgeBottomZ(delta) { + try { + const d = await postJSON('/api/devices/stage/bottom_z/nudge', { delta }); + if (d.position != null) _bzPos.textContent = Number(d.position).toFixed(1); + } catch (err) { toast(`Bottom-Z nudge blocked (${err.status || err.message})`); } + } + async function nudgeFdrive(delta) { + try { + const d = await postJSON('/api/devices/spim/fdrive/nudge', { delta }); + if (d.position != null) _fdPos.textContent = Number(d.position).toFixed(1); + if (d.distance_to_floor != null) _fdFloor.textContent = Number(d.distance_to_floor).toFixed(0); + if (_selected) setState(_selected, 'focused'); + selectEmbryo(_selected); // re-eval acquire enable + } catch (err) { toast(`F-drive nudge blocked (${err.status || err.message})`); } + } + async function refreshZReadouts() { + try { const b = await getJSON('/api/devices/stage/bottom_z'); if (b.position != null) _bzPos.textContent = Number(b.position).toFixed(1); } catch (_) {} + try { + const f = await getJSON('/api/devices/spim/fdrive'); + if (f.position != null) _fdPos.textContent = Number(f.position).toFixed(1); + if (f.distance_to_floor != null) _fdFloor.textContent = Number(f.distance_to_floor).toFixed(0); + } catch (_) {} + } + + // ---- SPIM focus ----------------------------------------------------- + async function toggleSpim() { + _spimToggle.disabled = true; + try { + const ep = _spimOn ? '/api/devices/lightsheet/live/stop' : '/api/devices/lightsheet/live/start'; + const data = await postJSON(ep, {}); + _spimOn = !!data.streaming; + _spimToggle.textContent = _spimOn ? 'Stop view' : 'Start view'; + _spimToggle.classList.toggle('op-btn-on', _spimOn); + if (!_spimOn) { _spimImg.classList.remove('has-frame'); _spimPh.style.display = ''; } + else _spimPh.textContent = 'waiting…'; + } catch (err) { toast(`SPIM view toggle failed (${err.status || err.message})`); } + finally { _spimToggle.disabled = false; } + } + function onLightsheetFrame(p) { + if (!p || !p.jpeg_b64) return; + _spimImg.src = `data:${p.mime || 'image/jpeg'};base64,${p.jpeg_b64}`; + if (!_spimImg.classList.contains('has-frame')) { _spimImg.classList.add('has-frame'); _spimPh.style.display = 'none'; } + if (p.focus_score != null) _spimScore.textContent = Number(p.focus_score).toFixed(3); + } + let _lsTimer = null; + function postLsParams() { + if (_lsTimer) clearTimeout(_lsTimer); + _lsTimer = setTimeout(() => { + postJSON('/api/devices/lightsheet/live/params', + { galvo: _galvo, piezo: _piezo, exposure: 20, side: 'A' }).catch(() => {}); + }, 120); + } + function nudgeGalvo(d) { _galvo = Math.max(-5, Math.min(5, _galvo + d)); _gvVal.textContent = _galvo.toFixed(1); postLsParams(); } + function nudgePiezo(d) { _piezo = Math.max(0, Math.min(200, _piezo + d)); _pzVal.textContent = _piezo.toFixed(0); postLsParams(); } + async function toggleLed() { + _ledOn = !_ledOn; + _ledBtn.classList.toggle('op-btn-on', _ledOn); _ledBtn.setAttribute('aria-pressed', _ledOn ? 'true' : 'false'); + try { await postJSON('/api/devices/led/set', { state: _ledOn ? 'Open' : 'Closed' }); } + catch (err) { toast(`LED failed (${err.status || err.message})`); } + } + + // ---- Wiring / lifecycle -------------------------------------------- + function wire() { + if (_wired) return; _wired = true; + cacheDom(); + _camToggle.addEventListener('click', toggleCamera); + _markCanvas.addEventListener('click', onCanvasClick); + _detectBtn.addEventListener('click', runDetect); + _confirmBtn.addEventListener('click', confirmMarks); + _clearBtn.addEventListener('click', () => { _markers = []; drawMarkers(); updateMarkActions(); }); + _bzNudge.addEventListener('click', e => { const b = e.target.closest('[data-bz]'); if (b) nudgeBottomZ(Number(b.dataset.bz)); }); + _fdNudge.addEventListener('click', e => { const b = e.target.closest('[data-fd]'); if (b) nudgeFdrive(Number(b.dataset.fd)); }); + _centerBtn.addEventListener('click', centerOnSelected); + _acquireBtn.addEventListener('click', acquireSelected); + _spimToggle.addEventListener('click', toggleSpim); + _ledBtn.addEventListener('click', toggleLed); + document.querySelectorAll('[data-gv]').forEach(b => b.addEventListener('click', () => nudgeGalvo(Number(b.dataset.gv)))); + document.querySelectorAll('[data-pz]').forEach(b => b.addEventListener('click', () => nudgePiezo(Number(b.dataset.pz)))); + window.addEventListener('resize', () => { if (_marking) drawMarkers(); }); + + if (typeof ClientEventBus !== 'undefined') { + ClientEventBus.on('BOTTOM_CAMERA_FRAME', onBottomFrame); + ClientEventBus.on('LIGHTSHEET_FRAME', onLightsheetFrame); + ClientEventBus.on('EMBRYOS_UPDATE', onEmbryosUpdate); + ClientEventBus.on('EMBRYO_DETECTED', () => {}); // bulk EMBRYOS_UPDATE covers it + ClientEventBus.on('DEVICE_STATE_UPDATE', p => { + const pos = p && p.positions; + if (pos && pos.xy_stage && Array.isArray(pos.xy_stage)) _lastXY = { X: pos.xy_stage[0], Y: pos.xy_stage[1] }; + }); + } + } + + async function activate() { + wire(); + if (_active) return; + _active = true; + // Bootstrap the canonical list (mid-session open). + try { const s = await getJSON('/api/embryos/current'); onEmbryosUpdate(s); } catch (_) {} + refreshZReadouts(); + } + function deactivate() { + if (!_active) return; + _active = false; + // Stop streams so they don't run while the view is hidden. + if (_camOn) { fetch('/api/devices/bottom_camera/stream/stop', { method: 'POST' }).catch(() => {}); applyCameraState(false); } + if (_spimOn) { fetch('/api/devices/lightsheet/live/stop', { method: 'POST' }).catch(() => {}); _spimOn = false; _spimToggle.textContent = 'Start view'; _spimToggle.classList.remove('op-btn-on'); } + if (_marking) exitMarking(); + } + + return { activate, deactivate }; +})(); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 62c79e24..a864a027 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -19,6 +19,7 @@ + {% if ux_v2 %} @@ -444,6 +445,7 @@

Notebook

Device state

+ @@ -482,6 +484,100 @@

Device

+ + +
@@ -1152,6 +1248,7 @@