diff --git a/README.md b/README.md index 78039e70..924f92cb 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,9 @@ uv run python launch_gently.py --no-api # Don't auto-open a browser — open the printed URL yourself uv run python launch_gently.py --no-browser +# Skip login — disable accounts (localhost-control mode; same as GENTLY_NO_AUTH=1) +uv run python launch_gently.py --no-auth + # Resume a previous session uv run python launch_gently.py --resume # interactive picker uv run python launch_gently.py --resume latest # most recent session @@ -203,8 +206,9 @@ First-run admin account created — sign in at the URL above: - **Lost it?** There's no reset command yet — delete `/auth/users.yaml` and restart to bootstrap a fresh `admin` (this clears all accounts). -- **Just trying it locally?** `GENTLY_NO_AUTH=1` disables accounts entirely - (legacy mode: localhost gets control, remote callers need `X-Gently-Token`). +- **Just trying it locally?** Launch with `--no-auth` (or set `GENTLY_NO_AUTH=1`) + to disable accounts entirely (localhost gets control, remote callers need + `X-Gently-Token`). Handy if you've lost the admin password. Accounts live under `/auth/` (`users.yaml` + `secret.key`), outside the repo. diff --git a/docs/superpowers/plans/2026-06-27-temperature-interface.md b/docs/superpowers/plans/2026-06-27-temperature-interface.md new file mode 100644 index 00000000..cfdaa563 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-temperature-interface.md @@ -0,0 +1,793 @@ +# Temperature Interface 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:** Persist live temperature into each imaging session and chart it, so bursts/volumes are correlatable to temperature and the rise/fall trajectory is visible during the temperature-strain experiments. + +**Architecture:** A `FileStore`-backed append-only `temperature.jsonl` per session (mirroring `predictions.jsonl`); a `TemperatureSampler(Service)` in the agent process — modeled on `DeviceStateMonitor` — that, while a session is active, polls the device layer at 1 Hz, appends each reading, holds the latest in memory, and publishes a `TEMPERATURE_UPDATE` event that the viz server already forwards to the browser; acquisition code stamps the latest reading into volume/burst metadata; a FastAPI history route backfills the graph; a hand-rolled SVG component renders water-temp + stepped-setpoint as a card on the Devices tab. + +**Tech Stack:** Python 3 + asyncio, FastAPI, the project's `EventBus`, `FileStore` (file-based YAML/JSONL), vanilla-JS + hand-rolled SVG frontend (no build step), pytest with `asyncio_mode = "auto"`. + +## Global Constraints + +- **No new dependency** — chart is hand-rolled SVG (`createElementNS`), matching `experiment-overview.js`. No charting library. +- **Session-scoped capture only** — sampler persists/emits **only while a session is active**; no always-on facility daemon. +- **Sample schema** (one JSONL line): `{"t": , "water_c": , "setpoint_c": , "state": }`. +- **Append pattern**: reuse `FileStore._append_jsonl` (append mode, `json.dumps(..., default=str)`, trailing `\n`). Meta written via existing `_write_yaml` / `yaml.safe_dump`. +- **Defaults (flippable):** 1 Hz sampling; stamp the **latest sample** (no fresh blocking read) at acquisition. +- **Robustness:** a failed poll = a gap, logged, loop continues; a sampler error never crashes the session. Empty state, **never mock data**. +- **Tests:** `pytest`; `async def test_*` needs no decorator (auto mode); use the `file_store` fixture (`tests/conftest.py:38-45`, `FileStore(tmp_path/...)`). The frontend has **no JS unit harness** — verify it by running the app + Chrome DevTools MCP. +- **Env:** production runs pip + `requirements*.txt` (no `uv`); we add no deps, so nothing to declare. + +--- + +### Task 1: Temperature log store (FileStore methods) + +**Files:** +- Modify: `gently/core/file_store.py` (add two methods on `FileStore`; reuse module-level `_append_jsonl` at `:197-201` and `_read_jsonl` at `:204-215`, and `_session_dir`/`_require_session_dir` at `:255-269`) +- Test: `tests/test_temperature_store.py` (new) + +**Interfaces:** +- Produces: + - `FileStore.append_temperature_sample(self, session_id: str, sample: dict) -> None` — appends one line to `sessions/{folder}/temperature.jsonl`. + - `FileStore.read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]` — returns samples; if `since` (an ISO-UTC string) is given, only samples with `r["t"] >= since` (lexicographic compare is valid for fixed-format UTC ISO). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_store.py +def _new_session(file_store): + return file_store.create_session(name="temp-test") # returns session_id + +def test_append_and_read_roundtrip(file_store): + sid = _new_session(file_store) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid) + assert [r["water_c"] for r in rows] == [28.0, 28.3] + +def test_read_since_filters(file_store): + sid = _new_session(file_store) + for i, t in enumerate(["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]): + file_store.append_temperature_sample(sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"}) + rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") + assert [r["water_c"] for r in rows] == [29.0, 30.0] + +def test_read_unknown_session_is_empty(file_store): + assert file_store.read_temperature_log("does-not-exist") == [] +``` + +> NOTE for implementer: confirm the exact session-creation API on `FileStore` (search for `def create_session`). If its signature differs, adjust `_new_session` accordingly — the rest of the test is unaffected. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_store.py -v` +Expected: FAIL — `AttributeError: 'FileStore' object has no attribute 'append_temperature_sample'` + +- [ ] **Step 3: Write minimal implementation** + +Add to the `FileStore` class body in `gently/core/file_store.py` (near the other per-session jsonl helpers like `add_prediction`): + +```python +def append_temperature_sample(self, session_id: str, sample: dict) -> None: + """Append one temperature reading to the session's temperature.jsonl.""" + sd = self._require_session_dir(session_id) + _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).""" + sd = self._session_dir(session_id) + if sd is None: + return [] + rows = _read_jsonl(sd / "temperature.jsonl") + if since is not None: + rows = [r for r in rows if str(r.get("t", "")) >= since] + return rows +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_store.py -v` +Expected: PASS (3 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/file_store.py tests/test_temperature_store.py +git commit -m "feat(temperature): session-scoped temperature.jsonl store on FileStore" +``` + +--- + +### Task 2: `TEMPERATURE_UPDATE` event type + +**Files:** +- Modify: `gently/core/event_bus.py` (add enum member near `:86`; add to `_NO_HISTORY_TYPES` near `:187`) +- Test: `tests/test_temperature_event.py` (new) + +**Interfaces:** +- Produces: `EventType.TEMPERATURE_UPDATE` (a new enum member). High-volume → excluded from event history. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_event.py +from gently.core.event_bus import EventType, EventBus + +def test_temperature_update_event_exists(): + assert EventType.TEMPERATURE_UPDATE.value == "TEMPERATURE_UPDATE" + +def test_temperature_update_publishes_to_subscriber(): + bus = EventBus() + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.TEMPERATURE_UPDATE, data={"x": 1}, source="t") + assert seen == [{"x": 1}] +``` + +> NOTE: confirm `EventBus.subscribe` signature/usage from an existing test (`tests/` has event-bus usage); adjust the subscribe call if the project's API differs (e.g. `subscribe(event_type, handler)` vs `subscribe(handler, event_type)`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_event.py -v` +Expected: FAIL — `AttributeError: TEMPERATURE_UPDATE` + +- [ ] **Step 3: Write minimal implementation** + +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 = 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`): + +```python + EventType.TEMPERATURE_UPDATE, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_event.py -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/core/event_bus.py tests/test_temperature_event.py +git commit -m "feat(temperature): add TEMPERATURE_UPDATE event type" +``` + +--- + +### Task 3: TemperatureSampler service + +**Files:** +- Create: `gently/app/temperature_sampler.py` +- Test: `tests/test_temperature_sampler.py` (new) +- Reference (template, do not modify): `gently/app/device_state_monitor.py`, `gently/core/service.py:63-172` + +**Interfaces:** +- Consumes: `EventType.TEMPERATURE_UPDATE` (Task 2); `FileStore.append_temperature_sample` (Task 1); a microscope client exposing `async get_temperature() -> dict` returning `{"success": bool, "temperature_c": float, "setpoint_c": float, "state": str, ...}` (`gently/hardware/dispim/client.py:837`). +- Produces: + - `TemperatureSampler(Service)` with `__init__(self, microscope, store, session_id_getter, interval_sec=1.0)`. + - `async on_start(self)` / `async on_stop(self)` (background asyncio loop, like `DeviceStateMonitor`). + - `async _tick(self, bus) -> None` — one poll/append/emit cycle (the unit under test). + - attribute `self.latest: dict | None` — most recent sample (for the acquisition stamp). + - module function `temperature_stamp(latest: dict | None) -> dict | None` — `{"water_c","setpoint_c","state","sampled_at"}` or `None`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_sampler.py +import asyncio +from gently.core.event_bus import EventBus, EventType +from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp + + +class FakeScope: + def __init__(self, resp): + self.resp = resp + self.calls = 0 + async def get_temperature(self): + self.calls += 1 + if isinstance(self.resp, Exception): + raise self.resp + return self.resp + + +def _capture(bus): + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + return seen + + +async def test_tick_appends_emits_and_sets_latest(file_store): + sid = file_store.create_session(name="s") + scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + bus = EventBus(); seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + rows = file_store.read_temperature_log(sid) + assert len(rows) == 1 and rows[0]["water_c"] == 28.4 + assert s.latest["water_c"] == 28.4 + assert seen and seen[0]["sample"]["water_c"] == 28.4 and seen[0]["session_id"] == sid + + +async def test_tick_no_active_session_is_noop(file_store): + scope = FakeScope({"success": True, "temperature_c": 1.0, "setpoint_c": 2.0, "state": "x"}) + bus = EventBus(); seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: None) + await s._tick(bus) + assert scope.calls == 0 and s.latest is None and seen == [] + + +async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): + sid = file_store.create_session(name="s") + scope = FakeScope(RuntimeError("device down")) + bus = EventBus() + s = TemperatureSampler(scope, file_store, lambda: sid) + # _run swallows; _tick raises — assert the loop-level guard swallows by calling _run-style guard: + try: + await s._tick(bus) + except RuntimeError: + pass # _tick may raise; the loop in _run catches it (see test below) + assert file_store.read_temperature_log(sid) == [] + + +def test_temperature_stamp_shapes(): + assert temperature_stamp(None) is None + assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == { + "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00", + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_sampler.py -v` +Expected: FAIL — `ModuleNotFoundError: gently.app.temperature_sampler` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/app/temperature_sampler.py +"""Session-scoped temperature sampler — polls the device layer, persists, emits. + +Modeled on gently/app/device_state_monitor.py. While a session is active it polls +the microscope's temperature at a fixed cadence, appends each reading to the +session's temperature.jsonl, holds the latest reading (for acquisition stamping), +and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a +crash; with no active session the loop idles. +""" +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from gently.core.service import Service +from gently.core.event_bus import get_event_bus, EventType + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def temperature_stamp(latest: dict | None) -> dict | None: + """Build a temperature meta block from a latest sample, or None if unavailable.""" + if not latest: + return None + return { + "water_c": latest.get("water_c"), + "setpoint_c": latest.get("setpoint_c"), + "state": latest.get("state"), + "sampled_at": latest.get("t"), + } + + +class TemperatureSampler(Service): + def __init__(self, microscope, store, session_id_getter, interval_sec: float = 1.0): + super().__init__(name="temperature-sampler", service_type="monitor") + self._microscope = microscope + self._store = store + self._session_id_getter = session_id_getter + self._interval = interval_sec + self._task: asyncio.Task | None = None + self.latest: dict | None = None + + async def on_start(self) -> None: + self._task = asyncio.create_task(self._run(), name="temperature-sampler-loop") + + async def on_stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + bus = get_event_bus() + while True: + try: + await self._tick(bus) + except asyncio.CancelledError: + raise + except Exception as exc: # a gap, never a crash + logger.warning("temperature sampler tick failed: %s", exc) + await asyncio.sleep(self._interval) + + async def _tick(self, bus) -> None: + session_id = self._session_id_getter() + if not session_id: + return + resp = await self._microscope.get_temperature() + if not resp or not resp.get("success", True): + return + water = resp.get("temperature_c") + if water is None: + return + sample = { + "t": _now_iso(), + "water_c": water, + "setpoint_c": resp.get("setpoint_c"), + "state": resp.get("state"), + } + self._store.append_temperature_sample(session_id, sample) + self.latest = sample + bus.publish( + event_type=EventType.TEMPERATURE_UPDATE, + data={"session_id": session_id, "sample": sample}, + source="temperature-sampler", + ) +``` + +> Note the failure test: `_tick` propagates the poll exception, and `_run`'s `except Exception` swallows it. The test asserts the gap (no rows). If you prefer, also add a direct `_run`-guard test that starts the loop briefly and asserts it does not raise — optional. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_sampler.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/temperature_sampler.py tests/test_temperature_sampler.py +git commit -m "feat(temperature): TemperatureSampler service (poll/persist/emit @1Hz)" +``` + +--- + +### Task 4: Wire the sampler into the agent lifecycle + +**Files:** +- Modify: `gently/app/agent.py` — init attribute (near `:212`), construct+start (near the `DeviceStateMonitor` block `:818-827` inside `start_viz_server`), stop (near `:850-855`). +- Test: `tests/test_temperature_sampler_wiring.py` (new) + +**Interfaces:** +- Consumes: `TemperatureSampler` (Task 3); the agent's microscope client (`self.microscope`), its `FileStore`, and `self.session_id`. +- Produces: `agent.temperature_sampler: TemperatureSampler | None` (the live instance, read by the acquisition stamp in Task 6). + +> IMPLEMENTER: confirm the agent's FileStore attribute name before writing the construction line. Search `gently/app/agent.py` for the `FileStore` it uses (likely `self.store`). Use that exact attribute. If the agent reaches the store indirectly, pass whatever object exposes `append_temperature_sample`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_sampler_wiring.py +from gently.app.temperature_sampler import TemperatureSampler + +def test_agent_initializes_temperature_sampler_attribute(): + # The attribute must exist (None until start_viz_server runs with a microscope). + import gently.app.agent as agent_mod + src = (agent_mod.__file__) + text = open(src, encoding="utf-8").read() + assert "temperature_sampler" in text + assert "TemperatureSampler(" in text +``` + +> This is a light wiring guard (a full agent boot is an integration concern). It fails until the wiring lines exist. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_sampler_wiring.py -v` +Expected: FAIL — assertion error ("temperature_sampler" not in source) + +- [ ] **Step 3: Write minimal implementation** + +Near `:212` (with the other monitor attrs, e.g. `self.device_state_monitor = None`): + +```python + self.temperature_sampler = None +``` + +Inside `start_viz_server`, right after the `DeviceStateMonitor` start block (`:818-827`): + +```python + if self.microscope is not None and self.temperature_sampler is None: + from .temperature_sampler import TemperatureSampler + self.temperature_sampler = TemperatureSampler( + self.microscope, self.store, lambda: self.session_id + ) + await self.temperature_sampler.start() +``` + +In the symmetric shutdown path (`:850-855`, where `device_state_monitor.stop()` is awaited): + +```python + if self.temperature_sampler is not None: + await self.temperature_sampler.stop() + self.temperature_sampler = None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_sampler_wiring.py -v` +Expected: PASS (1 passed) + +Then full suite sanity: `pytest -q` — Expected: no new failures. + +- [ ] **Step 5: Commit** + +```bash +git add gently/app/agent.py tests/test_temperature_sampler_wiring.py +git commit -m "feat(temperature): start/stop TemperatureSampler with the agent" +``` + +--- + +### Task 5: History API route + +**Files:** +- Create: `gently/ui/web/routes/temperature.py` +- Modify: `gently/ui/web/routes/__init__.py` (import + add to the factories tuple in `register_all_routes`) +- Test: `tests/test_temperature_route.py` (new) +- Reference (template): `gently/ui/web/routes/experiments.py:1-66`, test template `tests/test_data_catalog.py:69-114` + +**Interfaces:** +- Consumes: `server.gently_store` (a `FileStore`) with `list_sessions()`, `_session_dir(id)`, and `read_temperature_log(id, since=)` (Task 1). +- Produces: `GET /api/temperature/{session_id}/history?since=` → `{"session_id": str, "samples": list[dict]}`; `session_id="current"` resolves to newest session. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_route.py +from unittest.mock import MagicMock +from pathlib import Path +from fastapi import FastAPI +from fastapi.testclient import TestClient +from gently.ui.web.routes.temperature import create_router + + +def _server(samples, sessions=(("sess-1", True),)): + store = MagicMock() + store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] + store._session_dir.side_effect = lambda sid: Path("/x") if any(sid == s for s, _ in sessions) else None + store.read_temperature_log.return_value = samples + srv = MagicMock(); srv.gently_store = store + return srv, store + + +def _client(server): + app = FastAPI(); app.include_router(create_router(server)) + return TestClient(app) + + +def test_history_returns_samples(): + srv, store = _server([{"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}]) + r = _client(srv).get("/api/temperature/sess-1/history") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "sess-1" + assert body["samples"][0]["water_c"] == 28.0 + + +def test_history_passes_since_through(): + srv, store = _server([]) + _client(srv).get("/api/temperature/sess-1/history?since=2026-06-27T10:00:01+00:00") + store.read_temperature_log.assert_called_with("sess-1", since="2026-06-27T10:00:01+00:00") + + +def test_history_current_resolves_newest(): + srv, store = _server([], sessions=(("newest", True),)) + r = _client(srv).get("/api/temperature/current/history") + assert r.status_code == 200 + assert r.json()["session_id"] == "newest" + + +def test_history_unknown_session_404(): + srv, store = _server([], sessions=(("sess-1", True),)) + # _session_dir returns None for unknown -> 404 + store._session_dir.side_effect = lambda sid: None + r = _client(srv).get("/api/temperature/ghost/history") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_route.py -v` +Expected: FAIL — `ModuleNotFoundError: gently.ui.web.routes.temperature` + +- [ ] **Step 3: Write minimal implementation** + +```python +# gently/ui/web/routes/temperature.py +"""Read-only temperature history for the live graph (backfill on mount/reload). + +Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. +Mirrors routes/experiments.py session resolution. +""" +from fastapi import APIRouter, HTTPException + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str): + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + if session_id == "current": + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + if store._session_dir(session_id) is None: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + return session_id + + @router.get("/api/temperature/{session_id}/history") + async def get_history(session_id: str, since: str | None = None): + real_id = _resolve_session(session_id) + store = server.gently_store + samples = store.read_temperature_log(real_id, since=since) + return {"session_id": real_id, "samples": samples} + + return router +``` + +Register it in `gently/ui/web/routes/__init__.py` — add the import and append `create_router` to the factories iterated by `register_all_routes` (follow the existing pattern exactly; alias to avoid name clashes, e.g. `from .temperature import create_router as create_temperature_router`). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_route.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/routes/temperature.py gently/ui/web/routes/__init__.py tests/test_temperature_route.py +git commit -m "feat(temperature): GET /api/temperature/{session}/history backfill route" +``` + +--- + +### Task 6: Acquisition temperature stamp (burst + volume) + +**Files:** +- Modify: `gently/app/orchestration/exclusive.py` — `_persist_burst_to_disk` (per-frame `metadata` at `:387-407`, `burst.yaml` manifest dict at `:423-442`); the `BurstAcquisition`/`ExclusiveAcquisition` construction to receive a temperature provider. +- Modify: the volume acquisition call site that builds `metadata` for `FileStore.put_volume`/`register_volume` (locate the caller in `gently/app/orchestration/timelapse.py` / `gently/app/tools/acquisition_tools.py`). +- Test: `tests/test_temperature_stamp.py` (new) — covers the pure helper + the volume metadata channel. +- Consumes: `temperature_stamp` and `agent.temperature_sampler.latest` (Tasks 3–4). + +**Interfaces:** +- Produces: a `temperature` block under `metadata` for volumes (`meta["metadata"]["temperature"]`) and under both per-frame `metadata` and the `burst.yaml` manifest top-level for bursts. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_temperature_stamp.py +import numpy as np +from gently.app.temperature_sampler import temperature_stamp + + +def test_stamp_none_when_no_reading(): + assert temperature_stamp(None) is None + +def test_volume_metadata_carries_temperature(file_store): + sid = file_store.create_session(name="s") + emb = file_store.create_embryo(sid, position={"x": 0, "y": 0, "z": 0}) # confirm signature + stamp = temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + vol = np.zeros((2, 4, 4), dtype="uint16") + file_store.put_volume(sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp}) + meta = file_store.get_volume_meta(sid, emb, 0) # confirm accessor name + assert meta["metadata"]["temperature"]["water_c"] == 28.4 +``` + +> IMPLEMENTER: confirm `create_embryo` and the volume-meta accessor (`get_volume_meta` or read the `.meta.yaml` directly via `get_volume_path(...).with_suffix` — adjust to the real API). The assertion target — `metadata["temperature"]` round-tripping into `t0000.meta.yaml` — is the contract; `put_volume` already nests the passed `metadata`, so this passes once the helper exists and the accessor is right. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_temperature_stamp.py -v` +Expected: FAIL — initially on import/accessor; fix the accessor name from the real API, then it exercises the channel. + +- [ ] **Step 3: Write minimal implementation** + +**Volume path** — at the acquisition call site that calls `put_volume`/`register_volume`, fold the stamp into the existing `metadata` dict: + +```python +from gently.app.temperature_sampler import temperature_stamp +# ... where `agent` (or self) holds the sampler and `metadata` is being built: +stamp = temperature_stamp(getattr(getattr(agent, "temperature_sampler", None), "latest", None)) +if stamp is not None: + metadata["temperature"] = stamp +``` + +**Burst path** — `gently/app/orchestration/exclusive.py`: +1. Add a constructor param to the acquisition class that persists bursts: `temperature_provider=None` (a zero-arg callable returning the latest sample dict, or `None`), stored as `self._temperature_provider`. +2. In `_persist_burst_to_disk`, compute once: + +```python +from gently.app.temperature_sampler import temperature_stamp +_temp = temperature_stamp(self._temperature_provider() if self._temperature_provider else None) +``` + +3. Inject into the per-frame `metadata` dict (`:387-407`): add `"temperature": _temp` (only when not None — or always; `None` is acceptable YAML). +4. Inject into the `burst.yaml` manifest dict (`:423-442`): add a top-level `"temperature": _temp`. +5. Where this acquisition class is constructed (the orchestrator that owns bursts — `gently/app/orchestration/timelapse.py`), pass `temperature_provider=lambda: agent.temperature_sampler.latest if agent.temperature_sampler else None` (use the orchestrator's existing agent/store handle; confirm the attribute). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_temperature_stamp.py -v` +Expected: PASS + +- [ ] **Step 5: Verify burst wiring by inspection + targeted run** + +The burst persist writes real TIFFs, so it is verified by inspection (the `_temp` block is added in both dict sites) plus, during end-to-end verification (after Task 7), trigger one burst against the mock device and confirm `burst.yaml` and a frame `.meta.yaml` contain a `temperature` block. Note in the commit if the volume call site could not be located and was deferred — do not silently skip it. + +- [ ] **Step 6: Commit** + +```bash +git add gently/app/orchestration/exclusive.py gently/app/orchestration/timelapse.py tests/test_temperature_stamp.py +git commit -m "feat(temperature): stamp latest reading into burst + volume metadata" +``` + +--- + +### Task 7: Frontend — temperature graph component + Devices card + +**Files:** +- Create: `gently/ui/web/static/js/temperature-graph.js` +- Modify: `gently/ui/web/templates/index.html` — add a chart container inside `#devices-content` (the existing small readout is at `:452-465`; mount the chart as a section under the Details/Map view). +- Modify: `gently/ui/web/static/js/devices.js` — initialize the chart in `init()` (`:1556`) and subscribe it to `TEMPERATURE_UPDATE` (next to the `DEVICE_STATE_UPDATE` subscription at `:1561`). +- Reference (SVG style): `gently/ui/web/static/js/experiment-overview.js`; (event API) `gently/ui/web/static/js/event-bus.js:9-48` (`ClientEventBus.on(type, handler)`). + +**No JS unit harness exists** — this task is verified by running the app + Chrome DevTools MCP (see Step 4), consistent with the repo and the "UI audit before done" practice. + +- [ ] **Step 1: Build the component** + +Create `temperature-graph.js` exposing a small object/module: + +```javascript +// gently/ui/web/static/js/temperature-graph.js +// Hand-rolled SVG line chart: water-temp trace + stepped setpoint line. +// No dependency. Backfills from /api/temperature/{session}/history, then appends +// from TEMPERATURE_UPDATE events. Calm empty state, never mock data. +const TemperatureGraph = (() => { + const SVGNS = "http://www.w3.org/2000/svg"; + const MAX_POINTS = 600; // rolling ~10 min @ 1 Hz + let _root = null, _samples = [], _session = "current"; + + function init(container, sessionId) { + _root = container; _session = sessionId || "current"; _samples = []; + backfill(); + ClientEventBus.on("TEMPERATURE_UPDATE", onEvent); + } + + async function backfill() { + try { + const r = await fetch(`/api/temperature/${_session}/history`); + if (!r.ok) { renderEmpty(); return; } + const body = await r.json(); + _session = body.session_id || _session; + _samples = (body.samples || []).slice(-MAX_POINTS); + render(); + } catch (e) { renderEmpty(); } + } + + function onEvent(data) { + if (!data || !data.sample) return; + _samples.push(data.sample); + if (_samples.length > MAX_POINTS) _samples.shift(); + render(); + } + + function renderEmpty() { + _root.innerHTML = '
No temperature data yet
'; + } + + function render() { + if (!_samples.length) { renderEmpty(); return; } + const W = _root.clientWidth || 480, H = 160, pad = 24; + const xs = _samples.map((_, i) => i); + const ws = _samples.map(s => s.water_c).filter(v => v != null); + const sps = _samples.map(s => s.setpoint_c).filter(v => v != null); + const lo = Math.min(...ws, ...sps) - 1, hi = Math.max(...ws, ...sps) + 1; + const sx = i => pad + (i / Math.max(1, xs.length - 1)) * (W - 2 * pad); + const sy = v => H - pad - ((v - lo) / Math.max(0.001, hi - lo)) * (H - 2 * pad); + + const svg = document.createElementNS(SVGNS, "svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.setAttribute("width", "100%"); + + const line = (pts, cls) => { + const p = document.createElementNS(SVGNS, "polyline"); + p.setAttribute("points", pts); p.setAttribute("class", cls); + p.setAttribute("fill", "none"); svg.appendChild(p); + }; + line(_samples.map((s, i) => s.water_c != null ? `${sx(i)},${sy(s.water_c)}` : "").filter(Boolean).join(" "), "temp-water"); + // Stepped setpoint: carry previous y until it changes. + let sp = []; _samples.forEach((s, i) => { if (s.setpoint_c != null) sp.push(`${sx(i)},${sy(s.setpoint_c)}`); }); + line(sp.join(" "), "temp-setpoint"); + + const last = _samples[_samples.length - 1]; + const readout = document.createElement("div"); + readout.className = "temp-graph-readout"; + readout.textContent = `${last.water_c?.toFixed?.(1) ?? "—"} °C → ${last.setpoint_c?.toFixed?.(1) ?? "—"} °C (${last.state ?? ""})`; + + _root.innerHTML = ""; _root.appendChild(readout); _root.appendChild(svg); + } + + function dispose() { ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); } + return { init, dispose, _render: render, _samples: () => _samples }; +})(); +window.TemperatureGraph = TemperatureGraph; +``` + +Add minimal CSS (in the devices stylesheet) for `.temp-water` (stroke: water color), `.temp-setpoint` (dashed stroke), `.temp-graph-empty` (muted), matching existing palette. + +- [ ] **Step 2: Mount it** + +In `templates/index.html`, add inside `#devices-content` (under the map/details view): + +```html +
+``` + +Load the script (next to the other `static/js/*.js` includes for the devices tab). + +In `devices.js` `init()` (`:1556`), after existing setup: + +```javascript + const tg = document.getElementById('devices-temp-graph'); + if (tg && window.TemperatureGraph) TemperatureGraph.init(tg, 'current'); +``` + +(No extra subscription needed in `devices.js` — the component self-subscribes. Optionally also route the existing small readout off the new event.) + +- [ ] **Step 3: Add the script tag** + +Add `` in `index.html` alongside the other component scripts, **before** `devices.js` loads (so `window.TemperatureGraph` exists when `init()` runs). + +- [ ] **Step 4: Verify live in the app (Chrome DevTools MCP)** + +Use the `run` skill to launch the app with the mock temperature backend and an active session. Then with Chrome DevTools MCP: +- navigate to the Devices tab, take a snapshot/screenshot; +- confirm the empty state shows when no samples, then the water trace + stepped setpoint render and update live as the sampler emits; +- run the UI audit (alignment/spacing/overflow/contrast) per the "UI audit before done" practice and fix any flaws; +- trigger one burst and confirm (Task 6) that `burst.yaml` + a frame `.meta.yaml` carry a `temperature` block. + +- [ ] **Step 5: Commit** + +```bash +git add gently/ui/web/static/js/temperature-graph.js gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js +git commit -m "feat(temperature): live SVG temperature graph on the Devices tab" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Persistence / `temperature.jsonl` → Task 1. ✓ +- Sampler (poll @1 Hz, session-gated, latest-in-memory, SSE) → Tasks 2–4. ✓ +- Per-acquisition stamp (burst + volume) → Task 6. ✓ +- History API (`current` resolution, `since`) → Task 5. ✓ +- Reusable SVG graph on Devices card (water trace + stepped setpoint + readout, backfill + live, empty state) → Task 7. ✓ +- Error/empty handling (gap-not-crash, no-device idle, empty state) → Tasks 3 & 7. ✓ +- Out-of-scope (setpoint control, choreography, always-on) → not implemented, by design. ✓ + +**Open verification items folded into tasks (not placeholders):** session-creation API (Task 1/3/6), `EventBus.subscribe` shape (Task 2), the agent's FileStore attribute (Task 4), the volume-meta accessor + `create_embryo` signature (Task 6), the burst-acquisition construction site (Task 6). Each is an explicit "confirm from the real API" instruction with a concrete fallback, not a TODO. + +**Type consistency:** `temperature_stamp` returns `{water_c, setpoint_c, state, sampled_at}` everywhere; sample lines are `{t, water_c, setpoint_c, state}`; event payload is `{session_id, sample}`; history response is `{session_id, samples}`. Consistent across Tasks 1/3/5/6/7. diff --git a/docs/superpowers/specs/2026-06-27-temperature-interface-design.md b/docs/superpowers/specs/2026-06-27-temperature-interface-design.md new file mode 100644 index 00000000..30de5138 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-temperature-interface-design.md @@ -0,0 +1,179 @@ +# Design: Temperature Interface (persistence + live graph) + +Status: design approved in brainstorm (2026-06-27). +Base branch: off `integration/ux2-all` (PR #58, the UX-v2 stack) — this feature builds on that UI. + +## 0. Where this sits (execution roadmap) + +This is **sub-project A** of a sequenced program of work, preparing gently for +production temperature-strain experiments next week. The full order (commit history is +meant to reflect this priority): + +- **Track 1 — critical path for next week** + - **A. Temperature interface** *(this spec)* — persist temperature + live graph. + - **B. Manual mode** — write-enabled control surface (illumination from MMConfig presets, + live camera view, volume/timelapse config + by-hand triggers, temperature setpoint + control). Embeds A's graph. +- **Track 2 — fast-follow (repeatability + observability)** + - **C. Temp-change burst tactic** — a `MonitoringMode` choreographing + transmission-burst → setpoint-change → bursts-during → bursts-after. + - **D. Operations-tab overhaul** — make composed tactics observable. +- **Track 4 — later infrastructure** + - **G. Tactics library** (user-/agent-authored, persisted, reusable tactics), then + - **F. Plan-mode files** (repo base plans + session↔plan link/delink UI). +- **Lowest** + - **E. Presentation / README.** + +Next week's first experiment round is **hand-driven** (A + B). A is foundational because +B embeds its graph and C/D both consume the persisted temperature series. + +## 1. Overview + +The temperature controller (ACUITYnano Peltier stage, `gently/hardware/temperature.py`) is +already integrated as a Bluesky device: `read()` returns water temp + setpoint + state live, +exposed over the device layer at `GET /api/temperature/status`, with `set_temperature` / +`get_temperature` agent tools. The web UI already has an SSE push channel +(`DEVICE_STATE_UPDATE`) and shows temperature read-only in `devices.js`. + +**Two things are missing, and this sub-project supplies exactly those two:** + +1. **Persistence** — temperature is live-only today. `ImagingSpec.temperature_c` is a + *planned setpoint*, never a recorded readout. Nothing writes actual temperature into the + session or burst files, so a burst cannot be correlated to "what was the temperature." + This is the one real data hole for the experiment. +2. **A live graph** — a continuous chart of current temperature, setpoint, and the rise/fall + trajectory over time. + +### What we keep / reuse +- The controller's existing `read()` and the device-layer `GET /api/temperature/status` route. +- The existing SSE push mechanism (`websocket.js` / `event-bus.js` / `status-store.js`). +- The file-based, human-browsable store convention (`FileStore`, append-only jsonl like + `timeline.jsonl` / `predictions.jsonl`). +- The hand-rolled-SVG charting style already used in `experiment-overview.js` / + `occupancy3d.js` — **no new charting dependency.** +- The "calm empty state, never mock data" UI convention. + +### Scope decision: session-scoped capture +Temperature is captured **only while an imaging session is active** (decided in brainstorm). +No always-on facility daemon, no between-session global log. The series belongs to the +session, which is where correlation matters. + +## 2. Architecture — five well-bounded units + +### 2.1 Temperature log store (persistence) +Append-only series, one file per session: `sessions/{session}/temperature.jsonl`. +Dedicated file — **not** folded into `timeline.jsonl`, because 1 Hz samples would drown the +event stream and a standalone series is trivial to read and plot. + +API (methods on / adjacent to `FileStore`, `gently/core/file_store.py`): +- `append_temperature_sample(session_id, sample)` — atomic append of one line. +- `read_temperature_log(session_id, since=None)` — return samples, optionally filtered to + those at/after a timestamp (for the graph's rolling window / backfill). + +Sample schema (one jsonl line): +```json +{"t": "2026-06-27T10:31:04.512Z", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"} +``` +`state` mirrors the controller's reported state string (e.g. heating / cooling / locked). + +### 2.2 Temperature sampler (the capture loop) +A small background service in the **agent** process (persistence and session lifecycle are +agent-side). While a session is active it: +- polls the device layer's `GET /api/temperature/status` on a fixed cadence, +- appends each reading to the session's `temperature.jsonl` via 2.1, +- holds the **latest reading in memory** (so acquisition can stamp it without a blocking read), +- emits an SSE `TEMPERATURE_UPDATE` event for the live graph. + +Lifecycle: started/stopped with the session. If no temperature device is configured (the +`temperature:` block is config-gated in `device_layer.py`), the sampler does not run. + +Cadence: **1 Hz**, configurable via the existing `temperature:` config block. (Thermal change +is slow; 1 Hz gives a smooth trace without flooding the log.) + +### 2.3 Acquisition stamp (image↔temperature correlation) +At volume / burst acquisition, write the sampler's latest reading into the acquisition's +metadata: +- volume: `embryos/{id}/volumes/t{NNNN}.meta.yaml` +- burst: each frame `.meta.yaml` and the `burst.yaml` manifest + (`gently/app/orchestration/exclusive.py`). + +Stamped block: +```yaml +temperature: + water_c: 28.4 + setpoint_c: 32.0 + state: heating + sampled_at: 2026-06-27T10:31:04.512Z # timestamp of the reading, not of acquisition +``` +We stamp the **latest sample** rather than a fresh blocking device read — at 1 Hz it is +current enough, and acquisition stays latency-free. If no sample exists yet (session just +started), omit the `temperature` block rather than writing stale/zero values. + +### 2.4 Read API (graph backfill) +`GET /api/temperature/history?session=current&since=` returns the series for the initial +graph render and reloads (`session=current` resolves to the most-recently-touched session, +matching the existing experiments route convention). Live updates ride the existing SSE +channel via `TEMPERATURE_UPDATE`; the endpoint is only for backfill. + +### 2.5 Frontend component (the graph) +`gently/ui/web/static/js/temperature-graph.js` — a hand-rolled SVG line chart, reusable and +self-mounting, surfaced as a **card on the Devices tab** (where temperature already shows +read-only). B (manual mode) re-mounts the same component. + +Renders: +- **water-temp trace** (continuous line), +- **setpoint line** (distinct, stepped — so a commanded jump from 28→32 reads as a step while + the water trace climbs toward it), +- **current numeric readouts** (water, setpoint, state) above the chart, +- a **rolling time window** (last few minutes) with full-history fetch on demand. + +Data: backfills from 2.4 on mount, then appends from the `TEMPERATURE_UPDATE` SSE stream. + +## 3. Data flow + +``` +device layer (owns controller) + ▲ GET /api/temperature/status (poll @ 1 Hz, while session active) + │ + agent: TemperatureSampler ──► append temperature.jsonl (persistence) + │ └─► keep latest-in-memory ──► acquisition stamps meta + └─► emit SSE TEMPERATURE_UPDATE ──► browser: temperature-graph.js (live append) + +browser on mount/reload: GET /api/temperature/history?session=current ──► backfill graph +``` + +## 4. Error handling & edge cases + +- **No temperature device / device layer down** → sampler no-ops; graph shows a calm + "no temperature data" empty state (matching the codebase convention). Never mock data. +- **A failed poll** → log a gap and continue; a sampler error never crashes the session. +- **Partial last jsonl line** (atomic append + a reader tolerant of a truncated final line) → + the history endpoint skips an unparseable trailing line rather than erroring. +- **Acquisition before first sample** → omit the `temperature` block from meta (no stale/zero). +- **Setpoint change** → captured naturally: the next sample carries the new `setpoint_c`, so + the stepped setpoint line moves on its own; no special event needed. + +## 5. Testing (TDD) + +- **Store**: `append_temperature_sample` / `read_temperature_log` round-trip; `since` filter; + tolerance of a truncated trailing line. +- **Sampler**: against the existing mock controller scaffold + (`gently/hardware/dispim/devices/test_temperature_controller.py`) — samples at cadence, + appends, updates latest-in-memory, emits SSE; **no-device path** (sampler stays dormant); + a poll failure leaves a gap without raising. +- **Acquisition stamp**: a fake volume + burst acquisition stamps the latest sample into meta; + the no-sample-yet path omits the block. +- **History endpoint**: filters by `since`; `session=current` resolution. +- **Frontend**: a light render test for the SVG component — water trace + stepped setpoint + geometry, and the empty state when history is empty. + +## 6. Out of scope (deferred to later sub-projects) + +- **Setpoint *control* from the UI** — belongs to B (manual mode), which embeds this graph. +- **Automated temp-change choreography** — belongs to C (the tactic). +- **Always-on / facility-wide temperature logging** — explicitly not now (session-scoped only). + +## 7. Open defaults (easy to flip) + +- **1 Hz** sampling cadence. +- Stamp the **latest sample** (not a fresh blocking read) at acquisition time. diff --git a/gently/app/agent.py b/gently/app/agent.py index f68d67a7..13a9d73a 100644 --- a/gently/app/agent.py +++ b/gently/app/agent.py @@ -210,6 +210,8 @@ def __init__( # Device-state monitor (bridges device-layer SSE → EventBus) self.device_state_monitor = None + # Session-scoped temperature sampler — polls device layer, persists readings. + self.temperature_sampler = 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 @@ -633,6 +635,9 @@ def _init_timelapse_orchestrator(self): session_id=self.session_id, store=self.store, claude_client=self.claude, + temperature_provider=lambda: ( + self.temperature_sampler.latest if self.temperature_sampler else None + ), ) except Exception as e: logging.getLogger(__name__).warning(f"Failed to init timelapse orchestrator: {e}") @@ -826,6 +831,19 @@ async def start_viz_server( logger.warning(f"Failed to start device-state monitor: {e}") self.device_state_monitor = None + if self.microscope is not None and self.temperature_sampler is None: + try: + from .temperature_sampler import TemperatureSampler + + self.temperature_sampler = TemperatureSampler( + self.microscope, self.store, lambda: self.session_id + ) + await self.temperature_sampler.start() + logger.info("Temperature sampler started") + except Exception as e: + logger.warning(f"Failed to start temperature sampler: {e}") + self.temperature_sampler = 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. @@ -853,6 +871,12 @@ async def stop_viz_server(self): except Exception: logger.exception("Failed to stop device-state monitor") self.device_state_monitor = None + if self.temperature_sampler is not None: + try: + await self.temperature_sampler.stop() + except Exception: + logger.exception("Failed to stop temperature sampler") + self.temperature_sampler = None if self.viz_server is not None: await self.viz_server.stop() self.viz_server = None diff --git a/gently/app/orchestration/exclusive.py b/gently/app/orchestration/exclusive.py index db9804bd..83b196d7 100644 --- a/gently/app/orchestration/exclusive.py +++ b/gently/app/orchestration/exclusive.py @@ -23,6 +23,8 @@ import numpy as np +from gently.app.temperature_sampler import temperature_stamp + logger = logging.getLogger(__name__) @@ -98,11 +100,13 @@ def __init__( mode: str = "1hz", num_slices: int = 1, request_id: str | None = None, + temperature_provider=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 async def run(self, orchestrator) -> ExclusiveResult: from gently.core import EventType @@ -204,6 +208,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), + temperature_provider=self._temperature_provider, ) # Generate MP4 (derivative artifact; safe to fail). @@ -318,6 +323,7 @@ def _persist_burst_to_disk( piezo_amplitude: float, piezo_center: float, laser_power_488_pct: float | None, + temperature_provider=None, ) -> Path | None: """Save per-frame TIFFs + meta + projections + a burst.yaml manifest. @@ -344,6 +350,11 @@ def _persist_burst_to_disk( proj_dir = burst_dir / "projections" proj_dir.mkdir(exist_ok=True) + # Compute temperature stamp once for the whole burst (all frames share the + # same reading — the sampler captures at ~1 Hz so per-frame variation is + # sub-resolution anyway). + _temp = temperature_stamp(temperature_provider() if temperature_provider else None) + # Position recorded for the manifest. pos = getattr(embryo, "stage_position", {}) or {} sid = getattr(orchestrator, "_session_id", None) @@ -403,6 +414,7 @@ def _persist_burst_to_disk( "burst_mode": mode, "laser_power_488_pct": laser_power_488_pct, "role": "burst", + "temperature": _temp, }, } if _yaml is not None: @@ -432,6 +444,7 @@ def _persist_burst_to_disk( "sustained_hz": sustained_hz, "embryo_position": {"x": pos.get("x"), "y": pos.get("y")}, "laser_power_488_pct": laser_power_488_pct, + "temperature": _temp, "scan": { "galvo_amplitude": galvo_amplitude, "galvo_center": galvo_center, diff --git a/gently/app/orchestration/timelapse.py b/gently/app/orchestration/timelapse.py index 6b140922..5918bd9c 100644 --- a/gently/app/orchestration/timelapse.py +++ b/gently/app/orchestration/timelapse.py @@ -74,6 +74,7 @@ def __init__( session_id: str | None = None, store: Optional["FileStore"] = None, claude_client=None, + temperature_provider=None, ): """ Parameters @@ -102,6 +103,11 @@ def __init__( self.on_volume_callback = on_volume_callback self.claude_client = claude_client + # Zero-arg callable returning the latest temperature sample dict (or None). + # Threaded from the agent's TemperatureSampler so burst frames carry a + # temperature block in their metadata. + self._temperature_provider = temperature_provider + # Trace file storage (writes JSON files to disk) self._session_id = session_id self._trace_dir: Path | None = None @@ -1974,6 +1980,7 @@ def _parse_dt(s): mode=op_doc.get("mode", "1hz"), num_slices=int(op_doc.get("num_slices", 1)), request_id=op_doc.get("request_id"), + temperature_provider=self._temperature_provider, ) ) @@ -2092,6 +2099,7 @@ def queue_burst( frames=frames, mode=mode, num_slices=num_slices, + temperature_provider=self._temperature_provider, ) self._exclusive_queue.append(op) logger.info( diff --git a/gently/app/temperature_sampler.py b/gently/app/temperature_sampler.py new file mode 100644 index 00000000..3e02ba8c --- /dev/null +++ b/gently/app/temperature_sampler.py @@ -0,0 +1,110 @@ +"""Session-scoped temperature sampler — polls the device layer, persists, emits. + +Modeled on gently/app/device_state_monitor.py. While a session is active it polls +the microscope's temperature at a fixed cadence, appends each reading to the +session's temperature.jsonl, holds the latest reading (for acquisition stamping), +and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a +crash; with no active session the loop idles. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from gently.core.event_bus import EventType, get_event_bus +from gently.core.service import Service + +logger = logging.getLogger(__name__) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def temperature_stamp(latest: dict | None) -> dict | None: + """Build a temperature meta block from a latest sample, or None if unavailable.""" + if not latest: + return None + return { + "water_c": latest.get("water_c"), + "setpoint_c": latest.get("setpoint_c"), + "state": latest.get("state"), + "sampled_at": latest.get("t"), + } + + +class TemperatureSampler(Service): + def __init__(self, microscope, store, session_id_getter, interval_sec: float = 1.0): + super().__init__(name="temperature-sampler", service_type="monitor") + self._microscope = microscope + self._store = store + self._session_id_getter = session_id_getter + self._interval = interval_sec + self._task: asyncio.Task | None = None + self.latest: dict | None = None + + async def on_start(self) -> None: + self._task = asyncio.create_task(self._run(), name="temperature-sampler-loop") + + async def on_stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run(self) -> None: + bus = get_event_bus() + fail_streak = 0 + while True: + try: + await self._tick(bus) + if fail_streak: + logger.info( + "temperature sampler recovered after %d quiet failure(s)", fail_streak + ) + fail_streak = 0 + except asyncio.CancelledError: + raise + except Exception as exc: # a gap, never a crash + fail_streak += 1 + if fail_streak == 1: + # Log once, then stay quiet (e.g. device layer not connected yet). + logger.warning( + "temperature sampler paused — %s (retrying quietly until connected)", exc + ) + # Back off while failing so we neither spam logs nor hammer a disconnected server. + await asyncio.sleep( + self._interval if fail_streak == 0 else min(self._interval * 10, 30.0) + ) + + 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(), + "water_c": water, + "setpoint_c": resp.get("setpoint_c"), + "state": resp.get("state"), + } + self._store.append_temperature_sample(session_id, sample) + self.latest = sample + bus.publish( + event_type=EventType.TEMPERATURE_UPDATE, + data={"session_id": session_id, "sample": sample}, + source="temperature-sampler", + ) diff --git a/gently/app/tools/acquisition_tools.py b/gently/app/tools/acquisition_tools.py index 79f69732..bfc892ea 100644 --- a/gently/app/tools/acquisition_tools.py +++ b/gently/app/tools/acquisition_tools.py @@ -212,6 +212,13 @@ async def acquire_volume( "piezo_center": piezo_center, }, } + from gently.app.temperature_sampler import temperature_stamp as _ts + + _temp_stamp = _ts( + getattr(getattr(agent, "temperature_sampler", None), "latest", None) + ) + if _temp_stamp is not None: + acq_metadata["temperature"] = _temp_stamp volume_path_ref = result.get("volume_path") if volume_path_ref is not None: saved_path = agent.store.register_volume( diff --git a/gently/core/event_bus.py b/gently/core/event_bus.py index ce3aac03..b3bd7e68 100644 --- a/gently/core/event_bus.py +++ b/gently/core/event_bus.py @@ -84,6 +84,7 @@ class EventType(Enum): FOCUS_CHANGED = auto() LASER_CHANGED = auto() 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 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 @@ -185,6 +186,7 @@ class EventType(Enum): _NO_HISTORY_TYPES = frozenset( { 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.LOG_RECORD, # log lines can hit hundreds/min during # calibration; durable copy is in the diff --git a/gently/core/file_store.py b/gently/core/file_store.py index 5a8b7f01..46c781b2 100644 --- a/gently/core/file_store.py +++ b/gently/core/file_store.py @@ -444,6 +444,38 @@ def load_session_snapshot(self, session_id: str) -> dict | None: with open(path, encoding="utf-8") as f: return json.load(f) + def append_temperature_sample(self, session_id: str, sample: dict) -> None: + """Append one temperature reading to the session's temperature.jsonl.""" + sd = self._require_session_dir(session_id) + _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). + + 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 [] + 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 + # ------------------------------------------------------------------ # Session lock # ------------------------------------------------------------------ @@ -735,6 +767,14 @@ def get_volume_path(self, session_id: str, embryo_id: str, timepoint: int) -> Pa return vol_path return None + def get_volume_meta(self, session_id: str, embryo_id: str, timepoint: int) -> dict | None: + """Read the sidecar metadata YAML for a volume. Returns None if not found.""" + sd = self._session_dir(session_id) + if sd is None: + return None + meta_path = sd / "embryos" / embryo_id / "volumes" / self._volume_meta_filename(timepoint) + return _read_yaml(meta_path) + def list_volumes(self, session_id: str, embryo_id: str | None = None) -> list[VolumeInfo]: """List volume metadata by scanning sidecar YAML files on disk.""" sd = self._session_dir(session_id) diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py index 9f8cd903..3a5e2051 100644 --- a/gently/ui/web/routes/__init__.py +++ b/gently/ui/web/routes/__init__.py @@ -17,6 +17,7 @@ from .notebook import create_router as create_notebook_router from .pages import create_router as create_pages_router from .sessions import create_router as create_sessions_router +from .temperature import create_router as create_temperature_router from .volumes import create_router as create_volumes_router from .websocket import create_router as create_websocket_router @@ -37,6 +38,7 @@ def register_all_routes(server): create_chat_router, create_context_router, create_notebook_router, + create_temperature_router, ): router = factory(server) server.app.include_router(router) diff --git a/gently/ui/web/routes/temperature.py b/gently/ui/web/routes/temperature.py new file mode 100644 index 00000000..90916181 --- /dev/null +++ b/gently/ui/web/routes/temperature.py @@ -0,0 +1,47 @@ +"""Read-only temperature history for the live graph (backfill on mount/reload). + +Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. +Mirrors routes/experiments.py session resolution. +""" + +import urllib.parse + +from fastapi import APIRouter, HTTPException, Request + + +def create_router(server) -> APIRouter: + router = APIRouter() + + def _resolve_session(session_id: str): + store = getattr(server, "gently_store", None) + if store is None: + raise HTTPException(status_code=503, detail="FileStore not configured on viz server") + if session_id == "current": + sessions = store.list_sessions() + if not sessions: + raise HTTPException(status_code=404, detail="No sessions in store") + session_id = sessions[0].get("session_id") + if store._session_dir(session_id) is None: + raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") + return session_id + + @router.get("/api/temperature/{session_id}/history") + async def get_history(session_id: str, request: Request): + # Parse `since` from raw query string using unquote (not unquote_plus) so that + # timezone offsets like +00:00 are preserved. Standard FastAPI query-param + # parsing applies unquote_plus, which converts + to a space. + raw_qs = request.scope.get("query_string", b"").decode() + since = None + for part in raw_qs.split("&"): + if "=" in part: + k, v = part.split("=", 1) + if urllib.parse.unquote(k) == "since": + since = urllib.parse.unquote(v) + break + + real_id = _resolve_session(session_id) + store = server.gently_store + samples = store.read_temperature_log(real_id, since=since) + return {"session_id": real_id, "samples": samples} + + return router diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index 10bc6f62..36124bd9 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -23,6 +23,13 @@ --accent-pink: #f472b6; --accent-cyan: #22d3ee; + /* 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%); --gradient-success: linear-gradient(135deg, #4ade80 0%, #22d3ee 100%); @@ -63,6 +70,12 @@ --accent-pink: #ec4899; --accent-cyan: #06b6d4; + /* 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%); --gradient-success: linear-gradient(135deg, #22c55e 0%, #06b6d4 100%); @@ -9736,6 +9749,63 @@ body.modal-open { .devices-temp-set:hover:not(:disabled) { border-color: var(--map-accent); color: var(--map-ink); } .devices-temp-set:disabled { opacity: 0.5; cursor: default; } +/* --- Temperature trajectory graph ------------------------------------- */ +/* SVG chart card mounted at the bottom of #devices-content. */ +.devices-temp-graph { + padding: 0.6rem 1rem 0.5rem; + border-top: 1px solid var(--map-overlay-edge); + min-height: 2rem; /* keeps the section visible even when empty */ +} +/* Solid line: water temperature */ +.temp-water { + stroke: var(--temp-water-color); + stroke-width: 1.5; + stroke-linejoin: round; + stroke-linecap: round; +} +/* Dashed line: setpoint (stepped) */ +.temp-setpoint { + stroke: var(--temp-setpoint-color); + stroke-width: 1.5; + stroke-dasharray: 5 3; + stroke-linejoin: round; + stroke-linecap: round; + opacity: 0.85; +} +/* Y-axis reference gridlines */ +.temp-grid-line { + stroke: var(--temp-grid-line-color); + stroke-width: 0.75; +} +/* Y-axis tick labels */ +.temp-grid-label { + fill: var(--temp-grid-label-color); + font-size: 8px; + font-family: ui-monospace, monospace; + text-anchor: end; + dominant-baseline: middle; +} +/* Calm empty state — never shows mock data */ +.temp-graph-empty { + padding: 1rem 0; + text-align: center; + color: var(--map-ink-mute); + font-size: 0.78rem; +} +/* Last-sample readout above the SVG */ +.temp-graph-readout { + font-size: 0.68rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; + color: var(--map-ink-mute); + margin-bottom: 0.2rem; + padding: 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* --- 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 040b11d1..a56f0015 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -1554,6 +1554,8 @@ const DevicesManager = (function () { setupCameraWiring(); setupRoomLight(); setupTemperature(); + const _tgEl = document.getElementById('devices-temp-graph'); + if (_tgEl && window.TemperatureGraph) TemperatureGraph.init(_tgEl, 'current'); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); diff --git a/gently/ui/web/static/js/temperature-graph.js b/gently/ui/web/static/js/temperature-graph.js new file mode 100644 index 00000000..86129902 --- /dev/null +++ b/gently/ui/web/static/js/temperature-graph.js @@ -0,0 +1,168 @@ +/** + * Temperature Graph — hand-rolled SVG line chart for the Devices tab. + * + * Shows the water-temp trace (solid) and stepped setpoint line (dashed), + * backfilled from /api/temperature/{session}/history and then updated live + * via TEMPERATURE_UPDATE events from the ClientEventBus. + * + * No external dependencies. Calm empty state; never renders mock data. + * + * Usage: + * TemperatureGraph.init(containerEl, 'current'); + * // The component self-subscribes; no extra subscription needed in the caller. + * TemperatureGraph.dispose(); // on teardown + */ +const TemperatureGraph = (() => { + const SVGNS = "http://www.w3.org/2000/svg"; + const MAX_POINTS = 600; // rolling ~10 min @ 1 Hz + + let _root = null; + let _samples = []; + let _session = "current"; + + function init(container, sessionId) { + ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); + _root = container; + _session = sessionId || "current"; + _samples = []; + backfill(); + ClientEventBus.on("TEMPERATURE_UPDATE", onEvent); + } + + async function backfill() { + try { + const r = await fetch(`/api/temperature/${_session}/history`); + if (!r.ok) { renderEmpty(); return; } + const body = await r.json(); + // Adopt the resolved session_id (e.g. 'current' → real id) so that + // subsequent event filtering is consistent. + _session = body.session_id || _session; + _samples = (body.samples || []).slice(-MAX_POINTS); + render(); + } catch (e) { + console.warn('[TemperatureGraph] backfill error:', e); + renderEmpty(); + } + } + + function onEvent(data) { + // data = {session_id, sample: {t, water_c, setpoint_c, state}} + if (!data || !data.sample) return; + _samples.push(data.sample); + if (_samples.length > MAX_POINTS) _samples.shift(); + render(); + } + + function renderEmpty() { + if (!_root) return; + _root.innerHTML = '
No temperature data yet
'; + } + + function render() { + if (!_root) return; + if (!_samples.length) { renderEmpty(); return; } + + const W = _root.clientWidth || 480; + const H = 160; + const pad = { top: 12, right: 16, bottom: 20, left: 28 }; + + const ws = _samples.map(s => s.water_c).filter(v => v != null); + const sps = _samples.map(s => s.setpoint_c).filter(v => v != null); + + if (!ws.length) { renderEmpty(); return; } + + const allVals = [...ws, ...sps]; + const lo = Math.min(...allVals) - 0.5; + const hi = Math.max(...allVals) + 0.5; + const range = Math.max(0.001, hi - lo); + const plotW = W - pad.left - pad.right; + const plotH = H - pad.top - pad.bottom; + const n = _samples.length; + + const sx = i => pad.left + (i / Math.max(1, n - 1)) * plotW; + const sy = v => pad.top + plotH - ((v - lo) / range) * plotH; + + const svg = document.createElementNS(SVGNS, "svg"); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + svg.setAttribute("width", "100%"); + svg.setAttribute("aria-hidden", "true"); + + // Y-axis gridlines (3 levels) + const gridG = document.createElementNS(SVGNS, "g"); + gridG.setAttribute("class", "temp-graph-grid"); + for (let k = 0; k <= 2; k++) { + const v = lo + (k / 2) * (hi - lo); + const y = sy(v); + const gridLine = document.createElementNS(SVGNS, "line"); + gridLine.setAttribute("x1", pad.left); + gridLine.setAttribute("x2", W - pad.right); + gridLine.setAttribute("y1", y); + gridLine.setAttribute("y2", y); + gridLine.setAttribute("class", "temp-grid-line"); + gridG.appendChild(gridLine); + + const lbl = document.createElementNS(SVGNS, "text"); + lbl.setAttribute("x", pad.left - 3); + lbl.setAttribute("y", y); + lbl.setAttribute("class", "temp-grid-label"); + lbl.textContent = v.toFixed(1); + gridG.appendChild(lbl); + } + svg.appendChild(gridG); + + // Helper: build a polyline from a point-string array + const makeLine = (pts, cls) => { + if (!pts.length) return; + const p = document.createElementNS(SVGNS, "polyline"); + p.setAttribute("points", pts.join(" ")); + p.setAttribute("class", cls); + p.setAttribute("fill", "none"); + svg.appendChild(p); + }; + + // Water temp — skip null samples (gap rather than crash) + const waterPts = []; + _samples.forEach((s, i) => { + if (s.water_c != null) waterPts.push(`${sx(i).toFixed(1)},${sy(s.water_c).toFixed(1)}`); + }); + makeLine(waterPts, "temp-water"); + + // Setpoint — stepped: carry the last known setpoint forward + const spPts = []; + let lastSP = null; + _samples.forEach((s, i) => { + const sp = s.setpoint_c != null ? s.setpoint_c : lastSP; + if (sp != null) { + spPts.push(`${sx(i).toFixed(1)},${sy(sp).toFixed(1)}`); + lastSP = sp; + } + }); + makeLine(spPts, "temp-setpoint"); + + // Current readout line (last sample) + const last = _samples[_samples.length - 1]; + const wStr = last.water_c != null ? `${last.water_c.toFixed(1)} °C` : "—"; + const spStr = last.setpoint_c != null ? `${last.setpoint_c.toFixed(1)} °C` : "—"; + const stStr = last.state ? ` · ${last.state}` : ""; + + const readout = document.createElement("div"); + readout.className = "temp-graph-readout"; + readout.title = "Water temperature → setpoint (state)"; + readout.textContent = `${wStr} → ${spStr}${stStr}`; + + _root.innerHTML = ""; + _root.appendChild(readout); + _root.appendChild(svg); + } + + function dispose() { + ClientEventBus.off("TEMPERATURE_UPDATE", onEvent); + _root = null; + _samples = []; + } + + // Exposed for testing / forced refresh from devices.js + return { init, dispose, _render: render, _samples: () => _samples }; +})(); + +window.TemperatureGraph = TemperatureGraph; 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/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index 8bc8bf06..7e1f0726 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -711,6 +711,9 @@

Properties

+ + +
@@ -807,6 +810,7 @@

Properties

+ diff --git a/launch_gently.py b/launch_gently.py index 64efdc15..4aad5bda 100644 --- a/launch_gently.py +++ b/launch_gently.py @@ -231,6 +231,7 @@ async def main( log_level: str = "WARNING", no_browser: bool = False, no_api: bool = False, + no_auth: bool = False, ): # Set up log file in storage directory # Unified with FileStore: logs live under the same root as data @@ -272,9 +273,16 @@ async def main( # ── 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). + # Pass --no-auth (or set GENTLY_NO_AUTH=1) to disable accounts (localhost-control mode). admin_creds = None - if os.environ.get("GENTLY_NO_AUTH", "").strip().lower() not in ("1", "true", "yes"): + auth_disabled = no_auth or os.environ.get("GENTLY_NO_AUTH", "").strip().lower() in ( + "1", + "true", + "yes", + ) + if auth_disabled: + logger.warning("Accounts disabled (--no-auth) — control is open on this host") + if not auth_disabled: try: from gently.ui.web.accounts import AccountStore, set_account_store @@ -647,6 +655,11 @@ def cli_main(): "-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-auth", + action="store_true", + help="Disable accounts/login (localhost-control mode; same as GENTLY_NO_AUTH=1)", + ) parser.add_argument( "--no-browser", action="store_true", @@ -684,6 +697,7 @@ def cli_main(): log_level=log_level, no_browser=args.no_browser, no_api=args.no_api, + no_auth=args.no_auth, ) ) except (KeyboardInterrupt, RuntimeError, SystemExit): diff --git a/tests/test_temperature_event.py b/tests/test_temperature_event.py new file mode 100644 index 00000000..80e6601f --- /dev/null +++ b/tests/test_temperature_event.py @@ -0,0 +1,21 @@ +""" +Test suite for TEMPERATURE_UPDATE event type +""" + +from gently.core.event_bus import EventBus, EventType + + +def test_temperature_update_event_exists(): + """Verify TEMPERATURE_UPDATE enum member exists with correct name""" + assert hasattr(EventType, "TEMPERATURE_UPDATE") + event_type = EventType.TEMPERATURE_UPDATE + assert event_type.name == "TEMPERATURE_UPDATE" + + +def test_temperature_update_publishes_to_subscriber(): + """Verify publishing TEMPERATURE_UPDATE reaches subscribers""" + bus = EventBus() + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + bus.publish(event_type=EventType.TEMPERATURE_UPDATE, data={"x": 1}, source="t") + assert seen == [{"x": 1}] diff --git a/tests/test_temperature_route.py b/tests/test_temperature_route.py new file mode 100644 index 00000000..5e71a9ea --- /dev/null +++ b/tests/test_temperature_route.py @@ -0,0 +1,64 @@ +from pathlib import Path +from unittest.mock import MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gently.ui.web.routes.temperature import create_router + + +def _server(samples, sessions=(("sess-1", True),)): + store = MagicMock() + store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] + store._session_dir.side_effect = lambda sid: ( + Path("/x") if any(sid == s for s, _ in sessions) else None + ) + store.read_temperature_log.return_value = samples + srv = MagicMock() + srv.gently_store = store + return srv, store + + +def _client(server): + app = FastAPI() + app.include_router(create_router(server)) + return TestClient(app) + + +def test_history_returns_samples(): + srv, store = _server( + [ + { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.0, + "setpoint_c": 32.0, + "state": "heating", + } + ] + ) + r = _client(srv).get("/api/temperature/sess-1/history") + assert r.status_code == 200 + body = r.json() + assert body["session_id"] == "sess-1" + assert body["samples"][0]["water_c"] == 28.0 + + +def test_history_passes_since_through(): + srv, store = _server([]) + _client(srv).get("/api/temperature/sess-1/history?since=2026-06-27T10:00:01+00:00") + store.read_temperature_log.assert_called_with("sess-1", since="2026-06-27T10:00:01+00:00") + + +def test_history_current_resolves_newest(): + srv, store = _server([], sessions=(("newest", True),)) + r = _client(srv).get("/api/temperature/current/history") + assert r.status_code == 200 + assert r.json()["session_id"] == "newest" + + +def test_history_unknown_session_404(): + srv, store = _server([], sessions=(("sess-1", True),)) + # _session_dir returns None for unknown -> 404 + store._session_dir.side_effect = lambda sid: None + r = _client(srv).get("/api/temperature/ghost/history") + assert r.status_code == 404 diff --git a/tests/test_temperature_sampler.py b/tests/test_temperature_sampler.py new file mode 100644 index 00000000..8a221aef --- /dev/null +++ b/tests/test_temperature_sampler.py @@ -0,0 +1,118 @@ +"""Tests for TemperatureSampler service. + +Adaptation note: create_session() requires session_id as its first positional +argument (confirmed from file_store.py:328). The brief's +`file_store.create_session(name="s")` is adapted to +`file_store.create_session(str(uuid.uuid4()), name="s")` to match the real API. +""" + +import uuid + +from gently.app.temperature_sampler import TemperatureSampler, temperature_stamp +from gently.core.event_bus import EventBus, EventType + + +class FakeScope: + def __init__(self, resp): + self.resp = resp + self.calls = 0 + + async def get_temperature(self): + self.calls += 1 + if isinstance(self.resp, Exception): + raise self.resp + return self.resp + + +def _capture(bus): + seen = [] + bus.subscribe(EventType.TEMPERATURE_UPDATE, lambda e: seen.append(e.data)) + return seen + + +async def test_tick_appends_emits_and_sets_latest(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) + bus = EventBus() + seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: sid) + await s._tick(bus) + rows = file_store.read_temperature_log(sid) + assert len(rows) == 1 and rows[0]["water_c"] == 28.4 + assert s.latest["water_c"] == 28.4 + assert seen and seen[0]["sample"]["water_c"] == 28.4 and seen[0]["session_id"] == sid + + +async def test_tick_no_active_session_is_noop(file_store): + scope = FakeScope({"success": True, "temperature_c": 1.0, "setpoint_c": 2.0, "state": "x"}) + bus = EventBus() + seen = _capture(bus) + s = TemperatureSampler(scope, file_store, lambda: None) + await s._tick(bus) + assert scope.calls == 0 and s.latest is None and seen == [] + + +async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + scope = FakeScope(RuntimeError("device down")) + bus = EventBus() + s = TemperatureSampler(scope, file_store, lambda: sid) + # _tick propagates the poll exception; _run's except-Exception swallows it. + # Assert the gap (no rows). + try: + await s._tick(bus) + except RuntimeError: + pass # _tick may raise; the loop in _run catches it + assert file_store.read_temperature_log(sid) == [] + + +def test_temperature_stamp_shapes(): + assert temperature_stamp(None) is None + assert temperature_stamp( + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) == { + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + "sampled_at": "2026-06-27T10:00:00+00:00", + } + + +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_sampler_wiring.py b/tests/test_temperature_sampler_wiring.py new file mode 100644 index 00000000..3eec8148 --- /dev/null +++ b/tests/test_temperature_sampler_wiring.py @@ -0,0 +1,25 @@ +"""Wiring guard: asserts that agent.py contains the TemperatureSampler lifecycle hooks. + +This is an intentionally lightweight source-text guard — a full agent boot is a +heavy integration concern verified end-to-end in a later task. DeviceStateMonitor's +own wiring is likewise not unit-tested at this level. +""" + + +def test_agent_initializes_temperature_sampler_attribute(): + """agent.py must declare the attribute *and* construct the sampler. + + Uses find_spec to locate the source file without executing the module + (agent.py has heavy runtime deps like anthropic that aren't present in the + test environment). + """ + import importlib.util + from pathlib import Path + + spec = importlib.util.find_spec("gently.app.agent") + assert spec is not None and spec.origin is not None, ( + "Could not locate gently/app/agent.py via importlib" + ) + text = Path(spec.origin).read_text(encoding="utf-8") + assert "temperature_sampler" in text, "agent.py has no 'temperature_sampler' attribute" + assert "TemperatureSampler(" in text, "agent.py never constructs a TemperatureSampler" diff --git a/tests/test_temperature_stamp.py b/tests/test_temperature_stamp.py new file mode 100644 index 00000000..4b88fcdb --- /dev/null +++ b/tests/test_temperature_stamp.py @@ -0,0 +1,118 @@ +"""Tests for Task 6 & Task 7: acquisition temperature stamp. + +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. +- register_embryo(session_id, embryo_id, ...) — embryo_id is the key; returns None. +- put_volume(session_id, embryo_id, timepoint, volume, metadata=None) — nests `metadata` + 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 / +_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 + + +def test_stamp_none_when_no_reading(): + assert temperature_stamp(None) is None + + +def test_volume_metadata_carries_temperature(file_store): + sid = file_store.create_session(str(uuid.uuid4()), name="s") + emb = "embryo_1" + file_store.register_embryo(sid, emb, position_x=0.0, position_y=0.0) + stamp = temperature_stamp( + { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + } + ) + vol = np.zeros((2, 4, 4), dtype="uint16") + + # tifffile is not installed in this environment; mock it so put_volume can + # proceed to writing the sidecar YAML (the part under test). + tifffile_mock = MagicMock() + with patch.dict(sys.modules, {"tifffile": tifffile_mock}): + with patch.object(file_store, "_generate_projection", return_value=None): + file_store.put_volume( + sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp} + ) + + 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 new file mode 100644 index 00000000..dfc36fd9 --- /dev/null +++ b/tests/test_temperature_store.py @@ -0,0 +1,71 @@ +"""Tests for FileStore temperature log methods.""" + + +def _new_session(file_store): + """Create a test session and return its ID.""" + # Use UUID-based session ID for uniqueness + import uuid + + session_id = str(uuid.uuid4()) + return file_store.create_session(session_id, name="temp-test") + + +def test_append_and_read_roundtrip(file_store): + """Test appending and reading temperature samples.""" + sid = _new_session(file_store) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}, + ) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}, + ) + rows = file_store.read_temperature_log(sid) + assert [r["water_c"] for r in rows] == [28.0, 28.3] + + +def test_read_since_filters(file_store): + """Test that read_temperature_log filters by since parameter.""" + sid = _new_session(file_store) + for i, t in enumerate( + ["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"] + ): + file_store.append_temperature_sample( + sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"} + ) + rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") + assert [r["water_c"] for r in rows] == [29.0, 30.0] + + +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.""" + + 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. + # 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]