Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4d503f8
docs(temperature): design spec for temperature interface (sub-project A)
pskeshu Jun 27, 2026
d1c881f
docs(temperature): TDD implementation plan for temperature interface …
pskeshu Jun 27, 2026
06a5883
feat(temperature): session-scoped temperature.jsonl store on FileStore
pskeshu Jun 27, 2026
3279c8f
feat(temperature): add TEMPERATURE_UPDATE event type
pskeshu Jun 27, 2026
81bba23
feat(temperature): TemperatureSampler service (poll/persist/emit @1Hz)
pskeshu Jun 27, 2026
122a580
feat(temperature): start/stop TemperatureSampler with the agent
pskeshu Jun 27, 2026
1a062c2
feat(temperature): GET /api/temperature/{session}/history backfill route
pskeshu Jun 27, 2026
a8e9817
feat(temperature): stamp latest reading into burst + volume metadata
pskeshu Jun 27, 2026
e21b567
feat(temperature): live SVG temperature graph on the Devices tab
pskeshu Jun 27, 2026
0daebda
fix(temperature): idempotent graph init + theme setpoint color + labe…
pskeshu Jun 27, 2026
1f6c7fa
docs(temperature): correct EventType auto() example in plan
pskeshu Jun 27, 2026
60e63dd
fix(temperature): events-tab exclusion, burst-stamp test, invalidate …
pskeshu Jun 27, 2026
a79a957
fix(temperature): root-level temp graph colors for portability
pskeshu Jun 28, 2026
81a4d0a
style: ruff check + format — lint clean
pskeshu Jun 29, 2026
639718c
feat(launch): --no-auth flag + quiet temperature-sampler backoff when…
pskeshu Jun 29, 2026
ddde093
docs: document --no-auth launch flag in README
pskeshu Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -203,8 +206,9 @@ First-run admin account created — sign in at the URL above:
- **Lost it?** There's no reset command yet — delete
`<GENTLY_STORAGE_PATH>/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 `<GENTLY_STORAGE_PATH>/auth/` (`users.yaml` + `secret.key`),
outside the repo.
Expand Down
793 changes: 793 additions & 0 deletions docs/superpowers/plans/2026-06-27-temperature-interface.md

Large diffs are not rendered by default.

179 changes: 179 additions & 0 deletions docs/superpowers/specs/2026-06-27-temperature-interface-design.md
Original file line number Diff line number Diff line change
@@ -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=<iso>` 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.
24 changes: 24 additions & 0 deletions gently/app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions gently/app/orchestration/exclusive.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

import numpy as np

from gently.app.temperature_sampler import temperature_stamp

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions gently/app/orchestration/timelapse.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def __init__(
session_id: str | None = None,
store: Optional["FileStore"] = None,
claude_client=None,
temperature_provider=None,
):
"""
Parameters
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
)

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading