Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
7121a26
Capture illumination in optical config; add Ti2 inventory/config tools
qquais Sep 13, 2026
d02e178
Fix Bayer decode order and report camera white-balance mode
qquais Sep 23, 2026
9fe791f
Add a file-based emergency stop enforced inside every motion primitive
qquais Sep 23, 2026
fb1cb3a
Add an always-on-top STOP panel and expose the e-stop over MCP
qquais Sep 23, 2026
0fe4b79
Add move_xy: manual XY stage moves split into legal hops
qquais Sep 23, 2026
55f15c0
Add Ti2 stage probe scripts for halt and read-during-move behaviour
qquais Sep 23, 2026
73b44f1
Add fixed-position brightfield time-lapse acquisition
qquais Sep 23, 2026
e4f9d8e
Add tiled mosaic acquisition with stitching and focus-plane support
qquais Sep 23, 2026
e612905
Add Physarum contraction-rhythm analysis for time-lapse series
qquais Sep 23, 2026
dd669d4
Add make_movie: render a time-lapse series to MP4
qquais Sep 23, 2026
1de814c
Add live_view: browser preview of a running acquisition's files
qquais Sep 23, 2026
01e81b8
Remove duplicated TODO fragment from nudge_pfs_offset docstring
qquais Sep 23, 2026
6021c3a
Keep Nikon Ti2 SDK documentation out of the repository
qquais Sep 23, 2026
f190ea8
make_movie: render mosaic runs, one frame per stitched round
qquais Sep 23, 2026
725c268
Add oat_approach: measure plasmodium approach to food across mosaic r…
qquais Sep 23, 2026
4d3ffbd
Add estop_inflight_test: can the e-stop stop a move already running?
qquais Sep 25, 2026
c028cee
Make the e-stop flag-only: remove the halt write-back
qquais Sep 25, 2026
ff37bd8
get_image(): add backend='mock' path via MockNIS.capture(), keep sdk …
qquais Sep 28, 2026
7b8c53d
Add timelapse/ package: change_detector, frame_audit, first test suite
qquais Sep 28, 2026
45dbd54
tests: fix mock get_image assertions (resolve symlinked tmp path, MCP…
qquais Sep 28, 2026
e717379
Add timelapse/scheduler.py (adaptive slow/burst loop) and CI workflow
qquais Sep 28, 2026
d6726a5
docs: adaptive time-lapse proposal, README sections, changelog
qquais Sep 28, 2026
cbfe0e6
changelog: file adaptive time-lapse entries under Added; note the fix…
qquais Sep 28, 2026
a3dff0b
Pin numpy to 2.4.6: 2.5.x requires Python 3.12, project supports 3.11
qquais Sep 28, 2026
e848f0a
Add protocol-level MCP server test: subprocess server, exact tool set…
qquais Sep 28, 2026
ff9350d
Test the harness real-hardware approval gate in both harnesses
qquais Sep 28, 2026
c7cd406
Test harness/context.py image pruning; fix stale '4 tools' wording, l…
qquais Sep 28, 2026
fdf2543
Add timelapse/model_trigger.py: Claude behind the scheduler's trigger…
qquais Sep 28, 2026
a737c98
Add .env.example template; README points at it for the API key
qquais Sep 28, 2026
2a89c8b
docs: model trigger verified live on the mock; note the call blocks t…
qquais Sep 28, 2026
cef573d
ci: run on every branch push; verbose test names in the log
qquais Sep 28, 2026
8f9f701
Scheduler: run the trigger consult on a background thread
qquais Sep 28, 2026
4067e53
Open the STOP panel whenever a process connects to the stage
qquais Sep 26, 2026
189cfb1
Add worm_follow: track one C. elegans across the dish with re-centred…
qquais Sep 29, 2026
b1a24b2
Add aml18_survey: find worms, stage and head in an AML18 .nd2 frame
qquais Sep 29, 2026
a6ecc06
aml18_survey: handle time-lapse and Z-stack .nd2 files
qquais Sep 29, 2026
b32ee9c
aml18_survey: find larvae by their neurons; drop dark objects without…
qquais Sep 29, 2026
f39af5c
Add aml18_track: link surveyed worms across time-lapse frames
qquais Sep 29, 2026
b067725
aml18_survey: print worms found by neurons, which have no width
qquais Sep 29, 2026
4553b57
estop: make the panel mutex name a raw string
qquais Sep 30, 2026
02af5b4
Add nis_port_probe: HTTP control of NIS-Elements from a JOBS Python task
qquais Sep 30, 2026
7986799
Add make_soundtrack: original music for time-lapse movies
qquais Sep 30, 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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copy this file to .env (gitignored) and fill in your real key:
# cp .env.example .env
#
# Used by harness/agent.py, harness/mcp_agent.py and
# timelapse/model_trigger.py (loaded via python-dotenv). The MCP server
# itself and the mock-only tests never need it.
ANTHROPIC_API_KEY=sk-ant-...

# Optional: where captures and logs are written (default: current directory).
# CONFOCAL_MCP_DATA_DIR=/path/to/data

# Optional: PNG the mock camera serves for backend="mock" captures.
# CONFOCAL_MOCK_FRAME_PATH=/path/to/frame.png
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
push: # every branch, so a push runs even before a pull request exists
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
# No hardware groups: the mock stage and mock camera need none, so the
# whole suite runs on a plain runner. 'harness' is pure Python and is
# needed to import the Claude loops for the approval-gate tests (no
# API key required - no model is ever called).
- run: pip install -e ".[harness,test]"
- run: python -m pytest -v tests # -v names each test in the log
# The MCP server must import on a core-only install (the camera and
# SDK stacks are lazy imports) - fail the build if that regresses.
# (tests/test_mcp_server.py goes further and actually runs it.)
- run: python -c "import mcp_server.server_loop, timelapse.scheduler"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ eaa_integration/memory/
eaa_integration/*.sqlite
eaa_integration/*.sqlite-shm
eaa_integration/*.sqlite-wal

# Nikon Ti2 SDK documentation, originals and samples extracted from the
# Nikon installers. Licensed with SDK access - kept local, not published.
docs/nikon-sdk/
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,78 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow

## [Unreleased]

### Added
- `analysis/make_soundtrack.py` - original ambient soundtrack synthesised
with numpy (no samples, so no licensing questions), stretched to any
length. `--movie run.mp4` sizes it to the movie and muxes it on as AAC
with the video stream copied, writing `run_music.mp4`. At 39 s it
reproduces the Physarum Short's track exactly.
- `acquisition/calibration/ti2_inventory.py` - read-only inventory of every
device the Ti2 reports, with each one's valid range, unit, and `Control`
value. Field names come from the COM type library at runtime and rows are
grouped by the microscope's own `Control` value, so there is no device list
to keep in sync. `Control` is the practical guide to what can be driven:
`-1` devices (the D-LEDI channels, the DIA/EPI/AUX shutters, Intensilight,
TIRF, LAPP) reliably ignore writes; `>= 0` usually accepts, with measured
exceptions (`iDIC_PRISM`, `iTURRET2SHUTTER`). Note `Enabled` is *not* a
fitted-hardware flag - it reads True for all 88 devices.
- `acquisition/calibration/ti2_config.py` - `save` / `show` / `diff` / `apply`
for the full device configuration from the command line. `apply` requires
`--confirm`, skips stage/objective/TIRF unless `--include-motion`, and polls
the read-back rather than reading once (several devices report the old value
for up to a second after a successful write).
- `get_image(backend=...)`: `backend="mock"` returns a simulated frame via
`MockNIS.capture()` with no camera attached and no `confirm`, so capture
logic can be developed off the microscope PC. `backend="sdk"` (the
default) is unchanged and still requires `confirm=True`. The metadata dict
now carries `backend`. Both harnesses skip the hardware gate for mock
captures.
- `CONFOCAL_MOCK_FRAME_PATH`: PNG the mock capture serves (defaults to the
old `data/analysis/nd2_sample/frame_0.png` location).
- `timelapse/` package (not MCP tools): `change_detector` (model-free
per-frame change score), `frame_audit` (CLI: gaps / intensity jumps /
stage shifts vs. specimen change in an existing sequence), `scheduler`
(adaptive slow/burst acquisition loop with hard caps and a one-time
real-hardware approval), `model_trigger` (Claude behind the scheduler's
trigger hook: extend or end a burst from the before/after frames, with
call caps and fail-safe "no opinion"; runs on a background thread so
burst timing never waits on the model). See `docs/adaptive_timelapse.md`.
- `tests/` (pytest, mock only) and a GitHub Actions CI workflow. Includes a
protocol-level test that spawns `mcp_server.server_loop` as a subprocess,
asserts the exact tool set, and drives every tool over MCP stdio. Also
tests of both harnesses' real-hardware approval gate: no `confirm` in any
model-facing schema, sdk calls stop at the gate and a decline executes
nothing, mock calls never prompt. And tests of `harness/context.py`'s
image pruning.
- `numpy` (2.4.x, the last line that supports Python 3.11) is now a core dependency; new `test` optional group (pytest).

### Fixed
- README and code comments said the MCP surface was 4 tools; it has been 5
since `estop` was added. The README's tool list now includes `estop`.
- `get_optical_configuration()` recorded no illumination state. It walked
`OPTICAL_CONFIG_PROPERTIES`, a hardcoded list that omitted
`iDIA_LAMP_Switch`/`iDIA_LAMP_Pos` entirely - so a saved configuration never
captured whether the transmitted lamp was on, the one setting that decides
whether a camera on the camera port sees anything. The `iDLED*` names it did
list are ignored by this microscope, whose D-LEDI is not driven through the
Ti2 body. Both it and `apply_optical_configuration()` now use the SDK's own
`DataGet`, which returns all ~90 properties in one call, and the hardcoded
list is gone. `apply_optical_configuration()` gains `include_motion=False`:
the snapshot is now the full device set, so without that guard restoring a
lamp setting would also drive the stage.

- Runtime data no longer falls back to an unwritable working directory. MCP
clients choose the working directory their servers are launched with, and
Claude Desktop on Windows uses `C:\WINDOWS\system32` - so with
`CONFOCAL_MCP_DATA_DIR` unset, the first `get_image()` failed with an
"access is denied" `OSError` that read as a camera fault, and under an
elevated process would instead have written captures into a system
directory. `acquisition/paths.py` now rejects a working directory that is
inside the Windows directory or that it cannot create a file in, falling
back to `~/.confocal-mcp` with a warning on stderr. Setting
`CONFOCAL_MCP_DATA_DIR` is still honoured as-is, and a normal source
checkout still resolves to the checkout directory.

## [0.1.0] - 2026-08-31

First packaged release.
Expand Down
66 changes: 56 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ MCP client).

## Tools

Exactly 4, by design - kept deliberately minimal:
Exactly 5, by design - kept deliberately minimal:

- **`get_pos(backend)`** - current stage (x, y, z) position, in microns.
A cheap, on-demand sync primitive, not something to call before every
move (`get_image`/`move` already return position as part of their result).
- **`move(x, y, backend, confirm)`** - move the XY stage to an absolute
position, returns the *actual* resulting position. XY-only, on purpose
(no absolute Z move is reachable from chat - crash risk into the sample).
- **`get_image(confirm, exposure_time_us, gain, crop, max_dimension)`** -
capture a real frame from the Baumer camera, paired with the exact
stage position it was taken at. Returns both the metadata and an
- **`get_image(confirm, exposure_time_us, gain, crop, max_dimension, backend)`** -
capture a frame from the Baumer camera (`backend="sdk"`, the default)
or a simulated one from the mock (`backend="mock"`, no hardware, no
`confirm` needed - set `CONFOCAL_MOCK_FRAME_PATH` to choose the PNG it
serves), paired with the exact stage position it was taken at. Returns both the metadata and an
embedded image preview, so the model can actually see the picture, not
just a file path. `crop` (optional `{"x","y","width","height"}`
fractions of the full frame) restricts the embedded preview to a
Expand All @@ -35,6 +37,12 @@ identifying that specific capture.
the stage to this session, so "where have we already been" doesn't
need to be re-derived from conversation history.

- **`estop(action)`** - emergency stop. `"engage"` forbids all stage motion
for every process on the machine via a flag file; `"status"` reports it.
Needs no `confirm`: a stop is always safe. Release is deliberately not
reachable from chat - a human clears it with
`python -m acquisition.estop release`.

`backend` is `"mock"` (default, safe, simulated) or `"sdk"` (real
hardware - `move()`/`get_image()` require `confirm=True` for anything
that touches real hardware). See `mcp_server/loop_tools.py`'s header
Expand All @@ -45,6 +53,31 @@ No NIS-Elements involved anywhere - the camera is reached directly via
GenICam/GenTL, and the stage via the Ti2 ActiveX SDK, both independent
of whether NIS-Elements software is even running.

## Adaptive time-lapse (`timelapse/`)

Not an MCP tool - a loop that sits beside the tools and calls `get_image()`
itself. It captures on a slow interval, scores each frame for change with
plain numpy (no model call), and switches to a fast burst when something
happens. `timelapse/frame_audit.py` applies the same scores to an existing
frame sequence to tell an acquisition gap, an illumination change or a
stage bump from real specimen change. Design, safety model and status:
[docs/adaptive_timelapse.md](docs/adaptive_timelapse.md).

```
python -m timelapse.scheduler --backend mock --slow 2 --burst 0.5 --burst-duration 5 --max-runtime 30
python -m timelapse.scheduler --backend mock --model-trigger ... # Claude judges each trigger (needs API credentials)
python -m timelapse.frame_audit FRAME_DIR --timestamps times.csv --around 25h --window 1h
```

## Tests

```
pip install -e ".[test]"
python -m pytest -q tests
```

Mock backend only - nothing touches hardware. CI runs the same on every push.

## Install

This is a normal installable package (`confocal-mcp`) with a console entry
Expand Down Expand Up @@ -99,9 +132,22 @@ project-level `.mcp.json` for Claude Code:
{ "mcpServers": { "confocal": { "command": "confocal-mcp" } } }
```

Data (captures, move/frame history logs) is written under the current working
directory when the server runs from an installed package - launch it from a
stable location.
Data (captures, move/frame history logs, saved positions) is written under the
current working directory - so launch the server from a stable location, or set
`CONFOCAL_MCP_DATA_DIR` to pin it explicitly:

```json
{ "mcpServers": { "confocal": {
"command": "confocal-mcp",
"env": { "CONFOCAL_MCP_DATA_DIR": "D:\\path\\to\\your\\data" }
} } }
```

Setting it is worth doing for any MCP client, because the client picks the
working directory and the caller has no say in it - Claude Desktop on Windows
launches servers in `C:\WINDOWS\system32`. When the working directory is
unusable like that, the server falls back to `~/.confocal-mcp` and says so on
stderr rather than failing at the first capture.

## Harness loop (`harness/agent.py`)

Expand All @@ -113,10 +159,10 @@ unaffected either way - use whichever fits: MCP for Claude Desktop/Code,
this loop for a standalone script.

Requires `ANTHROPIC_API_KEY` set - either in a `.env` file at the repo
root (copy the commented-out line in `.env`, fill in your real key; this
file is gitignored and loaded automatically by `harness/agent.py`), as a
root (`cp .env.example .env`, fill in your real key; `.env` is gitignored
and loaded automatically by the harnesses and the model trigger), as a
regular environment variable, or via `ant auth login`. Real hardware
(`backend="sdk"`, or any `get_image` call) always pauses for a live
(`backend="sdk"` on `move` or `get_image`) always pauses for a live
"y/N" approval at the terminal before executing, regardless of what the
model requests - see `harness/agent.py`'s header comment for why. Old
captured images are pruned from the model's context after a couple of
Expand Down
37 changes: 32 additions & 5 deletions acquisition/backends/baumer_genicam.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,25 @@ def get_settings(self) -> dict:
node_map = self._acquirer.remote_device.node_map
exposure = node_map.ExposureTime
gain = node_map.Gain
return {
settings = {
"exposure_time_us": exposure.value,
"exposure_time_range_us": [exposure.min, exposure.max],
"gain": gain.value,
"gain_range": [gain.min, gain.max],
"pixel_format": node_map.PixelFormat.value,
}
# REPORTED BECAUSE IT IS OTHERWISE INVISIBLE STATE. White balance
# persists on the camera across processes, is settable from any
# GenICam consumer, and silently rewrites every captured colour -
# a whole time-lapse was acquired on 2026-09-21 without anyone
# being able to tell from the saved metadata what the colour
# pipeline had done to it. BalanceRatio is not exposed on the
# VCXU-23C, so mode is all there is to record.
try:
settings["balance_white_auto"] = node_map.BalanceWhiteAuto.value
except Exception:
settings["balance_white_auto"] = None
return settings

def set_settings(self, exposure_time_us: float | None = None, gain: float | None = None) -> dict:
"""Set exposure time (microseconds) and/or gain via the GenICam
Expand Down Expand Up @@ -217,10 +229,25 @@ def capture(self) -> Path:
elif pixel_format == "Mono8":
rgb = cv2.cvtColor(data.reshape(h, w), cv2.COLOR_GRAY2RGB)
elif pixel_format.startswith("Bayer"):
# Literal-name match to cv2's code; unverified against a
# known-colour target. If colours look swapped under real
# light, try COLOR_BayerGR/BG/GB2RGB instead.
rgb = cv2.cvtColor(data.reshape(h, w), cv2.COLOR_BayerRG2RGB)
# VERIFIED 2026-09-21 against the camera's OWN internal
# debayer, which is the ground truth available without a
# colour target: capturing the same scene in BGR8 and in
# RGB8 agreed exactly (organism hue 54 deg, saturation
# ~105), while decoding the BayerRG8 stream with
# COLOR_BayerRG2RGB - what this line used to do - gave hue
# 188 deg, very nearly the complement. That is R/B
# inversion, so the correct cv2 code for this body's
# "BayerRG8" is BayerBG2RGB, not BayerRG2RGB.
#
# PREFER BGR8 OVER ANY BAYER FORMAT ON THIS CAMERA. The
# Bayer stream also carries markedly less colour than the
# camera's own debayer of the same scene (channel spread
# 4.99 vs 11.93, saturation 56 vs 105), so it loses real
# information rather than just mislabelling it. The
# PixelFormat is whatever the camera was last left in by
# any GenICam consumer - if someone sets it back to Bayer,
# this branch at least renders the right hue.
rgb = cv2.cvtColor(data.reshape(h, w), cv2.COLOR_BayerBG2RGB)
else:
raise RuntimeError(
f"Unsupported camera PixelFormat {pixel_format!r} - "
Expand Down
12 changes: 10 additions & 2 deletions acquisition/backends/nis_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# nis = MockNIS()
# ------------------------------------------------------------

import os
import shutil
import sys
import time
Expand Down Expand Up @@ -48,8 +49,15 @@

# Sample frame copied by capture() to simulate a real image capture. Falls
# back to a generated placeholder if this isn't present (it usually isn't -
# it's a dev-only asset under the gitignored data/ tree).
SAMPLE_FRAME_PATH = _data_root() / "data" / "analysis" / "nd2_sample" / "frame_0.png"
# it's a dev-only asset under the gitignored data/ tree). Set
# CONFOCAL_MOCK_FRAME_PATH to point at any PNG instead - e.g. a real
# time-lapse frame - so mock captures return something worth analyzing.
# Read at call time (not cached) so a test or a dev script can swap the
# file, or reassign this module attribute, between captures.
SAMPLE_FRAME_PATH = Path(
os.environ.get("CONFOCAL_MOCK_FRAME_PATH")
or _data_root() / "data" / "analysis" / "nd2_sample" / "frame_0.png"
)
CAPTURE_DIR = _captures_dir()


Expand Down
Loading
Loading