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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow
## [Unreleased]

### Added
- `analysis/worm_short.py` - C. elegans Short from an AML18 time-lapse
surveyed by `aml18_survey`: neurons-only opening, brightfield with RFP glow,
trails and a live distance chart for up to three featured worms (auto-picked
adults, named by speed), and every track at once. Original synthesised music.
The speed claim uses the smaller of the raw and median-filtered ratios.
- `analysis/physarum_short.py` - the whole-dish Physarum Short: time-lapse
with a live on-agar area curve, then a wipe to the green new-growth map.
Reuses the combined analysis folder's alignment so frames match what was
measured. Captions are the 23-25 Sep 2026 experiment's.
- `analysis/short_video.py` - shared pieces for vertical YouTube Shorts:
1080x1920 canvas, fonts, centred text, and a writer that pipes frames into
the bundled ffmpeg (libx264, CRF 19) with the WAV soundtrack muxed in the
same pass. `--preview`-style stills instead of a render for layout checks.
- NIS bridge (not yet run inside NIS - see `docs/nis-bridge.md`):
`acquisition/nis_bridge/bridge_job.py`, a JOBS Python task that serves NIS
macro calls (status, relative XY move, 4x/10x objective change, ND2
capture, saved ND experiment run/finish) on 127.0.0.1:8766;
`acquisition/backends/nis_bridge.py`, its client and command line; and
`mcp_server/server_nis.py` (`confocal-mcp-nis`), a separate MCP server
with confirm-gated tools. The e-stop is enforced in both client and
bridge; `server_loop` is unchanged.
- `acquisition/calibration/nis_port_probe.py` - minimal JOBS task showing
that a port bound inside `nis_ar.exe` is reachable from outside.
- `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
Expand Down Expand Up @@ -77,6 +100,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow
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.
- The `analysis/` scripts imported `scipy`, `scikit-image`, `nd2`,
`imageio-ffmpeg` and `opencv-python`, none of which the package declared, so
a fresh install could not run them. They are now the `analysis` optional
group, which `all` (and so `requirements.txt`) includes.
- `python -m acquisition.orchestration.stage_positions` ended by loading
`protocols/example_protocol.yaml`, which is not in this repo, so the demo
always failed. That step is removed. Code comments that pointed to files in
ConfocalOrchestrator (`docs/microscope-notes.md`, `run_protocol.py`, the
calibration tests) now say so, and those citing old names
(`mcp_server/server.py`, `acquisition_tools.py`) use the current ones.

## [0.1.0] - 2026-08-31

Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ python -m timelapse.scheduler --backend mock --model-trigger ... # Claude judg
python -m timelapse.frame_audit FRAME_DIR --timestamps times.csv --around 25h --window 1h
```

## NIS bridge (`confocal-mcp-nis`) - not yet tested on the scope

A separate MCP server for what only NIS-Elements can do: the AX confocal
lasers and saved ND experiments. A JOBS Python task inside NIS
(`acquisition/nis_bridge/bridge_job.py`) serves NIS macro calls on
`127.0.0.1:8766`. The `confocal-mcp-nis` server talks to it and adds a
confirm gate, the e-stop and a capture-folder rule. `server_loop` is
unchanged. Setup, safety rules and the on-scope test plan:
[docs/nis-bridge.md](docs/nis-bridge.md).

```
python -m acquisition.backends.nis_bridge status # by hand, while the bridge job runs
confocal-mcp-nis # the MCP server
```

## Tests

```
Expand All @@ -85,10 +100,11 @@ point. Dependencies are split into groups so a machine only pulls what it needs:

| Group | Pulls in | For |
|---|---|---|
| *(core)* | `mcp`, `Pillow`, `PyYAML` | the MCP server against the **mock** stage |
| *(core)* | `mcp`, `Pillow`, `PyYAML`, `numpy` | the MCP server against the **mock** stage |
| `camera` | `harvesters`, `genicam`, `opencv-python` | real Baumer camera capture |
| `sdk` | `pywin32` | real Ti2 stage control (Windows) |
| `harness` | `anthropic`, `python-dotenv` | the standalone Claude loops in `harness/` |
| `analysis` | `scipy`, `scikit-image`, `nd2`, `imageio-ffmpeg`, `opencv-python` | the offline scripts in `analysis/` (source checkout only) |
| `all` | everything above | a full workstation |

The real hardware backends are imported lazily, so a core-only install runs
Expand Down
2 changes: 1 addition & 1 deletion acquisition/backends/baumer_genicam.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# (Baumer Camera Explorer + Toshiba Teli GenICam SDK). Originally added
# just to exercise the rest of the pipeline (focus_check, dashboard)
# against real frames while N-SPARC capture was blocked on NIS-Elements
# licensing/Jobs work (see docs/microscope-notes.md's "Image Capture"
# licensing/Jobs work (see ConfocalOrchestrator's docs/microscope-notes.md, "Image Capture"
# investigation for that history).
#
# 2026-08-17: now the PRIMARY capture path by deliberate decision - the
Expand Down
128 changes: 128 additions & 0 deletions acquisition/backends/nis_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# nis_bridge.py
# ------------------------------------------------------------
# Client for the NIS bridge (acquisition/nis_bridge/bridge_job.py), the
# JOBS task that exposes NIS-Elements macro calls on 127.0.0.1:8766 while
# it runs. Used by the NIS MCP server; also a command line for testing by
# hand:
#
# python -m acquisition.backends.nis_bridge status
# python -m acquisition.backends.nis_bridge move 100 0
# python -m acquisition.backends.nis_bridge objective "Plan Fluor 10x Ph1 DLL"
# python -m acquisition.backends.nis_bridge capture D:\...\check.nd2
# python -m acquisition.backends.nis_bridge nd-run "C. elegans 3h"
# python -m acquisition.backends.nis_bridge nd-finish
# python -m acquisition.backends.nis_bridge shutdown
#
# The e-stop is checked HERE as well as in the bridge: a refused request
# never leaves this process, so a stopped microscope does not depend on
# the NIS side having been updated. Stopping an ND run (nd-finish) and
# shutting the bridge down are always allowed - they only end things.
#
# CONFOCAL_NIS_BRIDGE_URL overrides the address (default
# http://127.0.0.1:8766).
# ------------------------------------------------------------

from __future__ import annotations

import argparse
import json
import os
import urllib.error
import urllib.request

from acquisition import estop

DEFAULT_URL = "http://127.0.0.1:8766"


class BridgeUnavailable(RuntimeError):
"""The bridge job is not running (or not reachable)."""


class NISBridge:
def __init__(self, url: str | None = None, timeout_s: float = 30.0):
self.url = (url or os.environ.get("CONFOCAL_NIS_BRIDGE_URL") or DEFAULT_URL).rstrip("/")
self.timeout_s = timeout_s

def _call(self, method: str, path: str, body: dict | None = None,
timeout_s: float | None = None) -> dict:
data = json.dumps(body or {}).encode() if method == "POST" else None
req = urllib.request.Request(self.url + path, data=data, method=method,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout_s or self.timeout_s) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
try:
return {"http_status": e.code, **json.loads(e.read())}
except ValueError:
return {"http_status": e.code, "error": str(e)}
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
raise BridgeUnavailable(
f"NIS bridge not reachable at {self.url} ({e}). Is the bridge job "
"running in NIS-Elements? See docs/nis-bridge.md.") from e

# Read-only
def status(self) -> dict:
return self._call("GET", "/status")

# Motion / acquisition: e-stop checked before anything is sent
def move_relative(self, dx_um: float, dy_um: float) -> dict:
estop.check()
return self._call("POST", "/move", {"dx": dx_um, "dy": dy_um})

def change_objective(self, name: str) -> dict:
estop.check()
return self._call("POST", "/objective", {"name": name})

def capture(self, path: str) -> dict:
estop.check()
return self._call("POST", "/capture", {"path": path}, timeout_s=300)

def nd_run(self, experiment: str, timeout_s: float = 7200.0) -> dict:
# ND_RunExperiment may block until the run ends (unverified), so
# the answer can take as long as the run itself.
estop.check()
return self._call("POST", "/nd/run", {"experiment": experiment}, timeout_s=timeout_s)

# Always allowed: these only end things
def nd_finish(self) -> dict:
return self._call("POST", "/nd/finish")

def shutdown(self) -> dict:
return self._call("POST", "/shutdown")


def main() -> None:
ap = argparse.ArgumentParser(description="Talk to the NIS bridge job by hand.")
ap.add_argument("--url", help=f"bridge address (default {DEFAULT_URL})")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("status")
m = sub.add_parser("move", help="relative XY move in um (max 1000 per axis)")
m.add_argument("dx", type=float); m.add_argument("dy", type=float)
o = sub.add_parser("objective", help="exact name as listed by status (4x/10x only)")
o.add_argument("name")
c = sub.add_parser("capture", help="capture one image and save it as .nd2")
c.add_argument("path")
r = sub.add_parser("nd-run", help="load a saved ND experiment by name and run it")
r.add_argument("experiment")
sub.add_parser("nd-finish", help="end the running ND experiment after this loop")
sub.add_parser("shutdown", help="stop the bridge job")
a = ap.parse_args()

b = NISBridge(a.url)
try:
out = {"status": lambda: b.status(),
"move": lambda: b.move_relative(a.dx, a.dy),
"objective": lambda: b.change_objective(a.name),
"capture": lambda: b.capture(a.path),
"nd-run": lambda: b.nd_run(a.experiment),
"nd-finish": lambda: b.nd_finish(),
"shutdown": lambda: b.shutdown()}[a.cmd]()
except (estop.EStopEngaged, BridgeUnavailable) as e:
raise SystemExit(str(e))
print(json.dumps(out, indent=2, ensure_ascii=False))


if __name__ == "__main__":
main()
16 changes: 8 additions & 8 deletions acquisition/backends/nis_mock.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# nis_mock.py
# ------------------------------------------------------------
# Mock simulator for the NIS-Elements Python API ('nis' module), so
# acquisition scripts (stage_positions.py, run_protocol.py, etc.) can be
# acquisition scripts (stage_positions.py here, ConfocalOrchestrator's run_protocol.py) can be
# developed and tested off the microscope PC.
#
# The real `nis` module only exists inside the NIS-Elements Python
# environment on the microscope PC (see nis_jobs_connection_test.py). This mock
# environment on the microscope PC (see ConfocalOrchestrator's nis_jobs_connection_test.py). This mock
# reproduces the small subset of that API used by ConfocalOrchestrator -
# XY/Z stage position, movement, and abort checks - as plain in-memory
# state, so it can run anywhere.
Expand All @@ -26,7 +26,7 @@

from acquisition.paths import captures_dir as _captures_dir, data_root as _data_root

# Stage travel limits from the Nikon Ti2-E spec (see docs/microscope-notes.md),
# Stage travel limits from the Nikon Ti2-E spec (stroke X +/-57 mm, Y +/-36.5 mm),
# converted from mm to microns to match the units used by the NIS API.
X_LIMIT_UM = 57_000.0
Y_LIMIT_UM = 36_500.0
Expand All @@ -41,7 +41,7 @@
MAX_Z_STEP_UM = 50.0

# Realistic movement delay for XY_Move, based on the Ti2-E's documented max
# XY stage speed (docs/microscope-notes.md: "Max speed: 25mm/sec"). The
# XY stage speed (Nikon Ti2-E spec: "Max speed: 25mm/sec"). The
# focus (Z) drive's speed isn't documented, so Z_Move uses a small fixed
# placeholder delay instead of a physics-based one.
XY_MAX_SPEED_UM_PER_SEC = 25_000.0
Expand All @@ -64,7 +64,7 @@
class _MockContext:
"""Mock of the NIS-Elements Jobs 'ctx' context object - only the
abort-check method ConfocalOrchestrator actually uses (see
run_protocol.py's should_abort(), which calls ctx.shouldAbort()).
ConfocalOrchestrator's run_protocol.py should_abort(), which calls ctx.shouldAbort()).
Always reports False - there is no UI to click Abort from in the mock.
"""

Expand All @@ -82,12 +82,12 @@ class MockNIS:

Also exposes capture() (simulated image capture) and .ctx.shouldAbort()
(mock Jobs context), for parity with the rest of the real API surface
ConfocalOrchestrator's acquisition scripts use - see run_protocol.py.
ConfocalOrchestrator's acquisition scripts use - see its run_protocol.py.

NOTE on capture(): unlike XY_Move/Z_Move/XY_GetPosition/Z_GetPosition
(all confirmed against docs/microscope-notes.md's documented API),
(all confirmed against the API documented in ConfocalOrchestrator's docs/microscope-notes.md),
the real capture function's name and signature are NOT confirmed yet -
run_protocol.py's capture_image() flags this as a TODO and guesses
ConfocalOrchestrator's run_protocol.py capture_image() flags this as a TODO and guesses
`nis.Capture()` as a placeholder. `capture()` here is written to match
this project's explicit spec for the mock, not a confirmed real
signature - expect to rename/adjust it once the real one is confirmed.
Expand Down
18 changes: 9 additions & 9 deletions acquisition/backends/nis_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,31 @@
# Ti2 ActiveX SDK backend for stage control - real hardware via
# win32com.client.Dispatch(NkTi2Ax.NikonTi2AxAutoConnectMicroscope.CLSID),
# the same connection pattern confirmed working in
# acquisition/calibration/nikon_connection_test.py against the Ti2-E Device Simulator.
# ConfocalOrchestrator's acquisition/calibration/nikon_connection_test.py against the Ti2-E Device Simulator.
#
# ConfocalOrchestrator has two stage-control backends, both exposing the
# same shape of interface so orchestration/stage_positions.py can swap
# between them via its `backend` parameter - see nis_mock.py for the
# other one ("mock").
# This is the "sdk" backend: direct ActiveX bindings, now that Nikon has
# approved SDK access (see docs/microscope-notes.md's "SDK Status") -
# approved SDK access (see ConfocalOrchestrator's docs/microscope-notes.md, "SDK Status") -
# confirmed end-to-end against the Ti2-E Device Simulator, 2026-07-27.
#
# CONFIRMED PROPERTIES (from .venv/Lib/site-packages/NkTi2Ax.py, the
# generated bindings for the SDK's own type library - the same file that
# defines iTURRET1POS/Turret1Pos, confirmed working in calibration/nikon_connection_test.py):
# defines iTURRET1POS/Turret1Pos, confirmed working in ConfocalOrchestrator's calibration/nikon_connection_test.py):
# iXPOSITION / iYPOSITION / iZPOSITION - direct properties, readable and
# writable, same shape as iTURRET1POS.
# XPosition / YPosition / ZPosition - child settings objects (.Value/
# .Lower/.Higher), same shape as Turret1Pos. Read-verified against the
# Ti2-E Device Simulator via acquisition/calibration/nikon_stage_test.py -
# Ti2-E Device Simulator via ConfocalOrchestrator's acquisition/calibration/nikon_stage_test.py -
# both forms returned identical values.
#
# UNITS (inferred, not stated anywhere explicit - the bindings just
# declare a plain integer VARIANT, no unit metadata): cross-referencing

# the simulator's reported Lower/Higher travel limits against
# docs/microscope-notes.md's documented hardware spec ("Stroke X:
# the Nikon Ti2-E hardware spec ("Stroke X:
# +/-57mm, Y: +/-36.5mm ... Focusing: min increment 0.01um, 10mm stroke"):
# X: Lower/Higher = +/-570000 -> 0.1um/count exactly reproduces +/-57mm
# Z: Lower/Higher = 0..1000000 -> 0.01um/count exactly reproduces the
Expand All @@ -41,7 +41,7 @@
# So: X/Y properties are in units of 0.1um ("decimicrons"), Z is in units
# of 0.01um ("centimicrons"). XY_GetPosition/XY_Move/Z_GetPosition/Z_Move
# below convert to/from plain microns at their boundary so callers
# (StagePositionManager, run_protocol.py) never see raw counts.
# (StagePositionManager, mcp_server/loop_tools.py) never see raw counts.
# ------------------------------------------------------------

import queue
Expand Down Expand Up @@ -92,7 +92,7 @@
def to_plain_float(value) -> float:
"""Convert a numpy scalar (or anything float-like) to a plain Python float.

Matches the same convention used in calibration/nis_jobs_connection_test.py/orchestration/stage_positions.py -
Matches the same convention used in orchestration/stage_positions.py (and ConfocalOrchestrator's nis_jobs_connection_test.py) -
values passed to a COM property setter must be plain Python numbers,
not numpy types.
"""
Expand Down Expand Up @@ -170,7 +170,7 @@ class NISSdk:

Every instance shares the same underlying COM connection (see
_ComThread above) - constructing NISSdk() repeatedly (once per MCP
tool call, as acquisition_tools.py does) is cheap and does not open a
tool call, as mcp_server/loop_tools.py does) is cheap and does not open a
new connection each time.
"""

Expand Down Expand Up @@ -351,7 +351,7 @@ def nudge_pfs_offset(self, delta_counts: float) -> dict:
mode, a different property/method entirely, or there may be an
interlock not exposed by NkTi2Ax's type library. Confirm the
correct procedure with Nikon's SDK docs or a Ti2 SDK-experienced
contact (see docs/microscope-notes.md) before relying on this -
contact (see ConfocalOrchestrator's docs/microscope-notes.md) before relying on this -
do not attempt to fix by further trial-and-error against real
hardware. Failure mode observed so far is safe (no motion, no
error) - not a functional feature yet, but not a hazard either.
Expand Down
Empty file.
Loading
Loading