diff --git a/software/control/_def.py b/software/control/_def.py index a122b8ea1..9000684cd 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -1277,6 +1277,14 @@ def _validate_objective_changer_flags(use_xeryon: bool, use_turret: bool) -> Non CONTROL_SERVER_PORT = 5050 ANTHROPIC_API_KEY = None # Set via GUI (Settings > Set Anthropic API Key...) +# Core Service (REST + SSE API) settings; overridable via [CORE_SERVICE] in the machine INI +CORE_SERVICE_ENABLED = True +CORE_SERVICE_HOST = "127.0.0.1" +CORE_SERVICE_PORT = 8060 +CORE_SERVICE_AUTH_ENABLED = False +CORE_SERVICE_AUTH_TOKEN = "" +CORE_SERVICE_METHODS_DIR = "machine_configs/acquisition_methods" + # Slack Notifications - send real-time notifications during acquisition class SlackNotifications: @@ -1443,6 +1451,34 @@ class SlackNotifications: except Exception as e: log.warning(f"Failed to load Views settings from config: {e}") + # Load Core Service settings from config file + try: + _core_service_config = ConfigParser() + _core_service_config.read(CACHED_CONFIG_FILE_PATH) + if _core_service_config.has_section("CORE_SERVICE"): + if _core_service_config.has_option("CORE_SERVICE", "enabled"): + CORE_SERVICE_ENABLED = _core_service_config.get("CORE_SERVICE", "enabled").lower() in ( + "true", + "1", + "yes", + ) + if _core_service_config.has_option("CORE_SERVICE", "host"): + CORE_SERVICE_HOST = _core_service_config.get("CORE_SERVICE", "host") + if _core_service_config.has_option("CORE_SERVICE", "port"): + CORE_SERVICE_PORT = _core_service_config.getint("CORE_SERVICE", "port") + if _core_service_config.has_option("CORE_SERVICE", "auth_enabled"): + CORE_SERVICE_AUTH_ENABLED = _core_service_config.get("CORE_SERVICE", "auth_enabled").lower() in ( + "true", + "1", + "yes", + ) + if _core_service_config.has_option("CORE_SERVICE", "auth_token"): + CORE_SERVICE_AUTH_TOKEN = _core_service_config.get("CORE_SERVICE", "auth_token") + if _core_service_config.has_option("CORE_SERVICE", "methods_dir"): + CORE_SERVICE_METHODS_DIR = _core_service_config.get("CORE_SERVICE", "methods_dir") + except Exception as e: + log.warning(f"Failed to load Core Service settings from config: {e}") + # Load GENERAL settings from config file try: _general_config = ConfigParser() diff --git a/software/control/acquisition_yaml_loader.py b/software/control/acquisition_yaml_loader.py index e0e81ae25..d8b80aebe 100644 --- a/software/control/acquisition_yaml_loader.py +++ b/software/control/acquisition_yaml_loader.py @@ -42,6 +42,10 @@ class AcquisitionYAMLData: overlap_percent: float = 10.0 scan_shape: Optional[str] = None wellplate_regions: Optional[List[Dict]] = None # [{name, center_mm, shape}, ...] + # Wells-by-name (additive): X/Y derived from the plate definition at run time. + # Mutually exclusive with wellplate_regions. Normalized to a comma-separated string + # ("A1:B3" or "A1,B2,C3"); None when the method uses explicit regions instead. + wells: Optional[str] = None # Flexible-specific nx: int = 1 @@ -108,6 +112,21 @@ def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData: if wellplate_regions and len(wellplate_regions) > 0: scan_shape = wellplate_regions[0].get("shape") + # Wells-by-name (additive): accept a string ("A1:B3", "A1,B2") or a YAML list of + # names (joined with ","). Normalize empty/absent to None. Mutually exclusive with + # a non-empty regions list. + wells_raw = wellplate_scan.get("wells") + if isinstance(wells_raw, (list, tuple)): + wells = ",".join(str(w).strip() for w in wells_raw) + elif wells_raw is not None: + wells = str(wells_raw).strip() + else: + wells = None + if not wells: + wells = None + if wells and wellplate_regions: + raise ValueError("wellplate_scan: specify either 'wells' or 'regions', not both") + return AcquisitionYAMLData( widget_type=widget_type, xy_mode=acq.get("xy_mode", "Select Wells"), @@ -134,6 +153,7 @@ def parse_acquisition_yaml(file_path: str) -> AcquisitionYAMLData: overlap_percent=overlap, scan_shape=scan_shape, wellplate_regions=wellplate_regions, + wells=wells, # Flexible-specific nx=flexible_scan.get("nx", 1), ny=flexible_scan.get("ny", 1), diff --git a/software/control/microscope_control_server.py b/software/control/microscope_control_server.py index 3210f13e4..e4a46cb66 100644 --- a/software/control/microscope_control_server.py +++ b/software/control/microscope_control_server.py @@ -1,4 +1,8 @@ """ +DEPRECATED: superseded by the Squid Core Service REST API (squid_service/, port 8060) +and the rewritten MCP bridge. Kept for one release for external TCP clients. +See docs/core-service-api.md. No new commands should be added here. + TCP Control Server for Squid Microscope This module provides a TCP socket server that runs inside the GUI process, diff --git a/software/docs/automation.md b/software/docs/automation.md index 17f3dab26..08d9710f3 100644 --- a/software/docs/automation.md +++ b/software/docs/automation.md @@ -1,26 +1,33 @@ # Automated Acquisition via Scripts -This document describes how to run automated acquisitions using the `run_acquisition.py` script. This approach is ideal for batch processing, CI pipelines, or headless operation. +This document describes how to run automated acquisitions using the `run_acquisition.py` script, or directly via the REST API. This approach is ideal for batch processing, CI pipelines, or headless operation. -For AI-assisted control via Claude Code, see [MCP Integration](mcp_integration.md). +**New here?** The [API Quickstart](quickstart-api.md) is the fastest way in — three ways to launch a scan, copy-paste examples, verified against the simulator. + +For AI-assisted control via Claude Code, see [MCP Integration](mcp_integration.md). For the full REST API reference (endpoints, faults, jobs, SSE), see [Core Service API](core-service-api.md). ## Overview The automation workflow: -1. Configure and save an acquisition in the GUI (creates `acquisition.yaml`) -2. Run the acquisition programmatically using the saved YAML +1. Configure and save an acquisition in the GUI (creates `acquisition.yaml`), or define a named server-side method under `machine_configs/acquisition_methods/` +2. Run the acquisition programmatically using the saved YAML (or method name) via the REST API 3. Optionally override parameters like wells or save location -**Note:** Only wellplate mode acquisitions are supported via scripting. FlexibleMultiPoint acquisitions must be run from the GUI. +**Note:** Only wellplate mode acquisitions are supported via scripting/the REST API. FlexibleMultiPoint acquisitions must be run from the GUI. ## Prerequisites - Squid software installed and configured -- Python environment with Squid dependencies +- Python environment with Squid dependencies (includes `httpx`) ## Enabling the Control Server -The TCP control server must be running to accept commands. +The Squid GUI process serves the automation API on two ports: + +- **REST API (port 8060)** — the current API; used by `run_acquisition.py`, the MCP bridge, and any `curl`/`httpx` client. See [Core Service API](core-service-api.md). +- **Legacy TCP control server (port 5050)** — newline-delimited JSON protocol; **deprecated**, kept only for backward compatibility with older integrations. + +Both start together. **Option 1: Via command line (recommended for automation)** ```bash @@ -30,6 +37,21 @@ python3 main_hcs.py --start-server **Option 2: Via GUI** - Go to Settings and check "Enable MCP Control Server" +### curl quick-start + +```bash +# Is the service alive? +curl http://127.0.0.1:8060/v1/healthz + +# Instrument state, active job, latest fault +curl http://127.0.0.1:8060/v1/system/status + +# Start an acquisition from a saved YAML (returns 202 + job handle) +curl -X POST http://127.0.0.1:8060/v1/acquisitions \ + -H "Content-Type: application/json" \ + -d '{"yaml_path": "/path/to/acquisition.yaml"}' +``` + ## Basic Usage ### Run an acquisition @@ -37,15 +59,23 @@ python3 main_hcs.py --start-server python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --wait ``` +### Run from a named server-side method +```bash +python scripts/run_acquisition.py --method my_method --wait +``` + ### Run in simulation mode ```bash python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --simulation --wait ``` -### Validate YAML without running (dry run) +### Validate against the live instrument without running (dry run) ```bash -python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --dry-run +python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --no-launch --dry-run ``` +This runs the server-side preflight checks (`POST /v1/acquisitions/preflight`) — YAML parsing, widget type, +hardware match, channel names, regions, and output path — without starting the acquisition. It requires a +reachable server (launch the GUI first, or omit `--no-launch` to let the script launch it). ## Parameter Overrides @@ -74,23 +104,33 @@ python scripts/run_acquisition.py --yaml acquisition.yaml --no-launch --wait ### Custom host/port ```bash -python scripts/run_acquisition.py --yaml acquisition.yaml --host 192.168.1.100 --port 5050 +python scripts/run_acquisition.py --yaml acquisition.yaml --host 192.168.1.100 --port 8060 +``` + +Non-loopback binds **require** authentication (the service refuses to start without `auth_enabled=true` + +`auth_token`; see [Core Service API — Authentication](core-service-api.md#authentication)). The script reads +the bearer token from the `SQUID_API_TOKEN` environment variable and sends it on every request: + +```bash +SQUID_API_TOKEN=your-token python scripts/run_acquisition.py --yaml acquisition.yaml --host 192.168.1.100 --port 8060 ``` ## Command Line Options | Option | Description | |--------|-------------| -| `--yaml`, `-y` | Path to acquisition.yaml file (required) | +| `--yaml`, `-y` | Path to acquisition.yaml file (exactly one of `--yaml`/`--method` required) | +| `--method` | Name of a server-side acquisition method under `machine_configs/acquisition_methods/` (alternative to `--yaml`) | | `--wells`, `-w` | Override wells from YAML (e.g., 'A1:B3' or 'A1,A2,B1') | | `--base-path` | Override save location | | `--simulation` | Run in simulation mode (no hardware) | | `--wait` | Wait for acquisition to complete | +| `--timeout` | Acquisition timeout in seconds (only with `--wait`) | | `--no-launch` | Don't launch GUI, connect to existing one | -| `--dry-run` | Validate YAML without running | +| `--dry-run` | Run server-side preflight checks only; don't start the acquisition | | `--verbose`, `-v` | Show detailed output | -| `--host` | Server host (default: localhost) | -| `--port` | Server port (default: 5050) | +| `--host` | REST API host (default: 127.0.0.1) | +| `--port` | REST API port (default: 8060) | ## Exit Codes @@ -148,11 +188,11 @@ jobs: ### "Control server did not become available" - Ensure the GUI is running with `--start-server` flag - Or enable via Settings → Enable MCP Control Server -- Check that port 5050 is not blocked +- Check that port 8060 (REST API) is not blocked -### "TCP command only supports wellplate mode" +### "Only wellplate-mode YAMLs are supported by the API" - The YAML was saved from FlexibleMultiPoint mode -- Re-save the acquisition using wellplate mode, or run from GUI +- FlexibleMultiPoint acquisitions must be run from the GUI, not via the script/REST API ### "Hardware configuration mismatch" - The current objective or camera binning differs from when YAML was saved @@ -162,7 +202,13 @@ jobs: - The script will retry up to 10 consecutive errors before failing - Check network connectivity and GUI status +### 401 Unauthorized +- Auth is only required when the server is bound to a non-loopback host; see + [Core Service API — Authentication](core-service-api.md#authentication) +- Pass a token with `-H "Authorization: Bearer "` (curl) or set `SQUID_API_TOKEN` (MCP bridge) + ## See Also +- [Core Service API](core-service-api.md) - Full REST API reference (endpoints, faults, jobs, SSE) - [MCP Integration](mcp_integration.md) - Control via Claude Code / AI agents - [Configuration System](configuration-system.md) - Setting up imaging channels and profiles diff --git a/software/docs/core-service-api.md b/software/docs/core-service-api.md new file mode 100644 index 000000000..021638503 --- /dev/null +++ b/software/docs/core-service-api.md @@ -0,0 +1,394 @@ +# Core Service API + +The Squid GUI process embeds a REST + Server-Sent-Events API (`squid_service`) for programmatic control: +starting/monitoring acquisitions, moving the stage, selecting channels, running autofocus, and more. This +is the API used by [`scripts/run_acquisition.py`](../scripts/run_acquisition.py) and the +[MCP bridge](mcp_integration.md); it can also be driven directly with `curl`/`httpx`/any HTTP client. + +> **New here? Start with the [API Quickstart](quickstart-api.md)** — the 5-minute, +> copy-paste version. This document is the complete reference. + +## Overview + +- **Base URL:** `http://127.0.0.1:8060` by default (see [Configuration](#configuration) to change host/port) +- **Interactive docs:** `GET /docs` (Swagger UI) and `GET /openapi.json` are always available, unauthenticated-or-not per the auth setting +- **Transport:** HTTP/1.1 + JSON only — there is no TLS termination built in; put a reverse proxy in front if you need it on a non-loopback network +- **Versioning:** all routes are prefixed `/v1/...`, except `GET /healthz` (unversioned, always open) +- **Two ways to serve it:** inside the GUI process (`main_hcs.py --start-server`, or the Settings toggle) or + GUI-free via `python3 main_headless.py [--simulation]` — same API, same configuration. Headless mode stops on + SIGINT/SIGTERM, aborting any in-flight acquisition before closing the hardware; there is no live view. + +### Deviations from the (aspirational) spec + +This implementation intentionally deviates from the abstract Core Service spec in a few places: + +1. **Acquisition source is `yaml_path`, `method`, or `grid` (exactly one), not just a method name.** The spec + envisions purely named methods; this implementation additionally accepts a filesystem path to an + `acquisition.yaml` (as saved by the GUI) or an inline grid-scan spec, for backward compatibility with the + existing GUI-driven workflow. See [Starting an acquisition](#starting-an-acquisition). +2. **Auth is off by default on loopback binds.** The spec assumes auth-on-by-default; here, a server bound to + `127.0.0.1`/`localhost` may run without a bearer token (matching the previous TCP server's trust model). + Binding to any non-loopback host makes `auth_enabled=true` + a non-empty `auth_token` **mandatory** — the + server refuses to start otherwise. See [Authentication](#authentication). +3. **HTTP only, no built-in TLS.** +4. **Several endpoints intentionally return `501 Not Implemented`** rather than being unimplemented-by-omission, + so the route shape and the compliance gap are both visible in the OpenAPI schema. See + [Deferred endpoints](#deferred-endpoints-501). + +## Endpoint reference + +Every non-2xx response body is `{"error": }` — see [Fault model](#fault-model). + +### Meta + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/v1/healthz` | open | Liveness check: `{"alive": true}` | +| GET | `/v1/sample_formats` | | List every known wellplate/sample format and its layout (URS API-LAB-001) | + +### System + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| POST | `/v1/system/initialize` | `{"home": bool}` (optional) | Initialize the instrument, optionally homing all axes | +| POST | `/v1/system/reset` | | Reset from an error/recovering state | +| GET | `/v1/system/status` | | Instrument state, active job id, latest fault, last-acquisition summary | +| GET | `/v1/system/heartbeat` | | `{"alive", "monotonic_ns", "state"}` — cheap, in-process, no MCU round-trip | +| GET | `/v1/system/capabilities` | | Channels, objectives, stage travel ranges, camera info, simulation flag | +| GET | `/v1/system/version` | | Software/API/firmware version strings | +| GET | `/v1/system/auth_status` | open | `{"auth_enabled", "bind_to_tls", "scheme"}` | +| GET | `/v1/system/faults?since=&limit=` | | Fault history (monotonic `sequence` cursor) | +| POST | `/v1/system/reserve` | | **501** — see [Deferred endpoints](#deferred-endpoints-501) | +| POST | `/v1/system/release` | | **501** | +| POST | `/v1/system/shutdown` | | **501** | + +### Motion + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| GET | `/v1/motion/position` | | Current XYZ stage position (mm) | +| POST | `/v1/motion/move` | `MoveRequest{mode: absolute\|relative, x, y, z, block_until_complete}` | Move the stage | +| POST | `/v1/motion/home` | | Home all axes | + +### Imaging + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| GET | `/v1/imaging/channels` | | Channels available for the current objective | +| POST | `/v1/imaging/channel` | `{"name": str}` | Select the active channel | +| POST | `/v1/imaging/exposure` | `{"exposure_ms": float, "channel": str?}` | Set exposure time | +| POST | `/v1/imaging/intensity` | `{"channel": str, "intensity": float}` | Set illumination intensity (0-100%) | +| POST | `/v1/imaging/illumination/on` \| `/off` | | Toggle illumination | +| GET | `/v1/imaging/objectives` | | List objectives + current selection | +| GET \| POST | `/v1/imaging/objective` | `{"name": str}` (POST) | Get/set the current objective | +| POST | `/v1/imaging/acquire` | `{"channel": str?, "save_path": str?}` | Capture a single image | +| POST | `/v1/imaging/live/start` \| `/stop` | | Toggle live camera streaming | + +### Autofocus + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| POST | `/v1/autofocus/run` | `{"mode": "reflection", "target_um": float}` | Run reflection (laser) autofocus | +| GET | `/v1/autofocus/status` | | Hardware/reference readiness | +| POST | `/v1/autofocus/store_reference` | | Capture the current laser spot as the new reference | +| POST | `/v1/autofocus/correct` | `{"threshold_um": float}` | Apply a correction if drift exceeds the threshold | +| POST | `/v1/autofocus/acquire_image` | `{"save_path": str?, "use_last_frame": bool}` | Grab a laser-AF camera frame | + +### Acquisitions & Jobs + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| POST | `/v1/acquisitions/preflight` | `AcquisitionRequest` | Run validation checks without starting anything | +| POST | `/v1/acquisitions` | `AcquisitionRequest` | Start an acquisition. **202** + `Location: /v1/jobs/{id}` on acceptance | +| GET | `/v1/jobs/last` | | Most recent job | +| GET | `/v1/jobs/{job_id}` | | Job record: state, progress, result, outcome | +| POST | `/v1/jobs/{job_id}/abort` | `{"timeout_s": float}` (optional) | Gracefully abort a running job | +| POST | `/v1/jobs/{job_id}/emergency_stop` | | **501** | + +`AcquisitionRequest` requires **exactly one** of `method`, `yaml_path`, `grid`, plus optional +`experiment_id`, `operator`, `scheduler_job_id`, `autofocus` overrides, and `overrides` +(`wells`, `output_path`, `sample_format`). See [Starting an acquisition](#starting-an-acquisition). + +### Methods + +Named, server-side acquisition configurations (URS API-METH-001..005) — see [Method registry](#method-registry) below. + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| GET | `/v1/methods` | | List all methods with a summary (channels, objective, nz/nt, ...) | +| GET | `/v1/methods/{name}` | | Full method config | +| POST | `/v1/methods` | `{"name": str, "config": dict}` | Create a method. **201** | +| PUT | `/v1/methods/{name}` | `{"config": dict}` | Update (overwrite) a method | +| DELETE | `/v1/methods/{name}` | | Delete a method (rejected while an acquisition is running) | +| POST | `/v1/methods/{name}/validate` | | Run preflight-style checks against a stored method | + +### Debug + +| Method | Path | Body | Description | +|--------|------|------|-------------| +| POST | `/v1/debug/python_exec` | `{"code": str}` | Execute Python with microscope objects in scope. **403** when disabled | +| GET | `/v1/debug/python_exec/status` | | `{"enabled": bool}` | +| GET | `/v1/debug/settings` | | `{"performance_mode", "save_downsampled_well_images", "display_mosaic_view"}` | +| POST | `/v1/debug/settings` | any subset of the above, all optional | Update one or more debug settings | + +`python_exec` is **not sandboxed**. It is gated by the GUI opt-in toggle (**Settings → Enable MCP Python +Exec**) and is intended only for loopback binds — do not expose it on a non-loopback network. + +`GET /v1/debug/settings` has no `display_plate_view` field: the legacy `DISPLAY_PLATE_VIEW` flag it used to +report no longer exists (plate view was unified into the mosaic view / `UnifiedMosaicWidget`, governed +solely by `display_mosaic_view`). `performance_mode` is `null` when no GUI is attached (headless service). + +### Events + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v1/events` | Server-Sent-Events stream — see [SSE](#server-sent-events) | + +## Fault model + +Every non-2xx response body has the shape `{"error": Fault}`: + +```json +{ + "error": { + "category": "INVALID_PARAM", + "code": 2001, + "recoverable": false, + "scheduler_action": "REJECT_PLATE", + "sequence": 42, + "component": "stage.x", + "message": "x target 200.000 mm outside [0.0, 120.0]", + "detail": {"axis": "x", "target_mm": 200.0}, + "timestamp": "2026-07-02T12:00:00Z", + "terminal": false, + "operator_intervention_required": false, + "plate_removable": true, + "resolved_at": null, + "resolved_by": null + } +} +``` + +Drivers should branch on `category`/`code`/`terminal`, not on the HTTP status code or message text (the +HTTP status is advisory triage only). Codes are allocated in 1000-blocks per category: + +| Category | Code block | Meaning | Typical HTTP status | +|----------|-----------|---------|---------------------| +| `PROTOCOL` | 1xxx | Unknown resource, wrong state, schema violation, auth, forbidden, not-implemented | 401/403/404/409/422/501 | +| `INVALID_PARAM` | 2xxx | Out-of-range or malformed request parameter | 400 | +| `CONFIG` | 3xxx | Unknown channel/objective, missing capability, hardware mismatch | 422 | +| `HARDWARE_TRANSIENT` | 4xxx | Timeout or other retryable hardware issue | 503 | +| `HARDWARE_FAULT` | 5xxx | Hardware fault (5999 = internal error) | 500/503 | +| `ACQUISITION` | 6xxx | Failed to start, or runtime failure during a run | 503 | +| `IO` | 7xxx | Path not writable, disk full, other I/O error | 500/507 | +| `AUTOFOCUS` | 8xxx | Autofocus failure or not-ready | 503 | + +`scheduler_action` is one of `RETRY`, `ABORT_PLATE`, `REJECT_PLATE`, `PAUSE_INSTRUMENT`, `ESCALATE_OPERATOR` +— a hint for an automated scheduler about how to react; it does not change what the API call itself did. + +`GET /v1/system/faults?since=&limit=` returns the fault history (monotonically increasing `sequence`); poll +it with `since=` to get only new faults. + +## Instrument state + +`GET /v1/system/status` reports `state`, one of (`squid_service/state.py`): + +| State | Meaning | +|-------|---------| +| `UNINITIALIZED` | Service constructed but `initialize()` not yet called | +| `INITIALIZING` | Homing/initialization in progress | +| `INITIALIZED` | Idle and ready to accept commands | +| `RESERVED` | Reserved for exclusive use (reserve/release are currently 501 — see below) | +| `ACQUIRING` | An acquisition job is actively imaging | +| `PROCESSING` | Acquisition imaging finished; post-processing/writers draining | +| `ERROR` | An unrecoverable fault occurred; call `POST /v1/system/reset` after resolving it | +| `RECOVERING` | Recovering from an error | +| `SHUTTING_DOWN` | Instrument shutting down | + +## Jobs lifecycle + +Acquisitions are asynchronous jobs: + +```bash +# 1. Start (accepts a job, returns immediately) +curl -i -X POST http://127.0.0.1:8060/v1/acquisitions \ + -H "Content-Type: application/json" \ + -d '{"yaml_path": "/path/to/acquisition.yaml", "overrides": {"output_path": "/data/out"}}' +# HTTP/1.1 202 Accepted +# Location: /v1/jobs/c46b7c7d825b +# {"job_id": "c46b7c7d825b", "kind": "acquisition", "experiment_id": "...", +# "expected_fov_count": 1, "expected_image_count": 6, "output_dir": "/data/out/...", +# "accepted_at": "2026-07-02T12:00:00Z"} + +# 2. Poll until COMPLETED (see Polling guidance below) +curl http://127.0.0.1:8060/v1/jobs/c46b7c7d825b +# {"job_id": "...", "state": "RUNNING", "progress": {"images_acquired": 2, "total_images": 6, ...}, ...} +# ... poll again ... +# {"job_id": "...", "state": "COMPLETED", "outcome": "SUCCESS", +# "result": {"end_reason": "completed", "output_dir": "...", "image_count_written": 6, ...}} +``` + +Job `state` is one of `ACCEPTED`, `RUNNING`, `COMPLETED`. Once `COMPLETED`, `outcome` is one of `SUCCESS`, +`FAILURE`, `ABORTED`, `PARTIAL`. Only one acquisition may run at a time; starting a second while one is +active fails with a `PROTOCOL_WRONG_STATE` (1002) fault. + +### Starting an acquisition + +`AcquisitionRequest` requires exactly one of: + +- `yaml_path` — absolute path to a GUI-saved `acquisition.yaml` (wellplate mode only) +- `method` — name of a method registered under [Method registry](#method-registry) +- `grid` — inline grid-scan spec: `{"wells": "A1:B3", "channels": [...], "nx", "ny", "overlap_percent", "wellplate_format"}` + +Optional fields: `experiment_id`, `operator`, `scheduler_job_id` (audit trail, written to +`/api_request.json`), `autofocus: {"reflection": bool?, "contrast": bool?}` (override the +YAML/method's autofocus flags), `overrides: {"wells", "output_path", "sample_format"}`, and +`z_reference` (see below). + +**`z_reference`** chooses the Z baseline for the run (the `z_range[0]` the worker starts each +z-stack from). It is one of: + +- `"current"` (default) — baseline on the stage's Z position at run start (today's behavior). +- `{"z_mm": }` — an explicit absolute Z baseline, validated against the stage Z limits + (`INVALID_PARAM` / code 2001, component `stage.z`, if outside). +- `"autofocus"` — baseline on the current Z but require autofocus for this run: preflight fails + with `INVALID_PARAM` if no AF mode is enabled (after `autofocus` overrides), or with + `AUTOFOCUS_NOT_READY` (8002) if reflection AF has no stored reference / the contrast-AF + controller is unattached. + +`z_reference` applies to `method`, `yaml_path`, and `grid` runs identically. It replaces any Z +that would otherwise come from the stage; for wells-by-name methods (which store no Z) it is the +only Z source. `z_stacking_config` ("FROM BOTTOM"/"FROM CENTER"/"FROM TOP") in the method still +governs how the stack is placed relative to that baseline. + +## Method registry + +A **method** is an acquisition YAML (same schema as a GUI-saved `acquisition.yaml`) stored server-side, so +clients reference it by name instead of by filesystem path (URS API-METH-001..005). Methods live in the +directory configured by `methods_dir` (default `machine_configs/acquisition_methods/`), one file per method +named `.yaml`. + +**Selecting wells by name.** A wellplate method may name its wells with `wellplate_scan.wells` — a range +`"A1:B3"` or comma list `"A1,A2,B1"` (a YAML list of names is also accepted and normalized to the comma +form). X/Y are derived from the plate definition (same machinery as the `overrides.wells` path), so no +coordinates are stored and no Z is stored. A method specifies **either** `wells` **or** `regions` (explicit +`center_mm` per region, needed for irregular/manual layouts) — providing both is rejected when the method is +parsed. `GET /v1/methods` includes the method's `wells` in its summary. + +```bash +curl -X POST http://127.0.0.1:8060/v1/methods \ + -H "Content-Type: application/json" \ + -d '{"name": "daily_scan", "config": {"acquisition": {"widget_type": "wellplate"}, ...}}' + +curl -X POST http://127.0.0.1:8060/v1/acquisitions -d '{"method": "daily_scan"}' +``` + +`GET /v1/methods` returns a summary per method including `estimated_duration_s`, which is **always `null` +in this version** — it is a documented placeholder for a future duration estimator, not a bug. Deleting a +method while an acquisition is in progress is rejected with a `PROTOCOL_WRONG_STATE` fault. + +## Polling guidance (URS API-POLL-005) + +`GET /v1/system/status` and `GET /v1/system/heartbeat` are served from in-process state — they never make a +round-trip to the MCU or block on hardware I/O, so they are cheap to poll frequently. Recommended intervals: + +| Endpoint | Recommended interval | Notes | +|----------|----------------------|-------| +| `GET /v1/system/status` | ~1 s | Cheap; use for state/current-job monitoring | +| `GET /v1/system/heartbeat` | ~5 s | Liveness only; cheaper than `/status` | +| `GET /v1/jobs/{job_id}` | 2-5 s | Job progress; `run_acquisition.py` polls every 2 s | + +Short bursts above these rates are tolerated (there is no server-side rate limiting), but sustained polling +faster than ~1 Hz per client provides no additional information — state only changes on hardware/acquisition +events, not on a faster clock. + +## Server-Sent Events + +`GET /v1/events` streams state changes, progress updates, and job completions as they happen, so clients +that need low-latency updates don't have to poll `/v1/jobs/{id}` at all: + +```bash +curl -N -H "Last-Event-Id: 0" http://127.0.0.1:8060/v1/events +``` + +- The stream always opens with a `session_started` event (`session_id`, `current_state`, `last_event_id`). +- Sending `Last-Event-Id: ` replays events with id greater than `n` from the in-process buffer before + tailing live; if the requested id has already fallen out of the buffer, a `resume_gap` event is emitted + first so the client knows it missed events and should re-sync via `GET /v1/system/status` / + `GET /v1/jobs/{id}`. +- Event ids are a monotonically increasing per-session sequence; there is no persistence across service + restarts (a new `session_id` means a new sequence). + +## Authentication + +Bearer token auth, off by default: + +- **Loopback bind** (`host` is `127.0.0.1`/`localhost`/any loopback address): auth is **off** unless you + explicitly set `auth_enabled=true`. +- **Non-loopback bind**: auth is **mandatory** — the service refuses to start (`ServiceConfig` validation + error) unless `auth_enabled=true` and `auth_token` is a non-empty string. + +When enabled, send `Authorization: Bearer ` on every request except the open paths: `/v1/healthz`, +`/v1/system/auth_status`, `/openapi.json`, `/docs`, `/redoc`. A missing/invalid token yields `401` with a +`PROTOCOL_AUTH` (1004) fault. `GET /v1/system/auth_status` lets a client check whether it needs a token +before making authenticated calls. + +The MCP bridge reads `SQUID_API_URL` (base URL) and `SQUID_API_TOKEN` (bearer token, optional) from the +environment — see [MCP Integration](mcp_integration.md#environment-variables). + +## Deferred endpoints (501) + +The following routes exist (for OpenAPI-schema completeness and forward compatibility) but currently return +`501 Not Implemented` with a `PROTOCOL_NOT_IMPLEMENTED` (1006) fault. Each is tracked against a URS +requirement so the compliance gap is explicit: + +| Endpoint | URS id | Notes | +|----------|--------|-------| +| `POST /v1/jobs/{job_id}/emergency_stop` | API-ACQ-005 | Use `POST /v1/jobs/{job_id}/abort` for a graceful stop today | +| `POST /v1/system/reserve` | API-LIFE-005 | Exclusive-reservation lifecycle not yet implemented | +| `POST /v1/system/release` | API-LIFE-006 | See above | +| `POST /v1/system/shutdown` | — | No remote shutdown path yet | + +Additionally, **plate-handling endpoints do not exist at all yet** (no routes registered) — tracked as +URS API-PLATE-* (loading/unloading/presence sensing). There is currently no REST equivalent; plate handling +remains GUI-only. + +## Known limitations + +- **GUI/API acquisition race**: the API's 409 "already in progress" guard reads the controller state, but + acquisitions started from the **GUI** bypass the service's command lock. If a GUI-initiated and an + API-initiated start land in the same instant, they can race on the shared `MultiPointController`. Do not + operate the GUI while a scheduler is driving the instrument through the API. A follow-up will route GUI + starts through the service so a single lock serializes both paths. + +## Configuration + +`[CORE_SERVICE]` section in the machine `.ini` config (all keys optional; defaults shown): + +```ini +[CORE_SERVICE] +enabled = true +host = 127.0.0.1 +port = 8060 +auth_enabled = false +auth_token = +methods_dir = machine_configs/acquisition_methods +``` + +| Key | Default | Notes | +|-----|---------|-------| +| `enabled` | `true` | Set `false` to disable the REST API entirely (the legacy TCP server is unaffected) | +| `host` | `127.0.0.1` | Bind address. Non-loopback requires `auth_enabled=true` + `auth_token` (see [Authentication](#authentication)) | +| `port` | `8060` | Bind port. Deliberately NOT 5060 (the original spec value): browsers block port 5060 (SIP) via their unsafe-port lists (`ERR_UNSAFE_PORT` in Chrome/Edge), which would make `/docs` unreachable | +| `auth_enabled` | `false` | See [Authentication](#authentication) | +| `auth_token` | `""` | Bearer token; required (non-empty) when `auth_enabled=true` | +| `methods_dir` | `machine_configs/acquisition_methods` | Directory for the [method registry](#method-registry), relative to the `software/` working directory | + +The server starts alongside the legacy TCP control server (port 5050) when the control server is enabled — +via `python3 main_hcs.py --start-server`, or **Settings → Enable MCP Control Server** in the GUI. Both are +stopped together on GUI exit. + +## See Also + +- [Automation](automation.md) - `run_acquisition.py` and `curl` recipes +- [MCP Integration](mcp_integration.md) - AI-agent control via Claude Code diff --git a/software/docs/mcp_integration.md b/software/docs/mcp_integration.md index 5f5445d00..9fb8a1a18 100644 --- a/software/docs/mcp_integration.md +++ b/software/docs/mcp_integration.md @@ -2,25 +2,39 @@ This document describes how to use the Model Context Protocol (MCP) integration to control the Squid microscope from Claude Code or other MCP-compatible AI agents. +> **New here? Start with the [Control-with-Claude Quickstart](quickstart-mcp.md)** — launch in +> two clicks and drive the microscope in plain English. This document is the full reference +> (setup options, complete tool list, fault shape, troubleshooting). + ## Architecture ``` -┌─────────────┐ stdio ┌──────────────────┐ TCP:5050 ┌─────────────────────────┐ -│ Claude Code │ ◄────────────► │ MCP Server │ ◄──────────────► │ MicroscopeControlServer │ -│ │ │ (mcp_microscope_ │ │ (runs inside GUI) │ -│ │ │ server.py) │ │ │ -└─────────────┘ └──────────────────┘ └────────────┬────────────┘ - │ - ▼ - ┌─────────────────────────┐ - │ Microscope Hardware │ - │ (stage, camera, etc.) │ - └─────────────────────────┘ +┌─────────────┐ stdio ┌──────────────────┐ REST :8060 ┌─────────────────────────┐ +│ Claude Code │ ◄────────────► │ MCP Server │ ◄──────────────► │ squid_service │ +│ │ │ (curated tools; │ HTTP/JSON │ (SquidCoreService, │ +│ │ │ mcp_microscope_ │ │ runs inside the GUI) │ +│ │ │ server.py) │ │ │ +└─────────────┘ └──────────────────┘ └────────────┬────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ Microscope Hardware │ + │ (stage, camera, etc.) │ + └─────────────────────────┘ ``` 1. **Claude Code** connects to the MCP server via stdio -2. **MCP Server** (`mcp_microscope_server.py`) translates MCP tool calls to TCP commands -3. **MicroscopeControlServer** (`control/microscope_control_server.py`) runs inside the GUI process and executes commands on the microscope +2. **MCP Server** (`mcp_microscope_server.py`) is a thin, static, curated-tool bridge that translates each MCP tool call into one or more REST calls (via `httpx`), targeting `SQUID_API_URL` (default `http://127.0.0.1:8060`) +3. **squid_service** (`squid_service/service.py` + `squid_service/rest/`) runs inside the GUI process, serves the REST+SSE API described in [Core Service API](core-service-api.md), and executes commands on the microscope + +The legacy TCP control server (port 5050, newline-delimited JSON) still runs alongside the REST API for backward compatibility, but the MCP bridge no longer talks to it. + +### Environment variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `SQUID_API_URL` | `http://127.0.0.1:8060` | Base URL of the REST API the MCP bridge talks to | +| `SQUID_API_TOKEN` | unset | Bearer token, sent as `Authorization: Bearer ` when set (needed only if the server has auth enabled — see [Core Service API — Authentication](core-service-api.md#authentication)) | ## Setup @@ -44,12 +58,14 @@ This automatically: ### On-Demand Control Server -The MCP control server does **not** start automatically when the GUI launches. It starts when: +The control server does **not** start automatically when the GUI launches. Starting it brings up both the +REST API (port 8060, used by the MCP bridge) and the legacy TCP server (port 5050) together. It starts when: | Action | Result | |--------|--------| | **Settings → Launch Claude Code** | Auto-starts server, then launches Claude Code | | **Settings → Enable MCP Control Server** | Manually start/stop the server | +| `python3 main_hcs.py --start-server` | Starts the server at GUI launch | This improves security by only running the server when needed. @@ -104,7 +120,8 @@ The `python_exec` command is disabled by default for security. To enable it: | Command | Description | |---------|-------------| | `ping` | Check if server is running | -| `get_status` | Get comprehensive microscope status | +| `get_status` | Get comprehensive microscope status (state, active job, latest fault) | +| `get_capabilities` | Channels, objectives, stage travel, camera, simulation flag | | `get_position` | Get current XYZ stage position (mm) | ### Stage Movement @@ -143,23 +160,44 @@ The `python_exec` command is disabled by default for security. To enable it: | `get_current_objective` | - | Get current objective | | `set_objective` | `objective_name` | Switch objective | -### Multi-Point Acquisition +### Autofocus + +| Command | Parameters | Description | +|---------|------------|-------------| +| `autofocus` | `target_um` | Run reflection (laser) autofocus at the current position | +| `autofocus_status` | - | Reflection (laser) autofocus hardware/reference readiness | +| `store_af_reference` | - | Capture the current laser spot as the new reflection-AF reference | + +### Multi-Point Acquisition & Methods | Command | Parameters | Description | |---------|------------|-------------| -| `run_acquisition` | `wells`, `channels`, `nx`, `ny`, `wellplate_format`, `overlap_percent` | Run automated well plate scan | -| `run_acquisition_from_yaml` | `yaml_path`, `wells`, `base_path` | Run acquisition from saved YAML config | -| `get_acquisition_status` | - | Check acquisition progress | -| `abort_acquisition` | - | Stop running acquisition | +| `run_acquisition` | `wells`, `channels`, `nx`, `ny`, `wellplate_format`, `overlap_percent`, `experiment_id`, `base_path` | Run a grid-mode multi-well acquisition; returns a job handle | +| `run_acquisition_from_yaml` | `yaml_path`, `wells`, `base_path`, `experiment_id` | Run acquisition from a saved YAML config; returns a job handle | +| `get_methods` | - | List named acquisition methods stored on the server | +| `run_method` | `method`, `experiment_id`, `wells`, `base_path`, `operator` | Start an acquisition from a named server-side method; returns a job handle | +| `get_acquisition_status` | - | Instrument status plus active or last job progress | +| `get_job` | `job_id` | Get a job record by id | +| `abort_acquisition` | `timeout_s` | Gracefully abort the running acquisition | -> **Note:** `run_acquisition_from_yaml` only supports wellplate mode. For scripted automation, see [Automation](automation.md). +> **Note:** All acquisition commands only support wellplate mode. FlexibleMultiPoint acquisitions must be run +> from the GUI. For scripted automation, see [Automation](automation.md). Acquisition methods live under +> `machine_configs/acquisition_methods/`; see [Core Service API — Method registry](core-service-api.md#method-registry). -### Performance +### Performance & View Settings | Command | Parameters | Description | |---------|------------|-------------| -| `set_performance_mode` | `enabled` | Toggle performance mode (faster, less RAM) | -| `get_performance_mode` | - | Check performance mode state | +| `set_performance_mode` | `enabled` | Toggle performance mode (faster, less RAM); requires a GUI | +| `get_performance_mode` | - | Get current performance/view debug settings | +| `get_view_settings` | - | Get downsampled-well-image saving + mosaic display + performance mode | +| `set_view_settings` | `save_downsampled_well_images`, `display_mosaic_view` | Set multiple view settings at once | +| `set_save_downsampled_images` | `enabled` | Enable/disable saving per-well downsampled TIFFs (next acquisition) | +| `set_display_mosaic_view` | `enabled` | Enable/disable mosaic view display (immediate) | + +> **Note:** `microscope_set_display_plate_view` does not exist. The legacy `DISPLAY_PLATE_VIEW` flag it +> toggled was removed — plate view was unified into the mosaic view (`UnifiedMosaicWidget`), governed solely +> by `display_mosaic_view`. ### Direct Python Access @@ -253,22 +291,30 @@ else: ## Protocol Details -The TCP protocol uses newline-delimited JSON: - -**Request:** -```json -{"command": "move_to", "params": {"x_mm": 50.0, "y_mm": 25.0}} -``` +The MCP bridge talks HTTP/JSON to the REST API (see [Core Service API](core-service-api.md) for the full +endpoint reference). Successful responses are plain JSON objects; every non-2xx response body is a +canonical Fault, so agents can branch on `category`/`code`/`terminal` instead of parsing free-text errors: -**Response (success):** ```json -{"success": true, "result": {"moved_to": {"x_mm": 50.0, "y_mm": 25.0, "z_mm": 1.2}}} +{ + "error": { + "category": "INVALID_PARAM", + "code": 2001, + "recoverable": false, + "scheduler_action": "REJECT_PLATE", + "component": "stage.x", + "message": "x target 200.000 mm outside [0.0, 120.0]", + "detail": {"axis": "x", "target_mm": 200.0}, + "timestamp": "2026-07-02T12:00:00Z", + "terminal": false, + "operator_intervention_required": false, + "plate_removable": true + } +} ``` -**Response (error):** -```json -{"success": false, "error": "Error message here"} -``` +The MCP bridge returns this JSON verbatim as the tool's text result (it does not raise/throw), so a tool +call that "failed" still returns successfully to Claude Code — inspect the `error` key to detect it. ## Troubleshooting @@ -284,12 +330,23 @@ The TCP protocol uses newline-delimited JSON: ### "Cannot connect to microscope" - Ensure the Squid GUI is running - Enable the control server via **Settings → Enable MCP Control Server** (or use **Launch Claude Code** which auto-starts it) -- Verify port 5050 is not blocked +- Verify port 8060 (REST API) is not blocked; the bridge reports the exact URL it tried in the error message +- If `SQUID_API_URL` is set, confirm it points at the right host/port ### Command timeout -- Long acquisitions may exceed the default 30s timeout -- Check `get_acquisition_status` for progress on running scans +- Long acquisitions run asynchronously as jobs; a "timeout" on `run_acquisition_from_yaml`/`run_method`/`run_acquisition` + only means the *start* request was slow — the acquisition itself keeps running +- Check `get_acquisition_status` or `get_job` for progress on running scans ### "Channel not found" - Channel names are objective-specific - Use `get_channels` to list available channels for current objective + +### 401 Unauthorized +- Only occurs when the server has auth enabled (non-default; required for non-loopback binds) +- Set `SQUID_API_TOKEN` in the environment Claude Code runs in + +## See Also + +- [Core Service API](core-service-api.md) - Full REST API reference (endpoints, faults, jobs, SSE) +- [Automation](automation.md) - Scripted acquisitions via `run_acquisition.py` or `curl` diff --git a/software/docs/quickstart-api.md b/software/docs/quickstart-api.md new file mode 100644 index 000000000..d8579b7f3 --- /dev/null +++ b/software/docs/quickstart-api.md @@ -0,0 +1,205 @@ +# Quickstart: Run a Plate Scan via the API + +Run an automated well-plate scan without touching the GUI. This is the 5-minute +version; for the full endpoint reference see [core-service-api.md](core-service-api.md). + +## 1. Start the software + +```bash +cd software +python3 main_hcs.py --simulation --start-server # drop --simulation on a real instrument +``` + +No screen attached, or driving the instrument from a scheduler? Run the same API +without the GUI: + +```bash +python3 main_headless.py --simulation # Ctrl+C or SIGTERM to stop +``` + +Headless mode serves the identical API; on shutdown an in-flight acquisition is +aborted safely first. There is no live view — images go to disk and progress +comes from `/v1/jobs`. + +Wait ~30 s, then confirm the API is up: + +```bash +curl http://127.0.0.1:8060/v1/healthz # -> {"alive": true} +``` + +Open **http://127.0.0.1:8060/docs** in a browser to explore and try every endpoint interactively. + +> **macOS note:** if requests hang after the window is hidden, launch with +> `caffeinate -i python3 main_hcs.py ...` (macOS "App Nap" pauses background processes). + +## 2. See what's available + +```bash +curl http://127.0.0.1:8060/v1/imaging/channels # channel names for the current objective +curl http://127.0.0.1:8060/v1/sample_formats # supported plate formats +curl http://127.0.0.1:8060/v1/system/capabilities # objectives, stage limits, camera, versions +``` + +Channel names must match exactly, e.g. `BF LED matrix full`, `Fluorescence 488 nm Ex`. + +## 3. Run a scan — pick one of three ways + +### A. Inline grid (fastest — no setup) + +Name wells, channels, and an FOV grid per well. Single z-plane, single timepoint. + +```bash +curl -X POST http://127.0.0.1:8060/v1/acquisitions \ + -H 'Content-Type: application/json' \ + -d '{ + "experiment_id": "my_scan", + "grid": { + "wells": "A1:B3", + "channels": ["BF LED matrix full", "Fluorescence 488 nm Ex"], + "nx": 2, "ny": 2, + "overlap_percent": 10, + "wellplate_format": "96 well plate" + }, + "overrides": {"output_path": "/tmp/scans"} + }' +``` + +`wells` accepts a range (`A1:B3`) or a list (`A1,A2,B1`). Add autofocus with +`"autofocus": {"reflection": true}`. + +### B. Named method (reusable — best for schedulers) + +Store a full acquisition definition once (z-stack, timelapse, AF, regions), then run it by name. +No filesystem access needed — author it entirely over the API. + +```bash +# Create the method (one time) +curl -X POST http://127.0.0.1:8060/v1/methods \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "spheroid_4ch_20x", + "config": { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 5, "delta_z_mm": 0.002}, + "time_series": {"nt": 1, "delta_t_s": 0}, + "channels": [{"name": "BF LED matrix full"}, {"name": "Fluorescence 488 nm Ex"}], + "autofocus": {"contrast_af": false, "laser_af": true}, + "wellplate_scan": { + "scan_size_mm": 1.5, "overlap_percent": 10, + "wells": "A1:B3" + } + } + }' + +# Run it, overriding wells and output path per run +curl -X POST http://127.0.0.1:8060/v1/acquisitions \ + -d '{"method": "spheroid_4ch_20x", + "overrides": {"wells": "A1:D6", "output_path": "/data/exp42"}}' +``` + +Name wells with `wellplate_scan.wells` (`"A1:B3"` range or `"A1,A2,B1"` list) — X/Y are +derived from the plate definition, so no coordinates are stored in the method. **Z is not +stored in a wells-by-name method**: by default the run baselines on the current stage +position at start. Pass `"z_reference": {"z_mm": 2.1}` on the acquisition request for an +explicit baseline, or `"z_reference": "autofocus"` to require a stored/ready autofocus for +the run. For irregular or manually-placed regions, `wellplate_scan.regions` with per-region +`center_mm` (including Z) is still supported — but a method uses either `wells` **or** +`regions`, not both. + +`curl http://127.0.0.1:8060/v1/methods` lists methods; `GET /v1/methods/{name}` shows one. +A `wells` override replaces the stored wells/regions. You can also author a method by saving +an acquisition in the GUI and copying its YAML into `machine_configs/acquisition_methods/`. + +### C. Existing GUI-saved YAML + +Point at any `acquisition.yaml` a previous GUI session saved: + +```bash +curl -X POST http://127.0.0.1:8060/v1/acquisitions \ + -d '{"yaml_path": "/path/to/acquisition.yaml", "overrides": {"wells": "A1:B2"}}' +``` + +## 4. What you get back + +Every start returns **202 Accepted** with a job handle: + +```json +{ + "job_id": "5903a3e01d4f", + "expected_fov_count": 1, + "expected_image_count": 1, + "output_dir": "/tmp/scans/my_scan_2026-07-03_17-23-56" +} +``` + +## 5. Track it + +```bash +curl http://127.0.0.1:8060/v1/jobs/5903a3e01d4f +``` + +```json +{ + "state": "COMPLETED", + "outcome": "SUCCESS", + "progress": {"images_acquired": 1, "total_images": 1, + "af_failures": 0, "save_failures": 0, "elapsed_s": 1.6}, + "result": {"output_dir": "...", "image_count_written": 1, + "end_reason": "completed", "skipped_fovs": []} +} +``` + +- `state`: `ACCEPTED` → `RUNNING` → `COMPLETED`. `outcome`: `SUCCESS` / `FAILURE` / `ABORTED` / `PARTIAL`. +- A `.done` file appears in `output_dir` when data is fully written to disk. +- `curl http://127.0.0.1:8060/v1/jobs/last` returns the most recent job (survives restarts). + +Live event stream instead of polling: + +```bash +curl -N http://127.0.0.1:8060/v1/events # state_changed, progress, job_completed, fault +``` + +Abort a running scan: + +```bash +curl -X POST http://127.0.0.1:8060/v1/jobs/5903a3e01d4f/abort # graceful; finishes current FOV +``` + +## 6. Validate before running (optional) + +`preflight` runs all checks (channels, format, wells, disk space) with **no hardware motion**: + +```bash +curl -X POST http://127.0.0.1:8060/v1/acquisitions/preflight -d '' +# -> {"ok": true, "checks": [{"name": "channels", "ok": true}, ...]} +``` + +## Prefer a command line or an AI assistant? + +Same three modes, wrapped: + +```bash +# CLI script +python scripts/run_acquisition.py --method spheroid_4ch_20x --wells "A1:B3" --wait +python scripts/run_acquisition.py --yaml acquisition.yaml --wait + +# Claude Code (MCP): just ask in plain language, e.g. +# "scan wells A1 to B3 in brightfield and 488, then tell me when it's done" +``` + +For the plain-English/Claude route, see the [Control-with-Claude Quickstart](quickstart-mcp.md). + +## If something fails + +Every error is a structured fault you can branch on — never a bare string: + +```json +{"error": {"category": "CONFIG", "code": 3001, "recoverable": false, + "scheduler_action": "REJECT_PLATE", "terminal": true, + "message": "Channel 'GFP' not found for objective '20x'"}} +``` + +Common ones: `CONFIG` (unknown channel/objective/format), `INVALID_PARAM` (out of range), +`IO` (output path not writable / disk full), `HARDWARE_TRANSIENT` (retryable). Full catalogue +in [core-service-api.md](core-service-api.md#faults). diff --git a/software/docs/quickstart-mcp.md b/software/docs/quickstart-mcp.md new file mode 100644 index 000000000..3896a34d0 --- /dev/null +++ b/software/docs/quickstart-mcp.md @@ -0,0 +1,83 @@ +# Quickstart: Control the Microscope with Claude + +Drive the microscope by **talking to Claude Code in plain English** — no code, no +tool syntax. This is the 5-minute version; for setup options, the full tool list, +and troubleshooting see [MCP Integration](mcp_integration.md). + +## 1. Launch + +1. Start the Squid GUI. +2. **Settings → Launch Claude Code.** + +That's it. The GUI starts the control server, wires up the connection, and pre-approves +all microscope commands. A terminal opens with Claude Code ready. (First time only: if +prompted, let it install Claude Code, and set your key via **Settings → Set Anthropic +API Key…** unless you're already logged in with `claude login`.) + +Verify it's connected — just ask: + +> **"Are you connected to the microscope? What's its status?"** + +Claude will report the instrument state (e.g. `INITIALIZED`), current objective, and position. + +## 2. Just ask for what you want + +You don't call tools or remember parameter names. Say what you want; Claude picks the +right commands and fills in the details. Examples that work out of the box: + +**Look around** + +> "What channels and objectives are available right now?" +> "Move the stage to x=20, y=20 and show me the current position." +> "Take a brightfield image and show it to me." + +**Set up imaging** + +> "Switch to the 488 fluorescence channel and set exposure to 100 ms." +> "Turn the illumination on, grab an image, then turn it off." + +**Run a plate scan** — the big one: + +> "Scan wells A1 through B3 on a 96-well plate in brightfield and 488, 2×2 fields per +> well, and tell me when it's done." + +Claude starts the acquisition, gets a job handle back, and can poll progress for you. +Follow up naturally: + +> "How's the scan going?" → Claude reports FOVs done, elapsed time, any AF/save failures. +> "Abort it — finish the current field first." → graceful abort, reports whether it was clean. + +**Use a saved method** (if any exist on the instrument): + +> "What acquisition methods are saved? Run 'spheroid_4ch_20x' on wells A1–D6." + +## 3. What Claude can do for you + +Behind the scenes it has tools for: status/position/capabilities, stage moves and homing, +channel/exposure/illumination/objective control, single-image and laser-AF-image capture, +reflection autofocus, and the full acquisition lifecycle (grid scans, saved methods, +GUI-saved YAMLs, job tracking, abort). You rarely need the names — describe the goal. + +The full catalogue is the tool table in [MCP Integration](mcp_integration.md#available-commands). + +## 4. When you want raw Python (advanced, off by default) + +For one-off exploration Claude can run Python directly on the live microscope objects — +but it's **disabled by default for safety**. Enable it per session in the GUI: +**Settings → Enable MCP Python Exec** (accept the warning; resets to off on restart). Then: + +> "Using python_exec, list the methods available on the autofocus camera object." + +Only turn this on when you need it — it is not sandboxed. + +## 5. If Claude says it can't reach the microscope + +- Make sure the GUI is running and the control server is on + (**Settings → Enable MCP Control Server**, or use **Launch Claude Code** which starts it). +- Everything else — port details, auth, timeouts — is in + [MCP Integration → Troubleshooting](mcp_integration.md#troubleshooting). + +## Not using Claude? + +The same capabilities are plain HTTP — see the [API Quickstart](quickstart-api.md) +for `curl` examples and the [`run_acquisition.py`](automation.md) CLI. diff --git a/software/main_hcs.py b/software/main_hcs.py index dddc81816..0cff36c8c 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -163,8 +163,39 @@ # Ensure clean shutdown of control server socket on app exit app.aboutToQuit.connect(control_server.stop) + core_service = None + core_rest_server = None + if control._def.CORE_SERVICE_ENABLED: + try: + from pathlib import Path + + from squid_service.config import ServiceConfig + from squid_service.gui_bridge import GuiBridge + from squid_service.rest.app import create_app + from squid_service.rest.server import CoreServiceServer + from squid_service.service import SquidCoreService + + _service_config = ServiceConfig.from_def() + core_service = SquidCoreService( + microscope=microscope, + multipoint_controller=win.multipointController, + scan_coordinates=win.scanCoordinates, + gui_bridge=GuiBridge(win), + simulation=args.simulation, + job_persist_path=Path("cache/last_job.json"), + methods_dir=Path(_service_config.methods_dir), + ) + core_rest_server = CoreServiceServer( + create_app(core_service, _service_config), _service_config.host, _service_config.port + ) + app.aboutToQuit.connect(core_rest_server.stop) + except Exception as e: + log.error(f"Core service setup failed; REST API unavailable: {e}") + def start_control_server_if_needed(): """Start the control server if not already running.""" + if core_rest_server is not None and not core_rest_server.is_running(): + core_rest_server.start() if not control_server.is_running(): control_server.start() log.info(f"MCP control server started on {CONTROL_SERVER_HOST}:{CONTROL_SERVER_PORT}") @@ -200,6 +231,8 @@ def on_control_server_toggled(checked): else: control_server.stop() log.info("MCP control server stopped") + if core_rest_server is not None: + core_rest_server.stop() control_server_action.toggled.connect(on_control_server_toggled) settings_menu.addAction(control_server_action) @@ -226,10 +259,14 @@ def on_python_exec_toggled(checked): ) if reply == QMessageBox.Yes: control_server.set_python_exec_enabled(True) + if core_service is not None: + core_service.set_python_exec_enabled(True) else: python_exec_action.setChecked(False) else: control_server.set_python_exec_enabled(False) + if core_service is not None: + core_service.set_python_exec_enabled(False) python_exec_action.toggled.connect(on_python_exec_toggled) settings_menu.addAction(python_exec_action) diff --git a/software/main_headless.py b/software/main_headless.py new file mode 100644 index 000000000..5e544e8c7 --- /dev/null +++ b/software/main_headless.py @@ -0,0 +1,124 @@ +"""Run the Squid core service (REST + SSE API) without the GUI. + +Serves the same API as the GUI-embedded server (see main_hcs.py), but uvicorn +runs in the main thread of a QApplication-free process — intended for +scheduler-driven instruments and remote operation. Host/port/auth come from the +[CORE_SERVICE] INI section, same as the GUI. Ctrl+C or SIGTERM stops the server, +aborts any in-flight acquisition, and closes the hardware. +""" + +import argparse +import logging +import multiprocessing +import signal +import threading +from pathlib import Path + +import squid.logging + +squid.logging.setup_uncaught_exception_logging() + +import control._def +import control.microscope +import control.utils +from control.single_instance import acquire_single_instance_lock +from tools.migrate_acquisition_configs import run_auto_migration + + +def _finish_active_acquisition(service, log) -> None: + """Abort an in-flight acquisition so the hardware lands in a safe state.""" + active = service.jobs.active + if active is None: + return + log.info(f"Shutdown requested while job {active.job_id} is running; aborting it first...") + try: + result = service.abort_job(active.job_id, timeout_s=60.0) + if result["timed_out"]: + log.warning("Acquisition did not stop within 60s; hardware may not be in a safe state") + else: + log.info(f"Job {active.job_id} stopped (outcome: {result['job'].get('outcome')})") + except Exception as e: + log.error(f"Abort on shutdown failed: {e}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Squid core service REST API, no GUI.") + parser.add_argument("--simulation", help="Run with simulated hardware.", action="store_true") + parser.add_argument("--verbose", help="Turn on verbose logging (DEBUG level)", action="store_true") + parser.add_argument( + "--skip-init", + help="Skip hardware initialization and homing (for restart after settings change)", + action="store_true", + ) + args = parser.parse_args() + + log = squid.logging.get_logger("main_headless") + if args.verbose: + log.info("Turning on debug logging.") + squid.logging.set_stdout_log_level(logging.DEBUG) + if not squid.logging.add_file_logging(f"{squid.logging.get_default_log_directory()}/main_headless.log"): + log.error("Couldn't setup logging to file!") + return 1 + + # Same hardware-exclusivity lock as the GUI (QLockFile needs no QApplication). + lock_result = acquire_single_instance_lock() + if lock_result.lock is None: + if lock_result.busy: + log.error("Another instance of Squid is already running on this computer.") + else: + log.error(f"Failed to create the lock file at: {lock_result.path}") + return 1 + + try: + log.info(f"Squid Repository State: {control.utils.get_squid_repo_state_description()}") + run_auto_migration() + + from squid_service.config import ServiceConfig + from squid_service.headless import create_headless_service + from squid_service.rest.app import create_app + from squid_service.rest.server import CoreServiceServer + + # Fail fast on a bad [CORE_SERVICE] config before touching hardware. + service_config = ServiceConfig.from_def() + + microscope = control.microscope.Microscope.build_from_global_config(args.simulation, skip_init=args.skip_init) + try: + service = create_headless_service( + microscope, + simulation=args.simulation, + job_persist_path=Path("cache/last_job.json"), + methods_dir=Path(service_config.methods_dir), + ) + # Run uvicorn in a thread (same as the GUI) and own the signals here. + # uvicorn's main-thread signal capture replays SIGTERM after serve(), + # which would kill the process before any teardown below could run. + server = CoreServiceServer(create_app(service, service_config), service_config.host, service_config.port) + shutdown_requested = threading.Event() + signal.signal(signal.SIGINT, lambda *_: shutdown_requested.set()) + signal.signal(signal.SIGTERM, lambda *_: shutdown_requested.set()) + server.start() + log.info("Headless mode: Ctrl+C or SIGTERM to stop.") + # Wait with a timeout so the main thread returns to the interpreter + # regularly; a bare wait() can delay signal-handler delivery. + while not shutdown_requested.wait(timeout=1.0): + pass + log.info("Shutdown requested.") + _finish_active_acquisition(service, log) + server.stop() + finally: + microscope.close() + # JobRunner subprocesses have no teardown path yet (issue tracked in + # the core-service follow-ups); reap them so an unattended instrument + # does not accumulate PPID-1 orphans across restarts. + for child in multiprocessing.active_children(): + child.join(timeout=2.0) + if child.is_alive(): + log.warning(f"Terminating lingering subprocess {child.pid}") + child.terminate() + finally: + lock_result.lock.unlock() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/software/mcp_microscope_server.py b/software/mcp_microscope_server.py index 3baa6d201..6df22d6c0 100644 --- a/software/mcp_microscope_server.py +++ b/software/mcp_microscope_server.py @@ -1,188 +1,560 @@ #!/usr/bin/env python3 -""" -MCP Server for Squid Microscope Control - -This MCP (Model Context Protocol) server allows Claude Code to directly control -the Squid microscope while the GUI is running. - -Architecture: -- GUI runs with MicroscopeControlServer (TCP server on port 5050) -- This MCP server connects to the TCP server and dynamically fetches available commands -- Claude Code connects to this MCP server via stdio - -Usage: -1. Start the Squid microscope GUI (which starts the TCP control server) -2. Configure Claude Code to use this MCP server -3. Claude Code can now call microscope control tools directly - -Claude Code configuration (.mcp.json in project directory): -{ - "mcpServers": { - "squid-microscope": { - "command": "python3", - "args": ["/path/to/mcp_microscope_server.py"] - } - } -} +"""MCP stdio bridge for the Squid Core Service REST API. + +Claude Code <-stdio-> this bridge <-HTTP:8060-> squid_service (inside the GUI). +Tool names and argument names are kept compatible with the previous TCP-based +bridge so existing .mcp.json configs and pre-approved permissions keep working. +Errors are returned as the canonical Fault JSON ({"error": {category, code, +terminal, ...}}) so agents can branch programmatically. """ import asyncio import json -import socket -from typing import Any, Optional +import os +from typing import Any, Dict, Optional +import httpx from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import TextContent, Tool -# Default connection settings for the microscope control server -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 5050 +DEFAULT_API_URL = "http://127.0.0.1:8060" -# Cache for schemas (refreshed on each list_tools call) -_schemas_cache: Optional[dict] = None +def make_client(transport: Optional[httpx.AsyncBaseTransport] = None) -> httpx.AsyncClient: + headers = {} + token = os.environ.get("SQUID_API_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + base_url = os.environ.get("SQUID_API_URL", DEFAULT_API_URL) + return httpx.AsyncClient(base_url=base_url, headers=headers, timeout=60.0, transport=transport) -MAX_BUFFER_SIZE = 10 * 1024 * 1024 # 10 MB limit to prevent memory exhaustion - -def send_command( - command: str, - params: Optional[dict] = None, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - timeout: float = 30.0, +async def _call( + client: httpx.AsyncClient, + method: str, + path: str, + body: Optional[dict] = None, + timeout: Optional[float] = None, ) -> dict: - """Send a command to the microscope control server.""" - request = {"command": command, "params": params or {}} - + # A per-call timeout overrides the client default for long-running ops (home, + # abort) so the HTTP timeout always outlives the server-side operation timeout. + request_kwargs = {"json": body} + if timeout is not None: + request_kwargs["timeout"] = timeout + try: + response = await client.request(method, path, **request_kwargs) + except httpx.TransportError as e: + return { + "error": { + "category": "HARDWARE_TRANSIENT", + "code": 4001, + "message": f"Cannot reach the Squid Core Service at {client.base_url} ({e}). " + "Is the GUI running with the control server enabled?", + "terminal": False, + } + } try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(timeout) - sock.connect((host, port)) - sock.sendall((json.dumps(request) + "\n").encode("utf-8")) - - buffer = b"" - while True: - chunk = sock.recv(8192) - if not chunk: - break - buffer += chunk - if len(buffer) > MAX_BUFFER_SIZE: - return {"success": False, "error": "Response too large"} - if b"\n" in buffer: - break - - return json.loads(buffer.decode("utf-8").strip()) - except ConnectionRefusedError: + payload = response.json() + except json.JSONDecodeError: + payload = {"raw": response.text} + return payload # non-2xx bodies already carry {"error": Fault} + + +# ---- tool handlers ---------------------------------------------------------- + + +def _pick(args: dict, mapping: Dict[str, str]) -> dict: + return {new: args[old] for old, new in mapping.items() if args.get(old) is not None} + + +async def _ping(c, a): + return await _call(c, "GET", "/v1/healthz") + + +async def _get_status(c, a): + return await _call(c, "GET", "/v1/system/status") + + +async def _get_capabilities(c, a): + return await _call(c, "GET", "/v1/system/capabilities") + + +async def _get_position(c, a): + return await _call(c, "GET", "/v1/motion/position") + + +async def _move_to(c, a): + body = {"mode": "absolute", "block_until_complete": a.get("blocking", True)} + body.update(_pick(a, {"x_mm": "x", "y_mm": "y", "z_mm": "z"})) + return await _call(c, "POST", "/v1/motion/move", body) + + +async def _move_relative(c, a): + body = {"mode": "relative", "block_until_complete": a.get("blocking", True)} + body.update(_pick(a, {"dx_mm": "x", "dy_mm": "y", "dz_mm": "z"})) + return await _call(c, "POST", "/v1/motion/move", body) + + +async def _home(c, a): + # Homing all axes can take a while; give the HTTP call a generous timeout. + return await _call(c, "POST", "/v1/motion/home", timeout=300.0) + + +async def _start_live(c, a): + return await _call(c, "POST", "/v1/imaging/live/start") + + +async def _stop_live(c, a): + return await _call(c, "POST", "/v1/imaging/live/stop") + + +async def _acquire_image(c, a): + return await _call(c, "POST", "/v1/imaging/acquire", _pick(a, {"save_path": "save_path", "channel": "channel"})) + + +async def _get_channels(c, a): + return await _call(c, "GET", "/v1/imaging/channels") + + +async def _set_channel(c, a): + return await _call(c, "POST", "/v1/imaging/channel", {"name": a["channel_name"]}) + + +async def _set_exposure(c, a): + return await _call( + c, "POST", "/v1/imaging/exposure", _pick(a, {"exposure_ms": "exposure_ms", "channel": "channel"}) + ) + + +async def _set_intensity(c, a): + return await _call(c, "POST", "/v1/imaging/intensity", {"channel": a["channel"], "intensity": a["intensity"]}) + + +async def _illum_on(c, a): + return await _call(c, "POST", "/v1/imaging/illumination/on") + + +async def _illum_off(c, a): + return await _call(c, "POST", "/v1/imaging/illumination/off") + + +async def _get_objectives(c, a): + return await _call(c, "GET", "/v1/imaging/objectives") + + +async def _get_current_objective(c, a): + return await _call(c, "GET", "/v1/imaging/objective") + + +async def _set_objective(c, a): + return await _call(c, "POST", "/v1/imaging/objective", {"name": a["objective_name"]}) + + +async def _autofocus(c, a): + return await _call(c, "POST", "/v1/autofocus/run", {"target_um": a.get("target_um", 0.0)}) + + +async def _acquire_laser_af_image(c, a): + body = _pick(a, {"save_path": "save_path", "use_last_frame": "use_last_frame"}) + return await _call(c, "POST", "/v1/autofocus/acquire_image", body) + + +async def _run_acquisition_from_yaml(c, a): + body = { + "yaml_path": a["yaml_path"], + "experiment_id": a.get("experiment_id"), + "overrides": {"wells": a.get("wells"), "output_path": a.get("base_path")}, + } + body = {k: v for k, v in body.items() if v is not None} + return await _call(c, "POST", "/v1/acquisitions", body) + + +async def _get_acquisition_status(c, a): + status = await _call(c, "GET", "/v1/system/status") + if status.get("current_job_id"): + job = await _call(c, "GET", f"/v1/jobs/{status['current_job_id']}") + return {"status": status, "job": job} + last = await _call(c, "GET", "/v1/jobs/last") + return {"status": status, "last_job": None if "error" in last else last} + + +async def _get_job(c, a): + return await _call(c, "GET", f"/v1/jobs/{a['job_id']}") + + +async def _abort_acquisition(c, a): + status = await _call(c, "GET", "/v1/system/status") + job_id = status.get("current_job_id") + if not job_id: return { - "success": False, - "error": "Cannot connect to microscope. Is the Squid GUI running with the control server enabled?", + "error": {"category": "PROTOCOL", "code": 1002, "message": "No acquisition in progress", "terminal": False} } - except Exception as e: - return {"success": False, "error": str(e)} - - -def fetch_schemas() -> dict: - """Fetch command schemas from the microscope control server.""" - global _schemas_cache - - response = send_command("get_schemas", timeout=10) - if response.get("success"): - _schemas_cache = response.get("result", {}).get("schemas", {}) - else: - # Return empty if server not available - _schemas_cache = {} - - return _schemas_cache - - -def schema_to_mcp_tool(command_name: str, schema: dict) -> Tool: - """Convert a command schema to an MCP Tool definition.""" - # Build JSON Schema properties from the schema - properties = {} - for param_name, param_info in schema.get("parameters", {}).items(): - prop = {"type": param_info.get("type", "string")} - if "description" in param_info: - prop["description"] = param_info["description"] - if "default" in param_info: - prop["default"] = param_info["default"] - if "minimum" in param_info: - prop["minimum"] = param_info["minimum"] - if "maximum" in param_info: - prop["maximum"] = param_info["maximum"] - properties[param_name] = prop + # The server blocks up to timeout_s draining the abort; the HTTP call must + # outlive that (+10s slack) or the client times out before the server replies. + timeout_s = a.get("timeout_s", 60.0) + return await _call(c, "POST", f"/v1/jobs/{job_id}/abort", {"timeout_s": timeout_s}, timeout=timeout_s + 10.0) + + +async def _python_exec(c, a): + return await _call(c, "POST", "/v1/debug/python_exec", {"code": a["code"]}) + + +async def _python_exec_status(c, a): + return await _call(c, "GET", "/v1/debug/python_exec/status") + + +# ---- URS delta handlers (API-COMPAT-002) ------------------------------------ +# Legacy TCP-era tools not covered above, mapped onto the new REST API, plus +# four brand-new tools. `microscope_set_display_plate_view` has no handler and +# no registry entry: the legacy `control._def.DISPLAY_PLATE_VIEW` flag it +# toggled no longer exists on master (plate view was unified into the mosaic +# view / UnifiedMosaicWidget, governed solely by `display_mosaic_view`), so +# there is nothing left for that tool to control. + + +async def _run_acquisition_grid(c, a): + grid = {"wells": a["wells"], "channels": a["channels"]} + grid.update({k: a[k] for k in ("nx", "ny", "overlap_percent", "wellplate_format") if a.get(k) is not None}) + body = {"grid": grid} + if a.get("experiment_id") is not None: + body["experiment_id"] = a["experiment_id"] + if a.get("base_path") is not None: + body["overrides"] = {"output_path": a["base_path"]} + return await _call(c, "POST", "/v1/acquisitions", body) + + +async def _set_performance_mode(c, a): + return await _call(c, "POST", "/v1/debug/settings", {"performance_mode": a["enabled"]}) + + +async def _get_performance_mode(c, a): + return await _call(c, "GET", "/v1/debug/settings") + + +async def _get_view_settings(c, a): + return await _call(c, "GET", "/v1/debug/settings") + + +async def _set_view_settings(c, a): + body = _pick( + a, + {"save_downsampled_well_images": "save_downsampled_well_images", "display_mosaic_view": "display_mosaic_view"}, + ) + return await _call(c, "POST", "/v1/debug/settings", body) + +async def _set_save_downsampled_images(c, a): + return await _call(c, "POST", "/v1/debug/settings", {"save_downsampled_well_images": a["enabled"]}) + + +async def _set_save_downsampled_overview(c, a): + return await _call(c, "POST", "/v1/debug/settings", {"save_downsampled_overview": a["enabled"]}) + + +async def _set_display_mosaic_view(c, a): + return await _call(c, "POST", "/v1/debug/settings", {"display_mosaic_view": a["enabled"]}) + + +async def _get_methods(c, a): + return await _call(c, "GET", "/v1/methods") + + +async def _run_method(c, a): + body = {"method": a["method"]} + if a.get("experiment_id") is not None: + body["experiment_id"] = a["experiment_id"] + if a.get("operator") is not None: + body["operator"] = a["operator"] + overrides = _pick(a, {"wells": "wells", "base_path": "output_path"}) + if overrides: + body["overrides"] = overrides + return await _call(c, "POST", "/v1/acquisitions", body) + + +async def _autofocus_status(c, a): + return await _call(c, "GET", "/v1/autofocus/status") + + +async def _store_af_reference(c, a): + return await _call(c, "POST", "/v1/autofocus/store_reference") + + +# ---- tool registry ----------------------------------------------------------- + + +def _tool(name: str, description: str, properties: Optional[dict] = None, required: Optional[list] = None) -> Tool: return Tool( - name=f"microscope_{command_name}", - description=schema.get("description", f"Execute {command_name} command"), - inputSchema={ - "type": "object", - "properties": properties, - "required": schema.get("required", []), - }, + name=f"microscope_{name}", + description=description, + inputSchema={"type": "object", "properties": properties or {}, "required": required or []}, ) -# Create MCP server +_NUM = {"type": "number"} +_STR = {"type": "string"} +_BOOL = {"type": "boolean"} +_ARR_STR = {"type": "array", "items": {"type": "string"}} + +_TOOLS: Dict[str, tuple] = { + "microscope_ping": (_tool("ping", "Check the Squid Core Service is reachable"), _ping), + "microscope_get_status": (_tool("get_status", "Instrument state, active job, latest fault"), _get_status), + "microscope_get_capabilities": ( + _tool("get_capabilities", "Channels, objectives, stage travel, camera, simulation flag"), + _get_capabilities, + ), + "microscope_get_position": (_tool("get_position", "Current XYZ stage position (mm)"), _get_position), + "microscope_move_to": ( + _tool( + "move_to", + "Move stage to absolute XYZ position in mm", + {"x_mm": _NUM, "y_mm": _NUM, "z_mm": _NUM, "blocking": _BOOL}, + ), + _move_to, + ), + "microscope_move_relative": ( + _tool( + "move_relative", + "Move stage by a relative amount in mm", + {"dx_mm": _NUM, "dy_mm": _NUM, "dz_mm": _NUM, "blocking": _BOOL}, + ), + _move_relative, + ), + "microscope_home": (_tool("home", "Home all stage axes (X, Y, Z)"), _home), + "microscope_start_live": (_tool("start_live", "Start live camera streaming"), _start_live), + "microscope_stop_live": (_tool("stop_live", "Stop live camera streaming"), _stop_live), + "microscope_acquire_image": ( + _tool( + "acquire_image", + "Acquire one image; optionally select channel and save to disk", + {"channel": _STR, "save_path": _STR}, + ), + _acquire_image, + ), + "microscope_get_channels": (_tool("get_channels", "List channels for the current objective"), _get_channels), + "microscope_set_channel": ( + _tool("set_channel", "Select the active imaging channel", {"channel_name": _STR}, ["channel_name"]), + _set_channel, + ), + "microscope_set_exposure": ( + _tool( + "set_exposure", + "Set exposure time (ms), optionally for a named channel", + {"exposure_ms": _NUM, "channel": _STR}, + ["exposure_ms"], + ), + _set_exposure, + ), + "microscope_set_illumination_intensity": ( + _tool( + "set_illumination_intensity", + "Set illumination intensity 0-100% for a channel", + {"channel": _STR, "intensity": _NUM}, + ["channel", "intensity"], + ), + _set_intensity, + ), + "microscope_turn_on_illumination": (_tool("turn_on_illumination", "Illumination on"), _illum_on), + "microscope_turn_off_illumination": (_tool("turn_off_illumination", "Illumination off"), _illum_off), + "microscope_get_objectives": (_tool("get_objectives", "List objectives and current selection"), _get_objectives), + "microscope_get_current_objective": ( + _tool("get_current_objective", "Get the current objective"), + _get_current_objective, + ), + "microscope_set_objective": ( + _tool("set_objective", "Switch objective", {"objective_name": _STR}, ["objective_name"]), + _set_objective, + ), + "microscope_autofocus": ( + _tool("autofocus", "Run reflection (laser) autofocus at the current position", {"target_um": _NUM}), + _autofocus, + ), + "microscope_run_acquisition_from_yaml": ( + _tool( + "run_acquisition_from_yaml", + "Start a wellplate acquisition from a saved acquisition.yaml; returns a job handle", + { + "yaml_path": _STR, + "wells": {"type": "string", "description": "Override wells, e.g. 'A1:B3' or 'A1,B2'"}, + "experiment_id": _STR, + "base_path": _STR, + }, + ["yaml_path"], + ), + _run_acquisition_from_yaml, + ), + "microscope_get_acquisition_status": ( + _tool("get_acquisition_status", "Instrument status plus active or last job progress"), + _get_acquisition_status, + ), + "microscope_get_job": ( + _tool("get_job", "Get a job record by id", {"job_id": _STR}, ["job_id"]), + _get_job, + ), + "microscope_abort_acquisition": ( + _tool("abort_acquisition", "Gracefully abort the running acquisition", {"timeout_s": _NUM}), + _abort_acquisition, + ), + "microscope_python_exec": ( + _tool( + "python_exec", + "Execute Python with microscope objects in scope (requires GUI opt-in; NOT sandboxed). " + "Set 'result' for return data, 'image' (ndarray) to auto-save.", + {"code": _STR}, + ["code"], + ), + _python_exec, + ), + "microscope_get_python_exec_status": ( + _tool("get_python_exec_status", "Check whether python_exec is enabled"), + _python_exec_status, + ), + # ---- URS delta (API-COMPAT-002): remaining legacy tools + new tools ---- + "microscope_run_acquisition": ( + _tool( + "run_acquisition", + "Run a grid-mode multi-well acquisition (legacy grid API); returns a job handle", + { + "wells": _STR, + "channels": _ARR_STR, + "nx": _NUM, + "ny": _NUM, + "experiment_id": _STR, + "base_path": _STR, + "wellplate_format": _STR, + "overlap_percent": _NUM, + }, + ["wells", "channels"], + ), + _run_acquisition_grid, + ), + "microscope_set_performance_mode": ( + _tool( + "set_performance_mode", + "Enable or disable performance mode (disables mosaic view to save RAM); requires a GUI", + {"enabled": _BOOL}, + ["enabled"], + ), + _set_performance_mode, + ), + "microscope_get_performance_mode": ( + _tool("get_performance_mode", "Get current performance/view debug settings"), + _get_performance_mode, + ), + "microscope_get_view_settings": ( + _tool( + "get_view_settings", + "Get current view settings (downsampled-well-image saving, mosaic display, performance mode)", + ), + _get_view_settings, + ), + "microscope_set_view_settings": ( + _tool( + "set_view_settings", + "Set multiple view settings at once (mosaic view: immediate; others: next acquisition)", + {"save_downsampled_well_images": _BOOL, "display_mosaic_view": _BOOL}, + ), + _set_view_settings, + ), + "microscope_set_save_downsampled_images": ( + _tool( + "set_save_downsampled_images", + "Enable/disable saving per-well downsampled TIFFs (takes effect on next acquisition)", + {"enabled": _BOOL}, + ["enabled"], + ), + _set_save_downsampled_images, + ), + "microscope_set_save_downsampled_overview": ( + _tool( + "set_save_downsampled_overview", + "Enable/disable saving the downsampled mosaic overview image (takes effect on next acquisition)", + {"enabled": _BOOL}, + ["enabled"], + ), + _set_save_downsampled_overview, + ), + "microscope_set_display_mosaic_view": ( + _tool( + "set_display_mosaic_view", + "Enable/disable mosaic view display (takes effect immediately)", + {"enabled": _BOOL}, + ["enabled"], + ), + _set_display_mosaic_view, + ), + "microscope_get_methods": ( + _tool("get_methods", "List named acquisition methods stored on the server"), + _get_methods, + ), + "microscope_run_method": ( + _tool( + "run_method", + "Start an acquisition from a named server-side method; returns a job handle", + {"method": _STR, "experiment_id": _STR, "wells": _STR, "base_path": _STR, "operator": _STR}, + ["method"], + ), + _run_method, + ), + "microscope_autofocus_status": ( + _tool("autofocus_status", "Reflection (laser) autofocus hardware/reference readiness"), + _autofocus_status, + ), + "microscope_store_af_reference": ( + _tool("store_af_reference", "Capture the current laser spot as the new reflection-AF reference"), + _store_af_reference, + ), + "microscope_acquire_laser_af_image": ( + _tool( + "acquire_laser_af_image", + "Acquire an image from the laser autofocus camera; optionally save to disk", + {"save_path": _STR, "use_last_frame": _BOOL}, + ), + _acquire_laser_af_image, + ), +} + + +def tool_definitions() -> list: + return [definition for definition, _ in _TOOLS.values()] + + +async def dispatch(client: httpx.AsyncClient, name: str, arguments: dict) -> dict: + entry = _TOOLS.get(name) + if entry is None: + raise ValueError(f"Unknown tool: {name}") + _, handler = entry + return await handler(client, arguments or {}) + + +# ---- MCP plumbing ------------------------------------------------------------- + app = Server("squid-microscope") +_client: Optional[httpx.AsyncClient] = None + + +def _get_client() -> httpx.AsyncClient: + global _client + if _client is None: + _client = make_client() + return _client @app.list_tools() -async def list_tools() -> list[Tool]: - """List available microscope control tools by fetching schemas from the server.""" - # Fetch schemas from the microscope control server - loop = asyncio.get_event_loop() - schemas = await loop.run_in_executor(None, fetch_schemas) - - if not schemas: - # Return a minimal ping tool if server is not available - return [ - Tool( - name="microscope_ping", - description="Check if the microscope control server is running and responsive", - inputSchema={ - "type": "object", - "properties": {}, - "required": [], - }, - ) - ] - - # Convert all schemas to MCP tools - tools = [] - for command_name, schema in schemas.items(): - # Skip get_schemas itself from the tool list - if command_name == "get_schemas": - continue - tools.append(schema_to_mcp_tool(command_name, schema)) - - return tools +async def list_tools() -> list: + return tool_definitions() @app.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: - """Handle tool calls by forwarding to microscope control server.""" - # Extract command name from tool name (remove "microscope_" prefix) - if name.startswith("microscope_"): - command = name[len("microscope_") :] - else: - command = name - - # Run the blocking socket call in a thread pool - loop = asyncio.get_event_loop() - response = await loop.run_in_executor(None, lambda: send_command(command, arguments)) - - if response.get("success"): - result = response.get("result", {}) - return [TextContent(type="text", text=json.dumps(result, indent=2))] - else: - error = response.get("error", "Unknown error") - return [TextContent(type="text", text=f"Error: {error}")] +async def call_tool(name: str, arguments: Dict[str, Any]) -> list: + try: + result = await dispatch(_get_client(), name, arguments) + except ValueError as e: + return [TextContent(type="text", text=f"Error: {e}")] + return [TextContent(type="text", text=json.dumps(result, indent=2))] async def main(): - """Run the MCP server.""" async with stdio_server() as (read_stream, write_stream): await app.run(read_stream, write_stream, app.create_initialization_options()) diff --git a/software/scripts/run_acquisition.py b/software/scripts/run_acquisition.py index aa5f824ca..4bba4211e 100755 --- a/software/scripts/run_acquisition.py +++ b/software/scripts/run_acquisition.py @@ -2,97 +2,77 @@ """ Automated Acquisition Script for Squid Microscope -Launches the GUI, connects via TCP, and runs acquisition from a YAML config file -that was saved during a previous acquisition. +Launches the GUI, connects via the REST API (squid_service), and runs an +acquisition from a YAML config file that was saved during a previous +acquisition, or from a named server-side method. Usage: python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --simulation --wait + python scripts/run_acquisition.py --method my_method --wait python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --wells "A1:B3" --wait python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --no-launch --wait + python scripts/run_acquisition.py --yaml /path/to/acquisition.yaml --no-launch --dry-run """ import argparse -import json import os import signal -import socket import subprocess import sys import time from pathlib import Path +import httpx + # Constants DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 5050 -MAX_BUFFER_SIZE = 10 * 1024 * 1024 # 10 MB +DEFAULT_PORT = 8060 CONNECTION_TIMEOUT = 120 # seconds to wait for server CONNECTION_RETRY_INTERVAL = 2.0 # seconds +JOB_POLL_INTERVAL = 2.0 # seconds +MAX_CONSECUTIVE_POLL_ERRORS = 10 -def send_command( - command: str, - params: dict = None, - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - timeout: float = 60.0, -) -> dict: - """Send a command to the microscope control server and return response.""" - request = {"command": command, "params": params or {}} - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(timeout) - sock.connect((host, port)) - sock.sendall((json.dumps(request) + "\n").encode("utf-8")) - - # Receive response - buffer = b"" - while True: - chunk = sock.recv(4096) - if not chunk: - break - buffer += chunk - if len(buffer) > MAX_BUFFER_SIZE: - raise ValueError("Response too large") - if b"\n" in buffer: - break - - if not buffer: - raise ConnectionError("Server closed connection without response") - - return json.loads(buffer.decode("utf-8").strip()) +def api(host: str, port: int) -> str: + """Base URL for the squid_service REST API.""" + return f"http://{host}:{port}" + + +def auth_headers() -> dict: + """Bearer auth header built from SQUID_API_TOKEN, if set. + + Non-loopback binds require auth (the service refuses to start otherwise), so + set SQUID_API_TOKEN when talking to a remote host. Empty dict on loopback. + """ + token = os.environ.get("SQUID_API_TOKEN") + return {"Authorization": f"Bearer {token}"} if token else {} def wait_for_server( host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, - timeout: float = CONNECTION_TIMEOUT, + timeout_s: float = CONNECTION_TIMEOUT, retry_interval: float = CONNECTION_RETRY_INTERVAL, verbose: bool = False, ) -> bool: - """Wait for the TCP control server to become available.""" - start_time = time.time() + """Wait for the REST API to become available (GET /v1/healthz).""" + start_time = time.monotonic() + deadline = start_time + timeout_s attempt = 0 - while time.time() - start_time < timeout: + while time.monotonic() < deadline: attempt += 1 try: - response = send_command("ping", host=host, port=port, timeout=5.0) - if response.get("success"): - # Always log connection success (useful for debugging) - elapsed = time.time() - start_time + if httpx.get(f"{api(host, port)}/v1/healthz", headers=auth_headers(), timeout=2.0).status_code == 200: + elapsed = time.monotonic() - start_time print(f"Server ready after {attempt} attempts ({elapsed:.1f}s)") return True - except (socket.error, ConnectionRefusedError, socket.timeout): + except httpx.TransportError: if verbose: print(f"Waiting for server... (attempt {attempt})") - time.sleep(retry_interval) - except Exception as e: - # Always show unexpected errors - they may indicate a real problem - print(f"Unexpected error connecting to server: {e}") - time.sleep(retry_interval) - - # Log failure details even when not verbose - elapsed = time.time() - start_time + time.sleep(retry_interval) + + elapsed = time.monotonic() - start_time print(f"Server connection failed after {attempt} attempts ({elapsed:.1f}s)") return False @@ -128,156 +108,166 @@ def launch_gui(simulation: bool = False, verbose: bool = False) -> subprocess.Po return process -def monitor_acquisition( - host: str = DEFAULT_HOST, - port: int = DEFAULT_PORT, - poll_interval: float = 5.0, - timeout: float = None, - verbose: bool = False, -) -> dict: - """Monitor acquisition progress until completion or timeout.""" - start_time = time.time() - last_fov = -1 - consecutive_errors = 0 - max_consecutive_errors = 10 +def build_body(args) -> dict: + """Build the JSON body for POST /v1/acquisitions[/preflight].""" + body = {"overrides": {}} + if args.method: + body["method"] = args.method + else: + body["yaml_path"] = os.path.abspath(args.yaml) + if args.wells: + body["overrides"]["wells"] = args.wells + if args.base_path: + body["overrides"]["output_path"] = args.base_path + return body - while True: - try: - response = send_command("get_acquisition_status", host=host, port=port, timeout=10.0) - - if not response.get("success"): - print(f"\nWarning: Error getting status: {response.get('error', 'Unknown error')}") - consecutive_errors += 1 - if consecutive_errors >= max_consecutive_errors: - return { - "completed": False, - "error": f"Lost connection to server after {consecutive_errors} consecutive errors", - } - time.sleep(poll_interval) - continue - - # Reset error counter on success - consecutive_errors = 0 - result = response.get("result", {}) - in_progress = result.get("in_progress", False) - - if not in_progress: - if verbose: - print("\nAcquisition completed!") - return {"completed": True, "status": result} - - # Print progress - current_fov = result.get("current_fov", 0) - total_fovs = result.get("total_fovs", 0) - - if current_fov != last_fov: - elapsed = time.time() - start_time - if total_fovs > 0: - progress = current_fov / total_fovs * 100 - print(f"\rProgress: {current_fov}/{total_fovs} FOVs ({progress:.1f}%) - {elapsed:.0f}s", end="") - else: - print(f"\rProgress: FOV {current_fov} - {elapsed:.0f}s", end="") - last_fov = current_fov - sys.stdout.flush() - - except (socket.error, ConnectionRefusedError, socket.timeout) as e: - # Connection errors during monitoring - warn and continue trying - consecutive_errors += 1 - print(f"\nWarning: Connection error polling status: {e}") - if consecutive_errors >= max_consecutive_errors: - return { - "completed": False, - "error": f"Lost connection to server after {consecutive_errors} consecutive errors", - } - except Exception as e: - # Unexpected errors - always show them - consecutive_errors += 1 - print(f"\nWarning: Unexpected error polling status: {e}") - if verbose: - import traceback +def _error_message(payload: dict) -> str: + error = payload.get("error", payload) + if isinstance(error, dict): + return str(error.get("message", error)) + return str(error) - traceback.print_exc() - if consecutive_errors >= max_consecutive_errors: - return { - "completed": False, - "error": f"Too many errors polling status: {e}", - } - # Check timeout - if timeout and (time.time() - start_time) > timeout: - return {"completed": False, "timeout": True, "elapsed": time.time() - start_time} +def run_preflight(args) -> int: + """POST /v1/acquisitions/preflight and print the check results.""" + print("\n=== DRY RUN (server-side preflight) ===") + try: + r = httpx.post( + f"{api(args.host, args.port)}/v1/acquisitions/preflight", + json=build_body(args), + headers=auth_headers(), + timeout=30.0, + ) + except httpx.TransportError as e: + print(f"Error contacting server: {e}") + return 1 + try: + payload = r.json() + except ValueError: + print(f"Unexpected response ({r.status_code}): {r.text}") + return 1 + if r.status_code != 200: + print(f"Preflight request failed: {_error_message(payload)}") + return 1 - time.sleep(poll_interval) + for check in payload.get("checks", []): + print(f" [{'ok' if check['ok'] else 'FAIL'}] {check['name']}: {check['message'] or 'ok'}") + ok = payload.get("ok", False) + print(f"\nDry run complete - no acquisition started. Overall: {'OK' if ok else 'FAILED'}") + return 0 if ok else 1 -def handle_dry_run(yaml_path: str, wells: str = None) -> None: - """Validate YAML and print configuration without running acquisition.""" - import yaml - print("\n=== DRY RUN MODE ===") - print("Validating YAML file...") +def print_acquisition_result(payload: dict) -> None: + """Print the job handle returned by POST /v1/acquisitions.""" + print("Acquisition started!") + print(f" Job ID: {payload.get('job_id')}") + print(f" Experiment ID: {payload.get('experiment_id')}") + print(f" Output directory: {payload.get('output_dir')}") + print(f" Expected FOVs: {payload.get('expected_fov_count')}") + print(f" Expected images: {payload.get('expected_image_count')}") - with open(yaml_path, "r") as f: - config = yaml.safe_load(f) - print("\nAcquisition Configuration:") - print(f" Widget type: {config.get('acquisition', {}).get('widget_type', 'unknown')}") - print(f" Objective: {config.get('objective', {}).get('name', 'unknown')}") - print(f" Channels: {[ch.get('name') for ch in config.get('channels', [])]}") - print(f" Z-stack: nz={config.get('z_stack', {}).get('nz', 1)}") - print(f" Time series: nt={config.get('time_series', {}).get('nt', 1)}") +def start_and_wait(args) -> int: + """POST /v1/acquisitions, then (if --wait) poll GET /v1/jobs/{id} until COMPLETED. - regions = config.get("wellplate_scan", {}).get("regions", []) - if regions: - print(f" Regions: {[r.get('name') for r in regions]}") - else: - positions = config.get("flexible_scan", {}).get("positions", []) - if positions: - print(f" Positions: {[p.get('name') for p in positions]}") + Returns 0 iff the acquisition was accepted (and, with --wait, completed with + outcome SUCCESS); 1 otherwise. + """ + base = api(args.host, args.port) + try: + r = httpx.post(f"{base}/v1/acquisitions", json=build_body(args), headers=auth_headers(), timeout=60.0) + except httpx.TransportError as e: + print(f"Error starting acquisition: {e}") + return 1 + try: + payload = r.json() + except ValueError: + print(f"Unexpected response ({r.status_code}): {r.text}") + return 1 + if r.status_code != 202: + print(f"Failed to start acquisition: {_error_message(payload)}") + return 1 - if wells: - print(f"\n Wells override: {wells}") + print_acquisition_result(payload) + job_id = payload["job_id"] - print("\nYAML validation: OK") - print("Dry run complete - no acquisition started.") + if not args.wait: + return 0 + print("\nMonitoring acquisition progress...") + consecutive_errors = 0 + start_time = time.time() + while True: + try: + job = httpx.get(f"{base}/v1/jobs/{job_id}", headers=auth_headers(), timeout=10.0).json() + consecutive_errors = 0 + except httpx.TransportError as e: + consecutive_errors += 1 + print(f"\nWarning: Connection error polling job: {e}") + if consecutive_errors >= MAX_CONSECUTIVE_POLL_ERRORS: + print(f"Lost contact with server after {consecutive_errors} consecutive errors") + return 1 + time.sleep(JOB_POLL_INTERVAL) + continue + + progress = job.get("progress", {}) + print( + f" {job.get('state')}: {progress.get('images_acquired', 0)}/{progress.get('total_images', '?')} images", + flush=True, + ) -def print_acquisition_result(result: dict) -> None: - """Print acquisition start result.""" - print("Acquisition started!") - print(f" Experiment ID: {result.get('experiment_id')}") - print(f" Save directory: {result.get('save_dir')}") - print(f" Regions: {result.get('region_count')}") - print(f" Channels: {result.get('channels')}") - print(f" Z-stack: {result.get('nz')} slices") - print(f" Timepoints: {result.get('nt')}") - print(f" Total images: {result.get('total_images')}") + if job.get("state") == "COMPLETED": + outcome = job.get("outcome") + end_reason = (job.get("result") or {}).get("end_reason") + print(f"\nOutcome: {outcome} ({end_reason})") + return 0 if outcome == "SUCCESS" else 1 + + if args.timeout and (time.time() - start_time) > args.timeout: + print(f"\nAcquisition timed out after {args.timeout:.0f}s (job {job_id} still running)") + return 1 + + time.sleep(JOB_POLL_INTERVAL) def main(): parser = argparse.ArgumentParser( - description="Run automated acquisition on Squid microscope using saved YAML settings", + description="Run automated acquisition on Squid microscope via the REST API", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Run with simulation mode, wait for completion python run_acquisition.py --yaml /path/to/acquisition.yaml --simulation --wait + # Run a named server-side method instead of a YAML file + python run_acquisition.py --method my_method --wait + # Run with different wells than saved in YAML python run_acquisition.py --yaml /path/to/acquisition.yaml --wells "A1:A3" --wait # Connect to already-running GUI (don't launch new one) python run_acquisition.py --yaml /path/to/acquisition.yaml --no-launch --wait + + # Validate a config against the live instrument without starting it + python run_acquisition.py --yaml /path/to/acquisition.yaml --no-launch --dry-run """, ) - parser.add_argument( + source_group = parser.add_mutually_exclusive_group(required=True) + source_group.add_argument( "--yaml", "-y", - required=True, + default=None, help="Path to acquisition.yaml file saved by the GUI", ) + source_group.add_argument( + "--method", + default=None, + help="Name of a server-side acquisition method under machine_configs/acquisition_methods/ " + "(alternative to --yaml)", + ) + parser.add_argument( "--wells", "-w", @@ -306,29 +296,27 @@ def main(): default=None, help="Acquisition timeout in seconds (only with --wait)", ) - # Launch mode options (mutually exclusive) - launch_group = parser.add_mutually_exclusive_group() - launch_group.add_argument( + parser.add_argument( "--no-launch", action="store_true", - help="Don't launch GUI, assume it's already running with server enabled", + help="Don't launch GUI, assume it's already running with the REST API enabled", ) - launch_group.add_argument( + parser.add_argument( "--dry-run", action="store_true", - help="Validate YAML and print what would be executed without actually running", + help="Run server-side preflight checks only (POST /v1/acquisitions/preflight); " "do not start the acquisition", ) parser.add_argument( "--host", default=DEFAULT_HOST, - help=f"TCP server host (default: {DEFAULT_HOST})", + help=f"REST API host (default: {DEFAULT_HOST})", ) parser.add_argument( "--port", type=int, default=DEFAULT_PORT, - help=f"TCP server port (default: {DEFAULT_PORT})", + help=f"REST API port (default: {DEFAULT_PORT})", ) parser.add_argument( "--verbose", @@ -339,21 +327,15 @@ def main(): args = parser.parse_args() - # Validate YAML file exists - yaml_path = os.path.abspath(args.yaml) - if not os.path.exists(yaml_path): - print(f"Error: YAML file not found: {yaml_path}") - sys.exit(1) - - print(f"Using YAML config: {yaml_path}") - - if args.dry_run: - try: - handle_dry_run(yaml_path, args.wells) - except Exception as e: - print(f"Error validating YAML: {e}") + if args.yaml: + yaml_path = os.path.abspath(args.yaml) + if not os.path.exists(yaml_path): + print(f"Error: YAML file not found: {yaml_path}") sys.exit(1) - sys.exit(0) + args.yaml = yaml_path + print(f"Using YAML config: {yaml_path}") + else: + print(f"Using method: {args.method}") gui_process = None @@ -363,18 +345,19 @@ def main(): def cleanup(signum=None, frame=None): """Clean up on exit, warning if acquisition is still running. - Called from signal handlers and explicit error paths. Always exits. - When gui_process is None (e.g., --no-launch mode), just exits cleanly. - Uses the exit_code variable from the enclosing scope. + Called from signal handlers and explicit exit paths (including normal + completion). Always exits. When gui_process is None (e.g., --no-launch + mode), just exits with the current exit_code. """ if gui_process: - # Check if acquisition is still running before terminating try: - response = send_command("get_acquisition_status", host=args.host, port=args.port, timeout=2.0) - if response.get("success") and response.get("result", {}).get("in_progress"): + status = httpx.get( + f"{api(args.host, args.port)}/v1/system/status", headers=auth_headers(), timeout=2.0 + ).json() + if status.get("current_job_id"): print("\nWARNING: Acquisition is still in progress!") print("Terminating GUI will abort the acquisition and may result in data loss.") - except (socket.error, ConnectionRefusedError, socket.timeout, json.JSONDecodeError): + except httpx.TransportError: pass # Server may not be reachable during cleanup - expected except Exception as e: print(f"\nWarning: Unexpected error checking acquisition status: {e}") @@ -403,67 +386,25 @@ def cleanup(signum=None, frame=None): print("Waiting for control server...") if not wait_for_server(host=args.host, port=args.port, verbose=args.verbose): print("Error: Control server did not become available within timeout") - print("Make sure the GUI is running and 'Enable MCP Control Server' is checked in Settings") + print("Make sure the GUI is running with the REST API enabled (--start-server flag or " "Settings)") exit_code = 1 cleanup() print("Control server ready!") - # Build acquisition parameters - params = {"yaml_path": yaml_path} - if args.wells: - params["wells"] = args.wells - if args.base_path: - params["base_path"] = args.base_path + if args.dry_run: + exit_code = run_preflight(args) + cleanup() - # Start acquisition print("Starting acquisition...") - response = send_command( - "run_acquisition_from_yaml", - params=params, - host=args.host, - port=args.port, - timeout=30.0, - ) + exit_code = start_and_wait(args) - if not response.get("success"): - print(f"Error starting acquisition: {response.get('error', 'Unknown error')}") - exit_code = 1 - cleanup() - - result = response.get("result", {}) - print_acquisition_result(result) - - # Monitor if requested - if args.wait: - print("\nMonitoring acquisition progress...") - status = monitor_acquisition( - host=args.host, - port=args.port, - timeout=args.timeout, - verbose=args.verbose, - ) - - if status.get("completed"): - print("\nAcquisition completed successfully!") - elif status.get("timeout"): - print(f"\nAcquisition timed out after {status.get('elapsed', 0):.0f}s") - exit_code = 1 - elif status.get("error"): - print(f"\nAcquisition error: {status.get('error')}") - exit_code = 1 - - # Terminate GUI after --wait completes - if gui_process: - cleanup() - - # If not waiting and we launched the GUI, inform user if not args.wait and gui_process: print("\nAcquisition running in background. GUI will remain open.") print("Press Ctrl+C to exit (this will close the GUI)") - - # Wait for GUI to exit gui_process.wait() + else: + cleanup() except KeyboardInterrupt: print("\nInterrupted by user") diff --git a/software/setup_22.04.sh b/software/setup_22.04.sh index ae7d02edf..bec16e02d 100755 --- a/software/setup_22.04.sh +++ b/software/setup_22.04.sh @@ -61,7 +61,7 @@ python3 -m pip install --upgrade pip # install libraries pip3 install qtpy pyserial pandas imageio crc==1.3.0 lxml "numpy<2" tifffile scipy pyreadline3 pip3 install opencv-python-headless opencv-contrib-python-headless -pip3 install napari==0.5.4 scikit-image dask_image ome_zarr aicsimageio basicpy pytest pytest-qt pytest-xvfb gitpython matplotlib pydantic_xml pyvisa hidapi filelock lxml_html_clean psutil mcp ndv +pip3 install napari==0.5.4 scikit-image dask_image ome_zarr aicsimageio basicpy pytest pytest-qt pytest-xvfb gitpython matplotlib pydantic_xml pyvisa hidapi filelock lxml_html_clean psutil mcp ndv fastapi uvicorn sse-starlette httpx # install camera drivers cd "$DAHENG_CAMERA_DRIVER_ROOT" diff --git a/software/squid_service/__init__.py b/software/squid_service/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/software/squid_service/config.py b/software/squid_service/config.py new file mode 100644 index 000000000..3237b48cc --- /dev/null +++ b/software/squid_service/config.py @@ -0,0 +1,47 @@ +"""Service configuration, sourced from control._def (INI-backed).""" + +import ipaddress + +from pydantic import BaseModel, model_validator + + +def _is_loopback(host: str) -> bool: + if host in ("localhost",): + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +class ServiceConfig(BaseModel): + host: str = "127.0.0.1" + port: int = 8060 + auth_enabled: bool = False + auth_token: str = "" + methods_dir: str = "machine_configs/acquisition_methods" + + @model_validator(mode="after") + def _require_auth_off_loopback(self) -> "ServiceConfig": + # Deviation from spec §2.3 (auth default-on), documented in the design doc: + # loopback binds may run without auth; anything else requires a bearer token. + if not _is_loopback(self.host): + if not self.auth_enabled or not self.auth_token: + raise ValueError( + "CORE_SERVICE bound to a non-loopback host requires auth_enabled=true " "and a non-empty auth_token" + ) + if self.auth_enabled and not self.auth_token: + raise ValueError("auth_enabled requires a non-empty auth_token") + return self + + @classmethod + def from_def(cls) -> "ServiceConfig": + import control._def + + return cls( + host=getattr(control._def, "CORE_SERVICE_HOST", "127.0.0.1"), + port=getattr(control._def, "CORE_SERVICE_PORT", 8060), + auth_enabled=getattr(control._def, "CORE_SERVICE_AUTH_ENABLED", False), + auth_token=getattr(control._def, "CORE_SERVICE_AUTH_TOKEN", ""), + methods_dir=getattr(control._def, "CORE_SERVICE_METHODS_DIR", "machine_configs/acquisition_methods"), + ) diff --git a/software/squid_service/events.py b/software/squid_service/events.py new file mode 100644 index 000000000..286c2c699 --- /dev/null +++ b/software/squid_service/events.py @@ -0,0 +1,83 @@ +"""In-process event bus backing the SSE stream (spec §2.6).""" + +import itertools +import queue +import threading +import uuid +from collections import deque +from dataclasses import dataclass +from typing import List, Tuple + + +@dataclass(frozen=True) +class Event: + id: int + event: str + data: dict + + +class EventBus: + """Thread-safe pub/sub with a bounded replay buffer. + + Subscribers get an unbounded queue.Queue of Event. Replay resumes from a + Last-Event-Id; if events between that id and the oldest buffered event + have been evicted from the ring buffer, the second return value is True + and the client must hard-resync (spec §2.6). + """ + + def __init__(self, buffer_size: int = 1024): + self._lock = threading.Lock() + self._counter = itertools.count(1) + self._buffer: deque = deque(maxlen=buffer_size) + self._subscribers: List[queue.Queue] = [] + self._session_id = uuid.uuid4().hex + self._last_id = 0 + + @property + def session_id(self) -> str: + return self._session_id + + @property + def last_event_id(self) -> int: + with self._lock: + return self._last_id + + def publish(self, event: str, data: dict) -> Event: + with self._lock: + ev = Event(id=next(self._counter), event=event, data=data) + self._last_id = ev.id + self._buffer.append(ev) + subscribers = list(self._subscribers) + for q in subscribers: + q.put(ev) + return ev + + def subscribe(self) -> queue.Queue: + q: queue.Queue = queue.Queue() + with self._lock: + self._subscribers.append(q) + return q + + def unsubscribe(self, q: queue.Queue) -> None: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + def replay_since(self, last_event_id: int) -> Tuple[List[Event], bool]: + """Return events after `last_event_id`, plus whether a gap occurred. + + The oldest id still available in the ring buffer defines the boundary + of what can be replayed without loss. If the client's last-seen id + falls more than one below that boundary, at least one event it never + saw has been evicted, so `gap` is True and the client must hard-resync. + When the buffer is empty, the boundary is simply "one past the last + published id" (nothing has been evicted since there is nothing to + evict from). + """ + with self._lock: + buffered = list(self._buffer) + last_id = self._last_id + oldest_buffered = buffered[0].id if buffered else last_id + 1 + gap = last_event_id < oldest_buffered - 1 + missed = [e for e in buffered if e.id > last_event_id] + return missed, gap diff --git a/software/squid_service/faults.py b/software/squid_service/faults.py new file mode 100644 index 000000000..e559de3fb --- /dev/null +++ b/software/squid_service/faults.py @@ -0,0 +1,163 @@ +"""Canonical fault shapes for the Squid Core Service (spec §2.5). + +This module is the single source of truth for fault categories and codes. +Codes are allocated in 1000-blocks per category: 1xxx PROTOCOL ... 8xxx AUTOFOCUS. +""" + +import itertools +import threading +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +from squid_service.timeutil import utc_now_iso + + +class FaultCategory(str, Enum): + PROTOCOL = "PROTOCOL" + INVALID_PARAM = "INVALID_PARAM" + CONFIG = "CONFIG" + HARDWARE_TRANSIENT = "HARDWARE_TRANSIENT" + HARDWARE_FAULT = "HARDWARE_FAULT" + ACQUISITION = "ACQUISITION" + IO = "IO" + AUTOFOCUS = "AUTOFOCUS" + + +class SchedulerAction(str, Enum): + RETRY = "RETRY" + ABORT_PLATE = "ABORT_PLATE" + REJECT_PLATE = "REJECT_PLATE" + PAUSE_INSTRUMENT = "PAUSE_INSTRUMENT" + ESCALATE_OPERATOR = "ESCALATE_OPERATOR" + + +# --- Code allocation (1000-block per category) --- +PROTOCOL_UNKNOWN_RESOURCE = 1001 +PROTOCOL_WRONG_STATE = 1002 +PROTOCOL_SCHEMA_VIOLATION = 1003 +PROTOCOL_AUTH = 1004 +PROTOCOL_FORBIDDEN = 1005 +PROTOCOL_NOT_IMPLEMENTED = 1006 +INVALID_PARAM_OUT_OF_RANGE = 2001 +INVALID_PARAM_BAD_VALUE = 2002 +CONFIG_UNKNOWN_CHANNEL = 3001 +CONFIG_UNKNOWN_OBJECTIVE = 3002 +CONFIG_CAPABILITY_MISSING = 3003 +CONFIG_HARDWARE_MISMATCH = 3004 +HARDWARE_TRANSIENT_TIMEOUT = 4001 +HARDWARE_FAULT_GENERIC = 5001 +HARDWARE_FAULT_INTERNAL = 5999 +ACQUISITION_START_FAILED = 6001 +ACQUISITION_RUNTIME = 6002 +IO_PATH_NOT_WRITABLE = 7001 +IO_DISK_FULL = 7002 +IO_GENERIC = 7003 +AUTOFOCUS_FAILURE = 8001 +AUTOFOCUS_NOT_READY = 8002 + + +class Fault(BaseModel): + category: FaultCategory + code: int + recoverable: bool + scheduler_action: SchedulerAction + sequence: int = 0 + component: Optional[str] = None + message: str + detail: Dict[str, Any] = Field(default_factory=dict) + timestamp: str + terminal: bool + operator_intervention_required: bool = False + plate_removable: bool = True + resolved_at: Optional[str] = None + resolved_by: Optional[str] = None + + +def make_fault( + category: FaultCategory, + code: int, + message: str, + *, + recoverable: bool = False, + scheduler_action: SchedulerAction = SchedulerAction.ESCALATE_OPERATOR, + terminal: bool = False, + component: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, +) -> Fault: + return Fault( + category=category, + code=code, + recoverable=recoverable, + scheduler_action=scheduler_action, + component=component, + message=message, + detail=detail or {}, + timestamp=utc_now_iso(), + terminal=terminal, + ) + + +class FaultError(Exception): + """Raise anywhere in the service layer; transports map it to a canonical response.""" + + def __init__(self, fault: Fault): + super().__init__(fault.message) + self.fault = fault + + +# HTTP mapping is advisory triage only (spec §2.5); drivers branch on category/code. +_PROTOCOL_STATUS = { + PROTOCOL_UNKNOWN_RESOURCE: 404, + PROTOCOL_WRONG_STATE: 409, + PROTOCOL_SCHEMA_VIOLATION: 422, + PROTOCOL_AUTH: 401, + PROTOCOL_FORBIDDEN: 403, + PROTOCOL_NOT_IMPLEMENTED: 501, +} + + +def http_status_for(fault: Fault) -> int: + if fault.category == FaultCategory.PROTOCOL: + return _PROTOCOL_STATUS.get(fault.code, 422) + if fault.category == FaultCategory.INVALID_PARAM: + return 400 + if fault.category == FaultCategory.CONFIG: + return 422 + if fault.category in (FaultCategory.HARDWARE_TRANSIENT, FaultCategory.HARDWARE_FAULT): + return 500 if fault.code == HARDWARE_FAULT_INTERNAL else 503 + if fault.category == FaultCategory.ACQUISITION: + return 503 + if fault.category == FaultCategory.IO: + return 507 if fault.code == IO_DISK_FULL else 500 + if fault.category == FaultCategory.AUTOFOCUS: + return 503 + return 500 + + +class FaultLog: + """Thread-safe fault history with monotonic sequence numbers.""" + + def __init__(self, max_entries: int = 1000): + self._lock = threading.Lock() + self._seq = itertools.count(1) + self._entries: List[Fault] = [] + self._max = max_entries + + def record(self, fault: Fault) -> Fault: + with self._lock: + stamped = fault.model_copy(update={"sequence": next(self._seq)}) + self._entries.append(stamped) + if len(self._entries) > self._max: + self._entries = self._entries[-self._max :] + return stamped + + def since(self, seq: int, limit: int = 100) -> List[Fault]: + with self._lock: + return [f for f in self._entries if f.sequence > seq][:limit] + + @property + def latest(self) -> Optional[Fault]: + with self._lock: + return self._entries[-1] if self._entries else None diff --git a/software/squid_service/gui_bridge.py b/software/squid_service/gui_bridge.py new file mode 100644 index 000000000..31e3bc0d0 --- /dev/null +++ b/software/squid_service/gui_bridge.py @@ -0,0 +1,92 @@ +"""Qt-thread bridge for GUI side effects. Headless-safe: no-ops without a GUI.""" + +from typing import Optional + +import squid.logging + +try: + from qtpy.QtCore import Q_ARG, QMetaObject, Qt, QTimer + + QT_AVAILABLE = True +except ImportError: + QT_AVAILABLE = False + + +class GuiBridge: + def __init__(self, gui=None): + self._log = squid.logging.get_logger(self.__class__.__name__) + self._gui = gui + + @property + def has_gui(self) -> bool: + return self._gui is not None + + def _widget_for_type(self, widget_type: str): + if self._gui is None: + return None + if widget_type == "wellplate": + return getattr(self._gui, "wellplateMultiPointWidget", None) + if widget_type == "flexible": + return getattr(self._gui, "flexibleMultiPointWidget", None) + return None + + def sync_yaml_to_widgets(self, yaml_data, yaml_path: str) -> None: + """Fire-and-forget widget refresh; completion is not required before acquisition.""" + if not QT_AVAILABLE or self._gui is None: + return + widget = self._widget_for_type(yaml_data.widget_type) + if widget is None or not hasattr(widget, "_load_acquisition_yaml"): + return + + def update(): + try: + widget._load_acquisition_yaml(yaml_path) + except Exception as e: + self._log.error(f"GUI YAML sync failed: {e}") + + QTimer.singleShot(0, update) + + def set_acquisition_state(self, yaml_data, running: bool) -> None: + """MUST complete before run_acquisition() (napari layer scale race, PR #463).""" + if not QT_AVAILABLE or self._gui is None: + return + widget = self._widget_for_type(yaml_data.widget_type) + if widget is None or not hasattr(widget, "set_acquisition_running_state"): + return + try: + ok = QMetaObject.invokeMethod( + widget, + "set_acquisition_running_state", + Qt.BlockingQueuedConnection, + Q_ARG(bool, running), + Q_ARG(int, yaml_data.nz), + Q_ARG(float, yaml_data.delta_z_um), + ) + if not ok: + self._log.error("invokeMethod(set_acquisition_running_state) failed") + except Exception as e: + self._log.error(f"GUI acquisition-state update failed: {e}") + + def get_performance_mode(self) -> Optional[bool]: + """None when headless (no GUI attached) -- callers surface this as null.""" + if self._gui is None: + return None + return bool(getattr(self._gui, "performance_mode", False)) + + def set_performance_mode(self, enabled: bool) -> None: + """Mirrors the legacy TCP `_cmd_set_performance_mode`. Fire-and-forget + (CLAUDE.md-approved pattern here: no caller needs to wait for completion) -- + schedules the toggle on the Qt main thread and returns immediately.""" + if not QT_AVAILABLE or self._gui is None: + return + if not hasattr(self._gui, "performanceModeToggle"): + self._log.error("performanceModeToggle not available on GUI") + return + + def update(): + try: + self._gui.performanceModeToggle.setChecked(enabled) + except Exception as e: + self._log.error(f"GUI performance-mode toggle failed: {e}") + + QTimer.singleShot(0, update) diff --git a/software/squid_service/headless.py b/software/squid_service/headless.py new file mode 100644 index 000000000..2f25a2f2d --- /dev/null +++ b/software/squid_service/headless.py @@ -0,0 +1,70 @@ +"""Build a fully wired SquidCoreService without the GUI. + +The HCS GUI wires MultiPointController and friends in gui_hcs.load_objects() +using Qt subclasses (QtMultiPointController, QtAutoFocusController). This module +performs the same wiring with the Qt-free base classes so the REST API can run +in a process that never creates a QApplication (see main_headless.py). +""" + +from pathlib import Path +from typing import Optional + +import control._def +from control.core.auto_focus_controller import AutoFocusController +from control.core.multi_point_controller import MultiPointController, NoOpCallbacks +from control.core.scan_coordinates import ScanCoordinates +from control.microscope import Microscope + +from squid_service.service import SquidCoreService + + +def create_headless_service( + microscope: Microscope, + simulation: bool = False, + job_persist_path: Optional[Path] = None, + methods_dir: Optional[Path] = None, +) -> SquidCoreService: + autofocus_controller = AutoFocusController( + camera=microscope.camera, + stage=microscope.stage, + liveController=microscope.live_controller, + microcontroller=microscope.low_level_drivers.microcontroller, + finished_fn=lambda: None, + image_to_display_fn=lambda image: None, + nl5=microscope.addons.nl5, + ) + scan_coordinates = ScanCoordinates( + objectiveStore=microscope.objective_store, + stage=microscope.stage, + camera=microscope.camera, + ) + # The GUI's live widget selects a default channel at startup; without one, + # MultiPointController's end-of-acquisition reset restores a None mode. + if microscope.live_controller.currentConfiguration is None: + channels = microscope.live_controller.get_channels(microscope.objective_store.current_objective) + if channels: + microscope.live_controller.set_microscope_mode(channels[0]) + laser_af_controller = None + if control._def.SUPPORT_LASER_AUTOFOCUS and microscope.addons.camera_focus: + # Populate the microscope's own lazily-initialized controller so the + # service's autofocus endpoints (microscope.laser_autofocus_controller) + # and the acquisition worker share one instance, as in the GUI. + microscope._ensure_laser_af_controller() + laser_af_controller = microscope.laser_autofocus_controller + multipoint_controller = MultiPointController( + microscope, + microscope.live_controller, + autofocus_controller, + microscope.objective_store, + callbacks=NoOpCallbacks, + scan_coordinates=scan_coordinates, + laser_autofocus_controller=laser_af_controller, + ) + return SquidCoreService( + microscope=microscope, + multipoint_controller=multipoint_controller, + scan_coordinates=scan_coordinates, + simulation=simulation, + job_persist_path=job_persist_path, + methods_dir=methods_dir, + ) diff --git a/software/squid_service/jobs.py b/software/squid_service/jobs.py new file mode 100644 index 000000000..75e7d6e72 --- /dev/null +++ b/software/squid_service/jobs.py @@ -0,0 +1,205 @@ +"""Acquisition job records and store (spec §9).""" + +import threading +import uuid +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +import squid.logging +from squid_service.faults import Fault +from squid_service.timeutil import utc_now_iso + + +class JobState(str, Enum): + ACCEPTED = "ACCEPTED" + RUNNING = "RUNNING" + COMPLETED = "COMPLETED" + + +class JobOutcome(str, Enum): + SUCCESS = "SUCCESS" + FAILURE = "FAILURE" + ABORTED = "ABORTED" + PARTIAL = "PARTIAL" + + +class JobProgress(BaseModel): + images_acquired: int = 0 + total_images: int = 0 + current_region: int = 0 + total_regions: int = 0 + current_timepoint: int = 0 + total_timepoints: int = 0 + elapsed_s: float = 0.0 + estimated_remaining_s: Optional[float] = None + # Failure counters (URS API-POLL-001, ERR-RES-001/003). af_failures is + # accumulated across timepoints from each TimepointStats.laser_af_failures; + # save_failures is set once at completion from AcquisitionStats.errors_encountered. + af_failures: int = 0 + save_failures: int = 0 + + +class JobResult(BaseModel): + output_dir: Optional[str] = None + image_count_written: int = 0 + partial_write: bool = False + errors_encountered: int = 0 + end_reason: Optional[str] = None + skipped_fovs: List[Dict[str, Any]] = Field(default_factory=list) + + +class JobRecord(BaseModel): + job_id: str + kind: str = "acquisition" + experiment_id: Optional[str] = None + origin: str = "api" # "api" or "gui" + # Audit fields (URS ERR-OBS-003/005): who/what requested the run. + operator: Optional[str] = None + scheduler_job_id: Optional[str] = None + state: JobState + accepted_at: str + started_at: Optional[str] = None + completed_at: Optional[str] = None + outcome: Optional[JobOutcome] = None + progress: JobProgress = Field(default_factory=JobProgress) + result: Optional[JobResult] = None + fault: Optional[Fault] = None + + +class JobStore: + """Thread-safe store for acquisition jobs. + + Keeps every job of the current process in memory; persists the most recently + completed job to disk so GET /v1/jobs/last is durable across restarts. + """ + + def __init__(self, persist_path: Optional[Path] = None): + self._log = squid.logging.get_logger(self.__class__.__name__) + self._lock = threading.Lock() + self._jobs: Dict[str, JobRecord] = {} + self._active_id: Optional[str] = None + self._last_id: Optional[str] = None + self._done_events: Dict[str, threading.Event] = {} + self._persist_path = persist_path + self._persisted_last: Optional[JobRecord] = None + if persist_path is not None and persist_path.exists(): + try: + self._persisted_last = JobRecord.model_validate_json(persist_path.read_text()) + except Exception as e: + self._log.warning(f"Could not load persisted last job: {e}") + + def create( + self, + experiment_id: Optional[str], + origin: str = "api", + expected_total_images: int = 0, + expected_total_regions: int = 0, + expected_total_timepoints: int = 0, + operator: Optional[str] = None, + scheduler_job_id: Optional[str] = None, + ) -> JobRecord: + job = JobRecord( + job_id=uuid.uuid4().hex[:12], + experiment_id=experiment_id, + origin=origin, + operator=operator, + scheduler_job_id=scheduler_job_id, + state=JobState.ACCEPTED, + accepted_at=utc_now_iso(), + progress=JobProgress( + total_images=expected_total_images, + total_regions=expected_total_regions, + total_timepoints=expected_total_timepoints, + ), + ) + with self._lock: + self._jobs[job.job_id] = job + self._active_id = job.job_id + self._done_events[job.job_id] = threading.Event() + return job.model_copy(deep=True) + + def get(self, job_id: str) -> Optional[JobRecord]: + with self._lock: + job = self._jobs.get(job_id) + if job is not None: + return job.model_copy(deep=True) + if self._persisted_last is not None and self._persisted_last.job_id == job_id: + return self._persisted_last + return None + + @property + def active(self) -> Optional[JobRecord]: + with self._lock: + job = self._jobs.get(self._active_id) if self._active_id else None + return job.model_copy(deep=True) if job is not None else None + + @property + def last(self) -> Optional[JobRecord]: + with self._lock: + if self._last_id: + job = self._jobs[self._last_id] + return job.model_copy(deep=True) + return self._persisted_last + + def mark_running(self, job_id: str) -> None: + with self._lock: + job = self._jobs[job_id] + job.state = JobState.RUNNING + job.started_at = utc_now_iso() + + def update_progress(self, job_id: str, **fields) -> None: + with self._lock: + job = self._jobs.get(job_id) + if job is None or job.state == JobState.COMPLETED: + return + progress = job.progress.model_copy(update=fields) + if progress.total_images and progress.images_acquired and progress.elapsed_s: + fraction = progress.images_acquired / progress.total_images + if 0 < fraction < 1: + progress.estimated_remaining_s = progress.elapsed_s * (1 - fraction) / fraction + elif fraction >= 1: + progress.estimated_remaining_s = 0.0 + job.progress = progress + + def complete( + self, + job_id: str, + outcome: JobOutcome, + result: JobResult, + fault: Optional[Fault] = None, + ) -> JobRecord: + with self._lock: + job = self._jobs[job_id] + job.state = JobState.COMPLETED + job.completed_at = utc_now_iso() + job.outcome = outcome + job.result = result + job.fault = fault + if self._active_id == job_id: + self._active_id = None + self._last_id = job_id + done = self._done_events.get(job_id) + job_copy = job.model_copy(deep=True) + self._persist(job) + if done: + done.set() + return job_copy + + def wait(self, job_id: str, timeout_s: float) -> bool: + with self._lock: + done = self._done_events.get(job_id) + if done is None: + return self.get(job_id) is not None + return done.wait(timeout=timeout_s) + + def _persist(self, job: JobRecord) -> None: + if self._persist_path is None: + return + try: + self._persist_path.parent.mkdir(parents=True, exist_ok=True) + self._persist_path.write_text(job.model_dump_json(indent=2)) + except Exception as e: + self._log.warning(f"Could not persist last job: {e}") diff --git a/software/squid_service/methods.py b/software/squid_service/methods.py new file mode 100644 index 000000000..9ac8f6261 --- /dev/null +++ b/software/squid_service/methods.py @@ -0,0 +1,132 @@ +"""Named acquisition-method registry (URS API-METH-001..005). + +A method is an acquisition YAML stored server-side; clients reference it by name. +""" + +import os +import re +import tempfile +from pathlib import Path +from typing import List + +import yaml + +from control.acquisition_yaml_loader import parse_acquisition_yaml +from squid_service import faults as F + +_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-]*$") + + +def _unknown_method(name: str) -> F.FaultError: + return F.FaultError( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_UNKNOWN_RESOURCE, + f"Unknown method: {name!r}", + detail={"method": name}, + ) + ) + + +def _invalid_name(name: str) -> F.FaultError: + return F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + f"Invalid method name: {name!r} (allowed: letters, digits, _ and -)", + detail={"method": name}, + ) + ) + + +class MethodRegistry: + def __init__(self, methods_dir: Path): + self._dir = Path(methods_dir) + + def path_for(self, name: str) -> Path: + if not _NAME_RE.match(name or ""): + raise _invalid_name(name) + path = self._dir / f"{name}.yaml" + if not path.exists(): + raise _unknown_method(name) + return path + + def exists(self, name: str) -> bool: + return bool(_NAME_RE.match(name or "")) and (self._dir / f"{name}.yaml").exists() + + def list(self) -> List[dict]: + summaries = [] + if not self._dir.is_dir(): + return summaries + for path in sorted(self._dir.glob("*.yaml")): + summaries.append(self._summarize(path.stem, path)) + return summaries + + def get(self, name: str) -> dict: + path = self.path_for(name) + with open(path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + return {"name": name, "config": config} + + def save(self, name: str, config: dict, overwrite: bool) -> None: + if not _NAME_RE.match(name or ""): + raise _invalid_name(name) + exists = (self._dir / f"{name}.yaml").exists() + if exists and not overwrite: + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + f"Method {name!r} already exists (use PUT to update)", + detail={"method": name}, + ) + ) + if not exists and overwrite: + raise _unknown_method(name) + self._validate_config(config) + self._dir.mkdir(parents=True, exist_ok=True) + with open(self._dir / f"{name}.yaml", "w", encoding="utf-8") as f: + yaml.safe_dump(config, f) + + def delete(self, name: str) -> None: + os.remove(self.path_for(name)) + + def _validate_config(self, config: dict) -> None: + """Parse-level validation via the canonical loader (no hardware access).""" + try: + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as tmp: + yaml.safe_dump(config, tmp) + tmp_path = tmp.name + try: + parse_acquisition_yaml(tmp_path) + finally: + os.unlink(tmp_path) + except F.FaultError: + raise + except Exception as e: + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + f"Method configuration invalid: {e}", + ) + ) + + def _summarize(self, name: str, path: Path) -> dict: + try: + data = parse_acquisition_yaml(str(path)) + with open(path, "r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + return { + "name": name, + "widget_type": data.widget_type, + "channels": data.channel_names, + "objective": data.objective_name, + "wellplate_format": raw.get("sample", {}).get("wellplate_format"), + "wells": data.wells, + "nz": data.nz, + "nt": data.nt, + "estimated_duration_s": None, # best-effort placeholder, documented + } + except Exception as e: + return {"name": name, "error": f"unparseable: {e}"} diff --git a/software/squid_service/models.py b/software/squid_service/models.py new file mode 100644 index 000000000..6ec358de9 --- /dev/null +++ b/software/squid_service/models.py @@ -0,0 +1,143 @@ +"""Request bodies for the REST API. Responses are plain dicts assembled by the service.""" + +from typing import List, Literal, Optional, Union + +from pydantic import BaseModel, Field, model_validator + + +class _Strict(BaseModel): + model_config = {"extra": "forbid"} + + +class ZMillimeters(_Strict): + """Explicit absolute Z baseline (mm) for an acquisition run.""" + + z_mm: float + + +# Z baseline policy for a run: "current" (today's default -- use the stage z at run +# start), "autofocus" (baseline on current z but require a ready AF for this run), or an +# explicit {"z_mm": } absolute position validated against the stage Z limits. +ZReference = Union[Literal["current", "autofocus"], ZMillimeters] + + +class MoveRequest(_Strict): + mode: Literal["absolute", "relative"] = "absolute" + x: Optional[float] = None + y: Optional[float] = None + z: Optional[float] = None + block_until_complete: bool = True + + +class ChannelSelectRequest(_Strict): + name: str + + +class ExposureRequest(_Strict): + exposure_ms: float = Field(gt=0, le=10000) + channel: Optional[str] = None + + +class IntensityRequest(_Strict): + channel: str + intensity: float = Field(ge=0, le=100) + + +class ObjectiveRequest(_Strict): + name: str + + +class AcquireRequest(_Strict): + channel: Optional[str] = None + save_path: Optional[str] = None + + +class AutofocusRunRequest(_Strict): + mode: Literal["reflection"] = "reflection" + target_um: float = 0.0 + + +class AutofocusCorrectRequest(_Strict): + threshold_um: float = Field(default=10.0, gt=0, le=1000) + + +class LaserAfImageRequest(_Strict): + save_path: Optional[str] = None + use_last_frame: bool = True + + +class InitializeRequest(_Strict): + home: bool = False + + +class MethodCreateRequest(_Strict): + name: str + config: dict + + +class MethodUpdateRequest(_Strict): + config: dict + + +class AutofocusOverride(_Strict): + reflection: Optional[bool] = None + contrast: Optional[bool] = None + + +class AcquisitionOverrides(_Strict): + wells: Optional[str] = None + output_path: Optional[str] = None + sample_format: Optional[str] = None + + +class GridSpec(_Strict): + wells: str + channels: List[str] = Field(min_length=1) + nx: int = Field(default=2, ge=1, le=100) + ny: int = Field(default=2, ge=1, le=100) + overlap_percent: float = Field(default=10.0, ge=0, le=50) + wellplate_format: str = "96 well plate" + + +class AcquisitionRequest(_Strict): + method: Optional[str] = None + yaml_path: Optional[str] = None + grid: Optional[GridSpec] = None + experiment_id: Optional[str] = None + operator: Optional[str] = None + scheduler_job_id: Optional[str] = None + autofocus: Optional[AutofocusOverride] = None + overrides: AcquisitionOverrides = Field(default_factory=AcquisitionOverrides) + z_reference: ZReference = "current" + + @model_validator(mode="after") + def _exactly_one_source(self) -> "AcquisitionRequest": + sources = [s for s in (self.method, self.yaml_path, self.grid) if s is not None] + if len(sources) != 1: + raise ValueError("Provide exactly one of: method, yaml_path, grid") + return self + + +class AbortRequest(_Strict): + timeout_s: float = Field(default=60.0, ge=0, le=600) + + +class PythonExecRequest(_Strict): + code: str + + +class DebugSettingsRequest(_Strict): + """URS API-COMPAT-002: REST parity for the legacy TCP view/performance debug + commands (_cmd_set_view_settings / _cmd_set_performance_mode). All fields are + optional; only the ones provided are changed. + + Note: `display_plate_view` from the original TCP command set is intentionally + omitted -- the underlying `control._def.DISPLAY_PLATE_VIEW` flag no longer + exists in this codebase (plate view was unified into the mosaic view / + UnifiedMosaicWidget, governed solely by `display_mosaic_view`). + """ + + performance_mode: Optional[bool] = None + save_downsampled_well_images: Optional[bool] = None + save_downsampled_overview: Optional[bool] = None + display_mosaic_view: Optional[bool] = None diff --git a/software/squid_service/rest/__init__.py b/software/squid_service/rest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/software/squid_service/rest/app.py b/software/squid_service/rest/app.py new file mode 100644 index 000000000..824a46eb2 --- /dev/null +++ b/software/squid_service/rest/app.py @@ -0,0 +1,89 @@ +"""FastAPI app factory for the Squid Core Service.""" + +import secrets + +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exception_handlers import http_exception_handler as default_http_exception_handler +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +import squid.logging +from squid_service import faults as F +from squid_service.config import ServiceConfig +from squid_service.rest.routers import build_routers +from squid_service.rest.sse import build_sse_router + +OPEN_PATHS = {"/v1/healthz", "/v1/system/auth_status", "/openapi.json", "/docs", "/redoc"} + +_log = squid.logging.get_logger("squid_service.rest.app") + + +def create_app(service, config: ServiceConfig) -> FastAPI: + app = FastAPI(title="Squid Core Service", version="1.0.0") + app.state.service = service + app.state.config = config + + @app.exception_handler(F.FaultError) + async def fault_handler(request: Request, exc: F.FaultError): + return JSONResponse(status_code=F.http_status_for(exc.fault), content={"error": exc.fault.model_dump()}) + + @app.exception_handler(RequestValidationError) + async def validation_handler(request: Request, exc: RequestValidationError): + fault = F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_SCHEMA_VIOLATION, + "Request schema violation", + detail={"errors": jsonable_encoder(exc.errors())}, + ) + return JSONResponse(status_code=422, content={"error": fault.model_dump()}) + + @app.exception_handler(StarletteHTTPException) + async def http_exception_handler(request: Request, exc: StarletteHTTPException): + # Turn an unmatched route (404) into a canonical PROTOCOL_UNKNOWN_RESOURCE + # fault so scheduler clients get the same {"error": Fault} envelope everywhere. + if exc.status_code == 404: + fault = F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_UNKNOWN_RESOURCE, + "Resource not found", + detail={"path": request.url.path}, + ) + return JSONResponse(status_code=404, content={"error": fault.model_dump()}) + # Other HTTP errors (405, etc.) fall back to FastAPI's default handler. + return await default_http_exception_handler(request, exc) + + @app.exception_handler(Exception) + async def internal_error_handler(request: Request, exc: Exception): + # Any exception that escaped the service layer (not a FaultError): log the + # full traceback server-side, but return a sanitized canonical fault with a + # fixed message (URS API-ERR-003 -- never leak str(exc)/internals to clients). + _log.exception("Unhandled exception handling %s %s", request.method, request.url.path) + fault = F.make_fault( + F.FaultCategory.HARDWARE_FAULT, + F.HARDWARE_FAULT_INTERNAL, + "Internal server error", + ) + fault_log = getattr(getattr(request.app.state, "service", None), "fault_log", None) + if fault_log is not None: + try: + fault = fault_log.record(fault) + except Exception: + _log.exception("failed to record internal-error fault") + return JSONResponse(status_code=F.http_status_for(fault), content={"error": fault.model_dump()}) + + @app.middleware("http") + async def bearer_auth(request: Request, call_next): + if config.auth_enabled and request.url.path not in OPEN_PATHS: + header = request.headers.get("authorization", "") + token = header[7:] if header.startswith("Bearer ") else "" + if not (token and secrets.compare_digest(token, config.auth_token)): + fault = F.make_fault(F.FaultCategory.PROTOCOL, F.PROTOCOL_AUTH, "Missing or invalid bearer token") + return JSONResponse(status_code=401, content={"error": fault.model_dump()}) + return await call_next(request) + + for router in build_routers(): + app.include_router(router) + app.include_router(build_sse_router()) + return app diff --git a/software/squid_service/rest/routers.py b/software/squid_service/rest/routers.py new file mode 100644 index 000000000..d3f8e5764 --- /dev/null +++ b/software/squid_service/rest/routers.py @@ -0,0 +1,264 @@ +"""All /v1 REST routers. Handlers are sync functions (FastAPI runs them in a +threadpool), calling the service facade directly — same threading position the +old socket threads occupied.""" + +from typing import Optional + +from fastapi import APIRouter, Request, Response + +from squid_service import faults as F +from squid_service.models import ( + AbortRequest, + AcquireRequest, + AcquisitionRequest, + AutofocusCorrectRequest, + AutofocusRunRequest, + ChannelSelectRequest, + DebugSettingsRequest, + ExposureRequest, + InitializeRequest, + IntensityRequest, + LaserAfImageRequest, + MethodCreateRequest, + MethodUpdateRequest, + MoveRequest, + ObjectiveRequest, + PythonExecRequest, +) + + +def _svc(request: Request): + return request.app.state.service + + +def _not_implemented(name: str): + raise F.FaultError( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_NOT_IMPLEMENTED, + f"{name} is reserved for a future version", + ) + ) + + +def build_routers(): + meta = APIRouter(prefix="/v1", tags=["meta"]) + + @meta.get("/healthz") + def healthz(): + return {"alive": True} + + @meta.get("/sample_formats") + def sample_formats(request: Request): + return _svc(request).sample_formats() + + system = APIRouter(prefix="/v1/system", tags=["system"]) + + @system.post("/initialize") + def initialize(request: Request, body: Optional[InitializeRequest] = None): + home = body.home if body is not None else False + return _svc(request).initialize(home=home) + + @system.post("/reset") + def reset(request: Request): + return _svc(request).reset() + + @system.get("/status") + def status(request: Request): + return _svc(request).status() + + @system.get("/heartbeat") + def heartbeat(request: Request): + return _svc(request).heartbeat() + + @system.get("/capabilities") + def capabilities(request: Request): + return _svc(request).capabilities() + + @system.get("/version") + def version(request: Request): + return _svc(request).version() + + @system.get("/auth_status") + def auth_status(request: Request): + config = request.app.state.config + return {"auth_enabled": config.auth_enabled, "bind_to_tls": False, "scheme": "bearer"} + + @system.get("/faults") + def faults(request: Request, since: int = 0, limit: int = 100): + return _svc(request).faults_since(since, limit) + + @system.post("/reserve") + def reserve(): + _not_implemented("reserve") + + @system.post("/release") + def release(): + _not_implemented("release") + + @system.post("/shutdown") + def shutdown(): + _not_implemented("shutdown") + + motion = APIRouter(prefix="/v1/motion", tags=["motion"]) + + @motion.get("/position") + def position(request: Request): + return _svc(request).get_position() + + @motion.post("/move") + def move(request: Request, body: MoveRequest): + return _svc(request).move(body) + + @motion.post("/home") + def home(request: Request): + return _svc(request).home() + + imaging = APIRouter(prefix="/v1/imaging", tags=["imaging"]) + + @imaging.get("/channels") + def channels(request: Request): + return _svc(request).list_channels() + + @imaging.post("/channel") + def select_channel(request: Request, body: ChannelSelectRequest): + return _svc(request).select_channel(body.name) + + @imaging.post("/exposure") + def exposure(request: Request, body: ExposureRequest): + return _svc(request).set_exposure(body) + + @imaging.post("/intensity") + def intensity(request: Request, body: IntensityRequest): + return _svc(request).set_intensity(body) + + @imaging.post("/illumination/on") + def illumination_on(request: Request): + return _svc(request).illumination(True) + + @imaging.post("/illumination/off") + def illumination_off(request: Request): + return _svc(request).illumination(False) + + @imaging.get("/objectives") + def objectives(request: Request): + return _svc(request).get_objectives() + + @imaging.get("/objective") + def get_objective(request: Request): + return {"objective": _svc(request).get_objectives()["current"]} + + @imaging.post("/objective") + def set_objective(request: Request, body: ObjectiveRequest): + return _svc(request).set_objective(body.name) + + @imaging.post("/acquire") + def acquire(request: Request, body: AcquireRequest = AcquireRequest()): + return _svc(request).acquire(body) + + @imaging.post("/live/start") + def live_start(request: Request): + return _svc(request).live(True) + + @imaging.post("/live/stop") + def live_stop(request: Request): + return _svc(request).live(False) + + autofocus = APIRouter(prefix="/v1/autofocus", tags=["autofocus"]) + + @autofocus.post("/run") + def af_run(request: Request, body: AutofocusRunRequest = AutofocusRunRequest()): + return _svc(request).autofocus_run(body) + + @autofocus.get("/status") + def af_status(request: Request): + return _svc(request).autofocus_status() + + @autofocus.post("/store_reference") + def af_store_reference(request: Request): + return _svc(request).autofocus_store_reference() + + @autofocus.post("/correct") + def af_correct(request: Request, body: AutofocusCorrectRequest = AutofocusCorrectRequest()): + return _svc(request).autofocus_correct(body) + + @autofocus.post("/acquire_image") + def af_acquire_image(request: Request, body: LaserAfImageRequest = LaserAfImageRequest()): + return _svc(request).autofocus_acquire_image(body) + + acquisitions = APIRouter(prefix="/v1/acquisitions", tags=["acquisitions"]) + + @acquisitions.post("/preflight") + def preflight(request: Request, body: AcquisitionRequest): + return _svc(request).preflight(body) + + @acquisitions.post("", status_code=202) + def create_acquisition(request: Request, response: Response, body: AcquisitionRequest): + handle = _svc(request).start_acquisition(body) + response.headers["Location"] = f"/v1/jobs/{handle['job_id']}" + return handle + + jobs = APIRouter(prefix="/v1/jobs", tags=["jobs"]) + + @jobs.get("/last") # MUST precede /{job_id} + def last_job(request: Request): + return _svc(request).last_job() + + @jobs.get("/{job_id}") + def get_job(request: Request, job_id: str): + return _svc(request).get_job(job_id) + + @jobs.post("/{job_id}/abort") + def abort_job(request: Request, job_id: str, body: Optional[AbortRequest] = None): + timeout_s = body.timeout_s if body is not None else 60.0 + return _svc(request).abort_job(job_id, timeout_s=timeout_s) + + @jobs.post("/{job_id}/emergency_stop") + def emergency_stop(job_id: str): + _not_implemented("emergency_stop") + + methods = APIRouter(prefix="/v1/methods", tags=["methods"]) + + @methods.get("") + def list_methods(request: Request): + return _svc(request).list_methods() + + @methods.get("/{name}") + def get_method(request: Request, name: str): + return _svc(request).get_method(name) + + @methods.post("", status_code=201) + def create_method(request: Request, body: MethodCreateRequest): + return _svc(request).create_method(body.name, body.config) + + @methods.put("/{name}") + def update_method(request: Request, name: str, body: MethodUpdateRequest): + return _svc(request).update_method(name, body.config) + + @methods.delete("/{name}") + def delete_method(request: Request, name: str): + return _svc(request).delete_method(name) + + @methods.post("/{name}/validate") + def validate_method(request: Request, name: str): + return _svc(request).validate_method(name) + + debug = APIRouter(prefix="/v1/debug", tags=["debug"]) + + @debug.post("/python_exec") + def python_exec(request: Request, body: PythonExecRequest): + return _svc(request).python_exec(body.code) + + @debug.get("/python_exec/status") + def python_exec_status(request: Request): + return _svc(request).python_exec_status() + + @debug.get("/settings") + def get_debug_settings(request: Request): + return _svc(request).debug_settings() + + @debug.post("/settings") + def set_debug_settings(request: Request, body: DebugSettingsRequest): + return _svc(request).set_debug_settings(body) + + return [meta, system, motion, imaging, autofocus, acquisitions, jobs, methods, debug] diff --git a/software/squid_service/rest/server.py b/software/squid_service/rest/server.py new file mode 100644 index 000000000..8c1715427 --- /dev/null +++ b/software/squid_service/rest/server.py @@ -0,0 +1,34 @@ +"""Run the REST app on uvicorn inside a daemon thread of the GUI process.""" + +import threading +from typing import Optional + +import uvicorn + +import squid.logging + + +class CoreServiceServer: + def __init__(self, app, host: str, port: int): + self._log = squid.logging.get_logger(self.__class__.__name__) + self._config = uvicorn.Config(app, host=host, port=port, log_level="warning", lifespan="off") + self._server = uvicorn.Server(self._config) + self._thread: Optional[threading.Thread] = None + + def start(self) -> None: + if self.is_running(): + return + self._server = uvicorn.Server(self._config) # uvicorn servers are single-use + self._thread = threading.Thread(target=self._server.run, daemon=True, name="SquidCoreService") + self._thread.start() + self._log.info(f"Core service REST API on http://{self._config.host}:{self._config.port}") + + def stop(self) -> None: + if not self.is_running(): + return + self._server.should_exit = True + self._thread.join(timeout=5.0) + self._log.info("Core service REST API stopped") + + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() diff --git a/software/squid_service/rest/sse.py b/software/squid_service/rest/sse.py new file mode 100644 index 000000000..f47305d59 --- /dev/null +++ b/software/squid_service/rest/sse.py @@ -0,0 +1,93 @@ +"""SSE endpoint (spec §2.6): session_started, Last-Event-Id replay, resume_gap, live tail. + +The stream is exposed as a standalone async generator (``sse_event_stream``) rather +than a closure so it can be driven directly in tests. Both Starlette's ``TestClient`` +and httpx's ``ASGITransport`` buffer the *entire* ASGI response before returning and +only deliver ``http.disconnect`` after the response has completed. An infinite SSE +generator therefore never terminates through those transports (its ``while True`` +tail loop waits for a disconnect that can only arrive once the response completes, +which can only happen once the loop ends) -> deadlock. Tests iterate +``sse_event_stream`` directly, read a bounded number of events, then close the +generator, which runs the ``finally`` that unsubscribes from the bus. +""" + +import asyncio +import functools +import json +import queue +from typing import AsyncIterator, Awaitable, Callable, Optional + +from fastapi import APIRouter, Request +from sse_starlette.sse import EventSourceResponse + + +async def sse_event_stream( + service, + raw_last: Optional[str], + is_disconnected: Callable[[], Awaitable[bool]], +) -> AsyncIterator[dict]: + """Yield SSE dicts: session_started, optional replay + resume_gap, then live tail. + + Subscribes to the bus BEFORE computing the replay so no event published between + subscribe and the first yield is missed. ``is_disconnected`` (production: + ``request.is_disconnected``) is re-checked every loop iteration so the live tail + stops promptly when the client goes away. The ``finally`` always unsubscribes, + so closing the generator (client disconnect, cancellation, or ``aclose()``) + releases the subscription. + """ + bus = service.events + q = bus.subscribe() # subscribe BEFORE replay so nothing is missed + yielded_up_to = 0 + try: + yield { + "id": str(bus.last_event_id), + "event": "session_started", + "data": json.dumps( + { + "session_id": bus.session_id, + "current_state": service.state.value, + "last_event_id": bus.last_event_id, + } + ), + } + if raw_last is not None: + try: + last_id = int(raw_last) + except ValueError: + last_id = 0 + missed, gap = bus.replay_since(last_id) + if gap: + yield { + "id": str(bus.last_event_id), + "event": "resume_gap", + "data": json.dumps({"last_event_id": bus.last_event_id}), + } + for ev in missed: + yielded_up_to = ev.id + yield {"id": str(ev.id), "event": ev.event, "data": json.dumps(ev.data)} + loop = asyncio.get_running_loop() + while True: + if await is_disconnected(): + break + try: + ev = await loop.run_in_executor(None, functools.partial(q.get, timeout=0.5)) + except queue.Empty: + continue + if ev.id <= yielded_up_to: # already delivered via replay + continue + yield {"id": str(ev.id), "event": ev.event, "data": json.dumps(ev.data)} + finally: + bus.unsubscribe(q) + + +def build_sse_router() -> APIRouter: + router = APIRouter(tags=["events"]) + + @router.get("/v1/events") + async def events(request: Request): + service = request.app.state.service + return EventSourceResponse( + sse_event_stream(service, request.headers.get("last-event-id"), request.is_disconnected) + ) + + return router diff --git a/software/squid_service/service.py b/software/squid_service/service.py new file mode 100644 index 000000000..3e3c3b9ea --- /dev/null +++ b/software/squid_service/service.py @@ -0,0 +1,1775 @@ +"""Transport-agnostic core service facade over the Microscope stack.""" + +import dataclasses +import json +import math +import os +import shutil +import threading +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Optional + +import yaml as _yaml + +import squid.logging +from squid_service import faults as F +from squid_service.events import EventBus +from squid_service.gui_bridge import GuiBridge +from squid_service.jobs import JobOutcome, JobResult, JobState, JobStore +from squid_service.methods import MethodRegistry +from squid_service.models import ( + AcquireRequest, + AcquisitionRequest, + AutofocusCorrectRequest, + AutofocusRunRequest, + DebugSettingsRequest, + ExposureRequest, + IntensityRequest, + LaserAfImageRequest, + MoveRequest, + ZMillimeters, +) +from squid_service.state import InstrumentState, StateMachine +from squid_service.timeutil import utc_now_iso +from squid_service.wells import parse_well_names, well_center_mm + +API_VERSION = "v1" + + +class SquidCoreService: + def __init__( + self, + microscope, + multipoint_controller=None, + scan_coordinates=None, + gui_bridge: Optional[GuiBridge] = None, + simulation: bool = False, + initial_state: InstrumentState = InstrumentState.INITIALIZED, + job_persist_path: Optional[Path] = None, + methods_dir: Optional[Path] = None, + ): + self._log = squid.logging.get_logger(self.__class__.__name__) + self._microscope = microscope + self._mpc = multipoint_controller + self._scan_coordinates = scan_coordinates + self._gui_bridge = gui_bridge or GuiBridge(None) + self._simulation = simulation + self.events = EventBus() + self.fault_log = F.FaultLog() + self.jobs = JobStore(persist_path=job_persist_path) + self.methods = MethodRegistry(methods_dir) if methods_dir is not None else None + self._state = StateMachine(initial_state, on_transition=self._on_state_changed) + self._command_lock = threading.Lock() + self._python_exec_enabled = False + self._acq_stats = None # last AcquisitionStats from the worker + # Per-acquisition observer state (set on start, updated on the worker thread). + self._api_yaml_data = None + self._acq_t0 = time.monotonic() + self._images_seen = 0 + self._last_progress_pub = 0.0 + if self._mpc is not None: + self._wrap_controller_callbacks() + + # ---- infrastructure ------------------------------------------------- + + @property + def state(self) -> InstrumentState: + return self._state.state + + def _on_state_changed(self, old: InstrumentState, new: InstrumentState) -> None: + self.events.publish("state_changed", {"old": old.value, "new": new.value, "at": utc_now_iso()}) + + def _record_fault(self, fault: F.Fault) -> F.Fault: + stamped = self.fault_log.record(fault) + self.events.publish("fault", stamped.model_dump()) + return stamped + + def _fail(self, fault: F.Fault): + raise F.FaultError(self._record_fault(fault)) + + @contextmanager + def _exclusive(self, component: str): + """Serialize state-changing commands; only allow them from INITIALIZED (spec §3). + + Rejecting every non-INITIALIZED state (not just BUSY_STATES) is deliberate: + it keeps moves/imaging/autofocus/acquisitions out of ERROR (URS API-LIFE-003). + Otherwise ``start_acquisition`` would create a job while in ERROR and then die + on the illegal ERROR->ACQUIRING transition, orphaning that job behind a raw 500. + Only ``reset()``/``initialize()`` recover from ERROR, and neither uses this guard. + """ + if not self._command_lock.acquire(blocking=False): + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_WRONG_STATE, + "Another state-changing command is in flight", + component=component, + detail={"current_state": self.state.value}, + ) + ) + try: + if self.state != InstrumentState.INITIALIZED: + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_WRONG_STATE, + f"Command not allowed while {self.state.value}", + component=component, + detail={"current_state": self.state.value}, + ) + ) + yield + finally: + self._command_lock.release() + + # ---- system ---------------------------------------------------------- + + def status(self) -> dict: + active = self.jobs.active + last = self.jobs.last + latest_fault = self.fault_log.latest + result = { + "state": self.state.value, + "current_job_id": active.job_id if active else None, + "latest_fault": latest_fault.model_dump() if latest_fault else None, + "last_acquisition": ( + { + "job_id": last.job_id, + "outcome": last.outcome.value if last.outcome else None, + "completed_at": last.completed_at, + } + if last and last.completed_at + else None + ), + "session_id": self.events.session_id, + "server_time": utc_now_iso(), + } + if active is not None: + result["acquisition"] = active.progress.model_dump() + return result + + def heartbeat(self) -> dict: + return {"alive": True, "monotonic_ns": time.monotonic_ns(), "state": self.state.value} + + def _firmware_version_str(self) -> str: + try: + firmware_version = self._microscope.low_level_drivers.microcontroller.firmware_version + return f"{firmware_version[0]}.{firmware_version[1]}" + except Exception: + return "unknown" + + def capabilities(self) -> dict: + from control.utils import get_squid_repo_state_description + + scope = self._microscope + objective = scope.objective_store.current_objective + stage_config = scope.stage.get_config() + channels = scope.live_controller.get_channels(objective) or [] + return { + "channels": [{"name": ch.name} for ch in channels], + "objectives": [ + {"name": name, "magnification": info.get("magnification"), "na": info.get("NA")} + for name, info in scope.objective_store.objectives_dict.items() + ], + "current_objective": objective, + "stage": { + "x_range_mm": [stage_config.X_AXIS.MIN_POSITION, stage_config.X_AXIS.MAX_POSITION], + "y_range_mm": [stage_config.Y_AXIS.MIN_POSITION, stage_config.Y_AXIS.MAX_POSITION], + "z_range_mm": [stage_config.Z_AXIS.MIN_POSITION, stage_config.Z_AXIS.MAX_POSITION], + }, + "camera": { + "model": type(scope.camera).__name__, + "sensor_size_px": list(scope.camera.get_resolution()), + "pixel_size_um": scope.camera.get_pixel_size_binned_um(), + }, + "reflection_af_hardware": scope.addons.camera_focus is not None, + "simulation": self._simulation, + "api_version": API_VERSION, + # URS API-DESC-002: surface the same version info as version(). + "software_version": get_squid_repo_state_description(), + "firmware_version": self._firmware_version_str(), + } + + def version(self) -> dict: + from control.utils import get_squid_repo_state_description + + return { + "software_version": get_squid_repo_state_description(), + "api_version": API_VERSION, + "firmware_version": self._firmware_version_str(), + } + + def sample_formats(self) -> dict: + """URS API-LAB-001: list every known sample/wellplate format and its layout. + + Mirrors ``control._def.WELLPLATE_FORMAT_SETTINGS`` (populated by + ``read_sample_formats_csv``/``load_formats``, ~control/_def.py:1128-1170), + accessed via the module (not a top-level import) so MCP-driven cache + reloads are reflected without restarting the service. + """ + import control._def + + return { + "formats": [ + { + "name": name, + "rows": settings["rows"], + "cols": settings["cols"], + "well_spacing_mm": settings["well_spacing_mm"], + "well_size_mm": settings["well_size_mm"], + "a1_x_mm": settings["a1_x_mm"], + "a1_y_mm": settings["a1_y_mm"], + } + for name, settings in control._def.WELLPLATE_FORMAT_SETTINGS.items() + ] + } + + def initialize(self, home: bool = False) -> dict: + """Recover to INITIALIZED, verifying read-only subsystem access (URS API-LIFE-002). + + With `home=False` and the instrument already INITIALIZED, this is a + pure no-op (no probes, no state transition). Otherwise it transitions + through INITIALIZING, probes stage/camera/mcu, optionally homes, then + returns to INITIALIZED. A probe failure moves the instrument to ERROR + and raises a HARDWARE_FAULT naming the failed component. + """ + started = time.monotonic() + if self.state == InstrumentState.INITIALIZED and not home: + return { + "state": self.state.value, + "no_op": True, + "duration_s": time.monotonic() - started, + "verified_components": [], + "home_performed": False, + } + + if not self._command_lock.acquire(blocking=False): + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_WRONG_STATE, + "Another state-changing command is in flight", + component="system", + detail={"current_state": self.state.value}, + ) + ) + try: + if self.state not in (InstrumentState.INITIALIZED, InstrumentState.ERROR): + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_WRONG_STATE, + f"initialize not allowed from {self.state.value}", + detail={"current_state": self.state.value}, + ) + ) + self._state.transition(InstrumentState.INITIALIZING) + + verified_components = [] + probes = ( + ("stage", lambda: self._microscope.stage.get_pos()), + ("camera", lambda: self._microscope.camera.get_resolution()), + ("mcu", lambda: self._microscope.low_level_drivers.microcontroller.firmware_version), + ) + for component, probe in probes: + try: + probe() + except Exception as e: + self._state.transition(InstrumentState.ERROR) + self._fail( + F.make_fault( + F.FaultCategory.HARDWARE_FAULT, + F.HARDWARE_FAULT_GENERIC, + f"{component} probe failed during initialize: {e}", + component=component, + detail={"verified_components": list(verified_components)}, + ) + ) + verified_components.append(component) + + home_performed = False + if home: + try: + self._microscope.home_xyz() + except Exception as e: + self._state.transition(InstrumentState.ERROR) + self._fail( + F.make_fault( + F.FaultCategory.HARDWARE_FAULT, + F.HARDWARE_FAULT_GENERIC, + f"Homing failed during initialize: {e}", + component="stage", + detail={"verified_components": list(verified_components)}, + ) + ) + home_performed = True + + self._state.transition(InstrumentState.INITIALIZED) + return { + "state": self.state.value, + "no_op": False, + "duration_s": time.monotonic() - started, + "verified_components": verified_components, + "home_performed": home_performed, + } + finally: + self._command_lock.release() + + def reset(self) -> dict: + started = time.monotonic() + if self.state == InstrumentState.INITIALIZED: + return {"state": self.state.value, "no_op": True, "duration_s": time.monotonic() - started} + if self.state == InstrumentState.ERROR: + if self._mpc is not None and self._mpc.acquisition_in_progress(): + self._mpc.request_abort_aquisition() # misspelling is the real API + self._state.transition(InstrumentState.RECOVERING) + self._state.transition(InstrumentState.INITIALIZED) + return {"state": self.state.value, "no_op": False, "duration_s": time.monotonic() - started} + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_WRONG_STATE, + f"reset not allowed from {self.state.value}", + detail={"current_state": self.state.value}, + ) + ) + + def faults_since(self, seq: int, limit: int = 100) -> dict: + return {"faults": [f.model_dump() for f in self.fault_log.since(seq, limit)]} + + # ---- motion ---------------------------------------------------------- + + def get_position(self) -> dict: + pos = self._microscope.stage.get_pos() + return {"x_mm": pos.x_mm, "y_mm": pos.y_mm, "z_mm": pos.z_mm} + + def _check_limits(self, axis: str, target: float) -> None: + cfg = self._microscope.stage.get_config() + axis_cfg = {"x": cfg.X_AXIS, "y": cfg.Y_AXIS, "z": cfg.Z_AXIS}[axis] + if not (axis_cfg.MIN_POSITION <= target <= axis_cfg.MAX_POSITION): + self._fail( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_OUT_OF_RANGE, + f"{axis} target {target:.3f} mm outside " f"[{axis_cfg.MIN_POSITION}, {axis_cfg.MAX_POSITION}]", + component=f"stage.{axis}", + detail={"axis": axis, "target_mm": target}, + ) + ) + + def move(self, req: MoveRequest) -> dict: + with self._exclusive("stage"): + pos = self._microscope.stage.get_pos() + if req.mode == "absolute": + targets = {"x": req.x, "y": req.y, "z": req.z} + else: + targets = { + "x": pos.x_mm + req.x if req.x is not None else None, + "y": pos.y_mm + req.y if req.y is not None else None, + "z": pos.z_mm + req.z if req.z is not None else None, + } + for axis, target in targets.items(): + if target is not None: + self._check_limits(axis, target) + blocking = req.block_until_complete + if targets["x"] is not None: + self._microscope.move_x_to(targets["x"], blocking=blocking) + if targets["y"] is not None: + self._microscope.move_y_to(targets["y"], blocking=blocking) + if targets["z"] is not None: + self._microscope.move_z_to(targets["z"], blocking=blocking) + return {"position": self.get_position()} + + def home(self) -> dict: + with self._exclusive("stage"): + self._microscope.home_xyz() + return {"homed": True, "position": self.get_position()} + + # ---- imaging ---------------------------------------------------------- + + def _channel_or_fail(self, name: str): + objective = self._microscope.objective_store.current_objective + channel = self._microscope.live_controller.get_channel_by_name(objective, name) + if channel is None: + self._fail( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_UNKNOWN_CHANNEL, + f"Channel {name!r} not found for objective {objective!r}", + detail={"channel": name, "objective": objective}, + ) + ) + return channel + + def list_channels(self) -> dict: + objective = self._microscope.objective_store.current_objective + channels = self._microscope.live_controller.get_channels(objective) or [] + return { + "objective": objective, + "channels": [ + { + "name": ch.name, + "exposure_ms": ch.exposure_time, + "intensity": ch.illumination_intensity, + } + for ch in channels + ], + } + + def select_channel(self, name: str) -> dict: + with self._exclusive("imaging"): + channel = self._channel_or_fail(name) + self._microscope.live_controller.set_microscope_mode(channel) + return {"channel": name, "objective": self._microscope.objective_store.current_objective} + + def set_exposure(self, req: ExposureRequest) -> dict: + with self._exclusive("imaging"): + if req.channel is not None: + self._channel_or_fail(req.channel) + self._microscope.set_exposure_time(req.channel, req.exposure_ms) + else: + self._microscope.camera.set_exposure_time(req.exposure_ms) + return {"exposure_ms": req.exposure_ms, "channel": req.channel} + + def set_intensity(self, req: IntensityRequest) -> dict: + with self._exclusive("imaging"): + self._channel_or_fail(req.channel) + self._microscope.set_illumination_intensity(req.channel, req.intensity) + return {"channel": req.channel, "intensity": req.intensity} + + def illumination(self, on: bool) -> dict: + with self._exclusive("imaging"): + if on: + self._microscope.live_controller.turn_on_illumination() + else: + self._microscope.live_controller.turn_off_illumination() + return {"illumination": "on" if on else "off"} + + def get_objectives(self) -> dict: + store = self._microscope.objective_store + return {"objectives": list(store.objectives_dict.keys()), "current": store.current_objective} + + def set_objective(self, name: str) -> dict: + with self._exclusive("imaging"): + if name not in self._microscope.objective_store.objectives_dict: + self._fail( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_UNKNOWN_OBJECTIVE, + f"Objective {name!r} not found", + detail={"objective": name}, + ) + ) + self._microscope.set_objective(name) + changer = self._microscope.addons.objective_changer + if changer is not None: + changer.move_to_objective(name) + return {"objective": name} + + def acquire(self, req: AcquireRequest) -> dict: + with self._exclusive("imaging"): + if req.channel is not None: + channel = self._channel_or_fail(req.channel) + self._microscope.live_controller.set_microscope_mode(channel) + try: + image = self._microscope.acquire_image() + except RuntimeError as e: + self._fail( + F.make_fault( + F.FaultCategory.HARDWARE_TRANSIENT, + F.HARDWARE_TRANSIENT_TIMEOUT, + f"Image acquisition failed: {e}", + recoverable=True, + scheduler_action=F.SchedulerAction.RETRY, + component="camera", + ) + ) + result = {"acquired": True, "shape": list(image.shape), "dtype": str(image.dtype)} + if req.save_path: + directory = os.path.dirname(req.save_path) or "." + if not os.path.isdir(directory) or not os.access(directory, os.W_OK): + self._fail( + F.make_fault( + F.FaultCategory.IO, + F.IO_PATH_NOT_WRITABLE, + f"Directory not writable: {directory}", + detail={"path": req.save_path}, + ) + ) + result["saved_to"] = self._microscope.save_image(image, req.save_path) + return result + + def live(self, start: bool) -> dict: + with self._exclusive("imaging"): + if start: + self._microscope.start_live() + else: + self._microscope.stop_live() + return {"live": start} + + # ---- autofocus --------------------------------------------------------- + + def _require_af_hardware(self) -> None: + if self._microscope.addons.camera_focus is None: + self._fail( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_CAPABILITY_MISSING, + "No reflection-AF hardware on this instrument", + component="autofocus", + ) + ) + + def _af_controller_or_fail(self): + """Guard used by the reference/correction ops (URS API-AF-002/003): + hardware must be present and the controller must already be constructed + and initialized (autofocus_run/perform_laser_af is what lazily builds it). + """ + self._require_af_hardware() + controller = self._microscope.laser_autofocus_controller + if controller is None or not controller.is_initialized: + self._fail( + F.make_fault( + F.FaultCategory.AUTOFOCUS, + F.AUTOFOCUS_NOT_READY, + "Laser autofocus controller is not initialized", + recoverable=True, + scheduler_action=F.SchedulerAction.RETRY, + component="autofocus", + ) + ) + return controller + + def autofocus_status(self) -> dict: + """URS API-AF-001. `reference_set` mirrors `laser_af_properties.has_reference`, + the flag `LaserAutofocusController.set_reference()` sets to True and which + `move_to_target()` itself checks before running - i.e. the controller's own + notion of "a reference is usable", as opposed to the raw `reference_crop` + ndarray it also stores (which is an implementation detail used only for the + cross-correlation alignment check). + """ + controller = self._microscope.laser_autofocus_controller + available = self._microscope.addons.camera_focus is not None + initialized = bool(controller.is_initialized) if controller is not None else False + reference_set = bool(controller.laser_af_properties.has_reference) if controller is not None else False + + if not available: + readiness = "NO_HARDWARE" + elif not initialized: + readiness = "NOT_INITIALIZED" + elif not reference_set: + readiness = "NO_REFERENCE" + else: + readiness = "OK" + + return { + "available": available, + "initialized": initialized, + "reference_set": reference_set, + "readiness": readiness, + } + + def autofocus_run(self, req: AutofocusRunRequest) -> dict: + with self._exclusive("autofocus"): + self._require_af_hardware() + try: + ok = self._microscope.perform_laser_af(req.target_um) + except RuntimeError as e: + self._fail( + F.make_fault( + F.FaultCategory.AUTOFOCUS, + F.AUTOFOCUS_NOT_READY, + str(e), + recoverable=True, + scheduler_action=F.SchedulerAction.RETRY, + component="autofocus", + ) + ) + if not ok: + self._fail( + F.make_fault( + F.FaultCategory.AUTOFOCUS, + F.AUTOFOCUS_FAILURE, + "Reflection autofocus did not converge", + recoverable=True, + scheduler_action=F.SchedulerAction.RETRY, + component="autofocus", + ) + ) + return {"autofocus": "ok", "position": self.get_position()} + + def autofocus_store_reference(self) -> dict: + """URS API-AF-002: capture the current laser spot as the new reference.""" + with self._exclusive("autofocus"): + controller = self._af_controller_or_fail() + reference_set = controller.set_reference() + return {"reference_set": bool(reference_set)} + + def autofocus_correct(self, req: AutofocusCorrectRequest) -> dict: + """URS API-AF-003: measure drift from the stored reference and correct it + if it's within `threshold_um`; otherwise report it without moving so the + caller can decide (e.g. re-run initialize_auto/set_reference). + """ + with self._exclusive("autofocus"): + controller = self._af_controller_or_fail() + displacement_um = controller.measure_displacement() + if math.isnan(displacement_um): + self._fail( + F.make_fault( + F.FaultCategory.AUTOFOCUS, + F.AUTOFOCUS_FAILURE, + "Failed to measure laser AF displacement", + recoverable=True, + scheduler_action=F.SchedulerAction.RETRY, + component="autofocus", + ) + ) + if abs(displacement_um) > req.threshold_um: + return {"corrected": False, "displacement_um": displacement_um} + # move_to_target refuses (returns False) when the correction would exceed + # the laser-AF operating range; don't set a new reference off an unmoved + # stage and don't report success. + moved = controller.move_to_target(0.0) + if not moved: + return { + "corrected": False, + "displacement_um": displacement_um, + "reason": "displacement exceeds laser AF operating range", + } + controller.set_reference() + return {"corrected": True, "displacement_um": displacement_um} + + def autofocus_acquire_image(self, req: LaserAfImageRequest) -> dict: + """Restores the legacy TCP `_cmd_acquire_laser_af_image`: grab a frame + from the laser-AF camera, either the most recently captured frame + (`use_last_frame=True`, the default) or a freshly triggered one, with + the same optional TIFF/npy save behavior as `acquire()`. + """ + with self._exclusive("autofocus"): + self._require_af_hardware() + camera_focus = self._microscope.addons.camera_focus + + import numpy as np + + if req.use_last_frame: + frame = getattr(camera_focus, "_current_frame", None) + if frame is None: + self._fail( + F.make_fault( + F.FaultCategory.AUTOFOCUS, + F.AUTOFOCUS_NOT_READY, + "no frame captured yet; start the AF camera stream or set use_last_frame=false", + recoverable=True, + scheduler_action=F.SchedulerAction.RETRY, + component="autofocus", + ) + ) + image = np.squeeze(frame.frame) + else: + camera_focus.send_trigger() + try: + image = camera_focus.read_frame() + except RuntimeError as e: + image = None + error = e + else: + error = None + if image is None: + self._fail( + F.make_fault( + F.FaultCategory.HARDWARE_TRANSIENT, + F.HARDWARE_TRANSIENT_TIMEOUT, + f"Laser AF frame capture failed{f': {error}' if error else ''}", + recoverable=True, + scheduler_action=F.SchedulerAction.RETRY, + component="autofocus.camera", + ) + ) + + result = { + "acquired": True, + "used_last_frame": req.use_last_frame, + "shape": list(image.shape), + "dtype": str(image.dtype), + } + if req.save_path: + directory = os.path.dirname(req.save_path) or "." + if not os.path.isdir(directory) or not os.access(directory, os.W_OK): + self._fail( + F.make_fault( + F.FaultCategory.IO, + F.IO_PATH_NOT_WRITABLE, + f"Directory not writable: {directory}", + detail={"path": req.save_path}, + ) + ) + try: + import tifffile + + tifffile.imwrite(req.save_path, image) + result["saved_to"] = req.save_path + except ImportError: + np.save(req.save_path, image) + result["saved_to"] = req.save_path + ".npy" + return result + + # ---- acquisitions ------------------------------------------------------ + + _REASON_TO_OUTCOME = { + "completed": JobOutcome.SUCCESS, + "user_abort": JobOutcome.ABORTED, + "error": JobOutcome.FAILURE, + "completed_with_errors": JobOutcome.PARTIAL, + } + + def _wrap_controller_callbacks(self) -> None: + """Chain our observers onto the controller's callbacks. + + Originals always run first (the GUI keeps working); our handlers never + propagate exceptions into the acquisition worker. Wrapped ONCE at + construction: the controller later does its own ``dataclasses.replace`` + of ``signal_acquisition_finished`` at run time, but that copies (and thus + preserves) our already-chained callbacks. + """ + original = self._mpc.callbacks + + def chain(first, second): + def call(*args, **kwargs): + try: + first(*args, **kwargs) + finally: + try: + second(*args, **kwargs) + except Exception: + self._log.exception("core-service acquisition observer failed") + + return call + + self._mpc.callbacks = dataclasses.replace( + original, + signal_acquisition_start=chain(original.signal_acquisition_start, self._on_acq_start), + signal_acquisition_finished=chain(original.signal_acquisition_finished, self._on_acq_finished), + signal_new_image=chain(original.signal_new_image, self._on_new_image), + signal_overall_progress=chain(original.signal_overall_progress, self._on_overall_progress), + signal_slack_timepoint_notification=chain( + original.signal_slack_timepoint_notification, self._on_timepoint_stats + ), + signal_slack_acquisition_finished=chain(original.signal_slack_acquisition_finished, self._on_acq_stats), + ) + + # -- observers (run on the acquisition worker thread) -- + + def _on_acq_start(self, params) -> None: + if self.state != InstrumentState.ACQUIRING: + self._state.transition(InstrumentState.ACQUIRING) + if self.jobs.active is None: + # GUI-started acquisition: track it so API clients see truthful state/jobs + self.jobs.create(experiment_id=getattr(params, "experiment_ID", None), origin="gui") + job = self.jobs.active + self._acq_t0 = time.monotonic() + self._images_seen = 0 + self._last_progress_pub = 0.0 + self.jobs.mark_running(job.job_id) + + def _on_new_image(self, frame, info) -> None: + job = self.jobs.active + if job is None: + return + self._images_seen += 1 + elapsed = time.monotonic() - self._acq_t0 + self.jobs.update_progress(job.job_id, images_acquired=self._images_seen, elapsed_s=elapsed) + now = time.monotonic() + if now - self._last_progress_pub >= 0.5: + self._last_progress_pub = now + progress = self.jobs.get(job.job_id).progress + self.events.publish("progress", {"job_id": job.job_id, **progress.model_dump()}) + + def _on_overall_progress(self, update) -> None: + job = self.jobs.active + if job is None: + return + self.jobs.update_progress( + job.job_id, + current_region=update.current_region, + total_regions=update.total_regions, + current_timepoint=update.current_timepoint, + total_timepoints=update.total_timepoints, + ) + + def _on_timepoint_stats(self, stats) -> None: + """Per-timepoint granularity: accumulate laser-AF failures across the run + (URS ERR-RES-001/003). Fires once per completed timepoint via Slack callback.""" + job = self.jobs.active + if job is None: + return + failures = getattr(stats, "laser_af_failures", 0) or 0 + if failures: + self.jobs.update_progress(job.job_id, af_failures=job.progress.af_failures + failures) + + def _on_acq_stats(self, stats) -> None: + self._acq_stats = stats + + def _derive_end_reason(self) -> str: + """Fallback used by _on_acq_finished when AcquisitionStats never arrived. + + multi_point_worker.py only calls ``signal_slack_acquisition_finished`` + (and ``signal_slack_timepoint_notification``) inside + ``if self._slack_notifier is not None:`` blocks. With no Slack notifier + configured -- the default -- ``self._acq_stats`` stays None and the real + end reason (e.g. "user_abort", "error") never reaches us, so every + acquisition would otherwise be reported as outcome SUCCESS. + + ``MultiPointWorker._compute_end_reason()`` (control/core/multi_point_worker.py:450) + is the worker's own authoritative classification: it reads + ``self._run_state_fatal``, ``self.abort_requested_fn()``, ``self._abort_cause``, + and ``self._acquisition_error_count`` and returns a string -- no mutation of + any state, so calling it again here is side-effect-free. It is invoked from + the ``finally`` block of ``MultiPointWorker.run()`` immediately before that + same block calls ``self.callbacks.signal_acquisition_finished()``, which is + what (via the chaining in ``_wrap_controller_callbacks``) eventually calls + this service's ``_on_acq_finished``. So by the time we get here the worker's + state is already final, and ``self._mpc.multiPointWorker`` has not yet been + cleared -- that only happens in ``MultiPointController.close()``, a separate + shutdown path -- so the reference is still valid. + """ + worker = getattr(self._mpc, "multiPointWorker", None) + if worker is not None: + try: + return worker._compute_end_reason() + except Exception: + self._log.exception("worker._compute_end_reason() failed; falling back") + return "user_abort" if getattr(self._mpc, "abort_acqusition_requested", False) else "completed" + + def _on_acq_finished(self) -> None: + job = self.jobs.active + stats = self._acq_stats + self._acq_stats = None + yaml_data = getattr(self, "_api_yaml_data", None) + self._api_yaml_data = None + + # A run_acquisition() validation failure fires finished WITHOUT start, + # so the job never reached RUNNING (started_at is None). + validation_failure = job is not None and job.started_at is None + # See _derive_end_reason: without a Slack notifier, `stats` stays None + # even for real runs, so fall back to asking the worker directly. Skip + # that lookup for a validation failure -- runtime_reason is unused + # there (that branch hardcodes end_reason="error" below), and + # multiPointWorker could still reference a *previous* completed run. + worker = None + if stats is not None: + runtime_reason = getattr(stats, "reason", "completed") + elif validation_failure: + runtime_reason = "completed" + else: + worker = getattr(self._mpc, "multiPointWorker", None) + runtime_reason = self._derive_end_reason() + # A runtime "error" drives the instrument to ERROR (URS ERR-STATE-001/002); + # a pre-start validation failure is recoverable and returns to INITIALIZED. + go_error = (not validation_failure) and runtime_reason == "error" + + try: + if self.state == InstrumentState.ACQUIRING: + self._state.transition(InstrumentState.PROCESSING) + if self.state == InstrumentState.PROCESSING: + self._state.transition(InstrumentState.ERROR if go_error else InstrumentState.INITIALIZED) + except Exception: + self._log.exception("state transition on acquisition finish failed") + + if job is None: + return + + if validation_failure: + fault = self._record_fault( + F.make_fault( + F.FaultCategory.ACQUISITION, + F.ACQUISITION_START_FAILED, + "Acquisition failed controller validation before starting", + terminal=True, + component="acquisition", + ) + ) + completed = self.jobs.complete(job.job_id, JobOutcome.FAILURE, JobResult(end_reason="error"), fault=fault) + else: + outcome = self._REASON_TO_OUTCOME.get(runtime_reason, JobOutcome.SUCCESS) + output_dir = None + if self._mpc.base_path and self._mpc.experiment_ID: + output_dir = os.path.join(self._mpc.base_path, self._mpc.experiment_ID) + if stats is not None: + errors_encountered = getattr(stats, "errors_encountered", 0) + image_count_written = getattr(stats, "total_images", self._images_seen) + else: + # No stats (no Slack notifier): fall back to the worker's own + # error counter and the service's own per-image counted total. + errors_encountered = getattr(worker, "_acquisition_error_count", 0) + image_count_written = self._images_seen + result = JobResult( + output_dir=output_dir, + image_count_written=image_count_written, + partial_write=outcome is not JobOutcome.SUCCESS, + errors_encountered=errors_encountered, + end_reason=runtime_reason, + ) + # save_failures mirrors the terminal error count (URS API-POLL-001). + progress_update = {"save_failures": errors_encountered} + if stats is None: + # signal_slack_timepoint_notification (which normally accumulates + # af_failures across the run via _on_timepoint_stats) is gated the + # same way, so without a notifier job.progress.af_failures never + # moves off 0. Merge the worker's own running total in now. + af_failures = getattr(worker, "_laser_af_failures", 0) or 0 + if af_failures > job.progress.af_failures: + progress_update["af_failures"] = af_failures + self.jobs.update_progress(job.job_id, **progress_update) + fault = None + if go_error: + fault = self._record_fault( + F.make_fault( + F.FaultCategory.ACQUISITION, + F.ACQUISITION_RUNTIME, + "Acquisition failed during the run", + terminal=True, + component="acquisition", + ) + ) + completed = self.jobs.complete(job.job_id, outcome, result, fault=fault) + + if yaml_data is not None: + self._gui_bridge.set_acquisition_state(yaml_data, running=False) + self.events.publish( + "job_completed", + { + "job_id": completed.job_id, + "outcome": completed.outcome.value if completed.outcome else None, + "completed_at": completed.completed_at, + }, + ) + + # -- source resolution & checks -- + + def _load_yaml_or_fault(self, yaml_path: str): + from control.acquisition_yaml_loader import parse_acquisition_yaml + + if not yaml_path or not os.path.exists(yaml_path): + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + f"YAML file not found: {yaml_path}", + detail={"yaml_path": yaml_path}, + ) + ) + try: + yaml_data = parse_acquisition_yaml(yaml_path) + with open(yaml_path, "r", encoding="utf-8") as f: + raw = _yaml.safe_load(f) or {} + except F.FaultError: + raise + except Exception as e: + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + f"Failed to parse YAML: {e}", + detail={"yaml_path": yaml_path}, + ) + ) + return yaml_data, raw + + def _resolve_yaml_path(self, req: AcquisitionRequest) -> str: + """Resolve a method name to its server-side YAML; a raw yaml_path passes through.""" + if req.method is not None: + if self.methods is None: + raise F.FaultError( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_CAPABILITY_MISSING, + "Method registry not attached; cannot run by method name", + detail={"method": req.method}, + ) + ) + return str(self.methods.path_for(req.method)) + return req.yaml_path + + def _output_path_check(self, req: AcquisitionRequest, ctx: dict): + import control._def + + def check_output_path(): + base = req.overrides.output_path or getattr(control._def, "DEFAULT_SAVING_PATH", None) + if not base: + raise F.FaultError( + F.make_fault(F.FaultCategory.IO, F.IO_GENERIC, "No output path and no DEFAULT_SAVING_PATH") + ) + ctx["base_path"] = base + probe = base + while probe and not os.path.isdir(probe): + parent = os.path.dirname(probe) + if parent == probe: + break + probe = parent + if not probe or not os.access(probe, os.W_OK): + raise F.FaultError( + F.make_fault( + F.FaultCategory.IO, + F.IO_PATH_NOT_WRITABLE, + f"Output path not writable: {base}", + detail={"output_path": base}, + ) + ) + ctx["free_bytes"] = shutil.disk_usage(probe).free + + return check_output_path + + def _yaml_checks(self, req: AcquisitionRequest): + """Ordered (name, callable) checks for a yaml_path/method acquisition. + + Each callable raises FaultError on failure and threads context to later + checks via the shared ``ctx`` dict.""" + import control._def + from control.acquisition_yaml_loader import validate_hardware + + ctx = {} + + def check_yaml(): + yaml_path = self._resolve_yaml_path(req) + ctx["yaml_path"] = yaml_path + ctx["yaml_data"], ctx["raw"] = self._load_yaml_or_fault(yaml_path) + + def check_widget_type(): + if ctx["yaml_data"].widget_type != "wellplate": + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + "Only wellplate-mode YAMLs are supported by the API " + f"(got widget_type={ctx['yaml_data'].widget_type!r})", + ) + ) + + def check_hardware(): + try: + binning = tuple(self._microscope.camera.get_binning()) + except Exception: + binning = (1, 1) + validation = validate_hardware( + ctx["yaml_data"], self._microscope.objective_store.current_objective, binning + ) + if not validation.is_valid: + raise F.FaultError( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_HARDWARE_MISMATCH, + f"Hardware configuration mismatch: {validation.message}", + ) + ) + + def check_channels(): + objective = self._microscope.objective_store.current_objective + available = {ch.name for ch in (self._microscope.live_controller.get_channels(objective) or [])} + if not ctx["yaml_data"].channel_names: + raise F.FaultError( + F.make_fault(F.FaultCategory.INVALID_PARAM, F.INVALID_PARAM_BAD_VALUE, "YAML has no channels") + ) + invalid = [ch for ch in ctx["yaml_data"].channel_names if ch not in available] + if invalid: + raise F.FaultError( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_UNKNOWN_CHANNEL, + f"Invalid channels: {invalid}. Available: {sorted(available)}", + detail={"invalid": invalid}, + ) + ) + + def check_regions(): + # sample_format override (URS API-LAB-002): validate before any hardware call. + if req.overrides.sample_format: + try: + control._def.get_wellplate_settings(req.overrides.sample_format) + except ValueError as e: + raise F.FaultError(F.make_fault(F.FaultCategory.INVALID_PARAM, F.INVALID_PARAM_BAD_VALUE, str(e))) + # Region precedence (URS wells-by-name): overrides.wells -> yaml wells -> + # yaml regions -> fault. Both wells sources derive X/Y from the plate. + effective_wells = req.overrides.wells or ctx["yaml_data"].wells + if effective_wells: + fmt = req.overrides.sample_format or ctx["raw"].get("sample", {}).get( + "wellplate_format", "96 well plate" + ) + try: + settings = control._def.get_wellplate_settings(fmt) + for name in parse_well_names(effective_wells): + well_center_mm(name, settings) + except ValueError as e: + raise F.FaultError(F.make_fault(F.FaultCategory.INVALID_PARAM, F.INVALID_PARAM_BAD_VALUE, str(e))) + elif not ctx["yaml_data"].wellplate_regions: + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + "No regions in YAML and no wells override provided", + ) + ) + + checks = [ + ("yaml", check_yaml), + ("widget_type", check_widget_type), + ("hardware", check_hardware), + ("channels", check_channels), + ("regions", check_regions), + # Effective AF flags read lazily from the parsed yaml (ctx populated by + # check_yaml) so the request-override precedence matches the run. + ( + "z_reference", + self._z_reference_check(req, lambda: (ctx["yaml_data"].laser_af, ctx["yaml_data"].contrast_af)), + ), + ("output_path", self._output_path_check(req, ctx)), + ] + return checks, ctx + + def _grid_checks(self, req: AcquisitionRequest): + """Ordered checks for a grid acquisition (URS API-COMPAT-002 parity).""" + import control._def + + grid = req.grid + ctx = {} + + def check_channels(): + objective = self._microscope.objective_store.current_objective + available = {ch.name for ch in (self._microscope.live_controller.get_channels(objective) or [])} + invalid = [c for c in grid.channels if c not in available] + if invalid: + raise F.FaultError( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_UNKNOWN_CHANNEL, + f"Invalid channels: {invalid}. Available: {sorted(available)}", + detail={"invalid": invalid}, + ) + ) + + def check_wellplate_format(): + try: + ctx["settings"] = control._def.get_wellplate_settings(grid.wellplate_format) + except ValueError as e: + raise F.FaultError(F.make_fault(F.FaultCategory.INVALID_PARAM, F.INVALID_PARAM_BAD_VALUE, str(e))) + + def check_regions(): + settings = ctx.get("settings") or control._def.get_wellplate_settings(grid.wellplate_format) + try: + for name in parse_well_names(grid.wells): + well_center_mm(name, settings) + except ValueError as e: + raise F.FaultError(F.make_fault(F.FaultCategory.INVALID_PARAM, F.INVALID_PARAM_BAD_VALUE, str(e))) + + checks = [ + ("channels", check_channels), + ("wellplate_format", check_wellplate_format), + ("regions", check_regions), + # Grid runs carry no AF flags of their own; the base is (False, False) and + # only request overrides can enable AF for a z_reference="autofocus" run. + ("z_reference", self._z_reference_check(req, lambda: (False, False))), + ("output_path", self._output_path_check(req, ctx)), + ] + return checks, ctx + + def _acquisition_checks(self, req: AcquisitionRequest): + if req.grid is not None: + return self._grid_checks(req) + return self._yaml_checks(req) + + def _run_checks_report(self, checks, ctx, skip_names=()) -> dict: + """Run checks, never raising for a check failure; report each as ok/failed/skipped. + + Once the ``yaml`` check fails there is no parsed YAML for the later checks to + read, so they are reported "skipped" rather than crashing on missing context. + """ + results = [] + ok = True + yaml_failed = False + for name, fn in checks: + if name in skip_names: + continue + if yaml_failed: + results.append({"name": name, "ok": False, "message": "skipped (yaml check failed)"}) + continue + try: + fn() + results.append({"name": name, "ok": True, "message": ""}) + except F.FaultError as e: + ok = False + yaml_failed = yaml_failed or name == "yaml" + results.append({"name": name, "ok": False, "message": e.fault.message}) + except Exception as e: + ok = False + yaml_failed = yaml_failed or name == "yaml" + results.append({"name": name, "ok": False, "message": str(e)}) + return {"ok": ok, "checks": results, "free_bytes": ctx.get("free_bytes")} + + def preflight(self, req: AcquisitionRequest) -> dict: + checks, ctx = self._acquisition_checks(req) + return self._run_checks_report(checks, ctx) + + # -- z-reference policy -- + + def _effective_af_flags(self, req: AcquisitionRequest, base_reflection: bool, base_contrast: bool): + """The (reflection, contrast) AF flags that will actually run, i.e. the yaml/grid + base with req.autofocus overrides applied (mirrors _apply_autofocus_override).""" + reflection, contrast = base_reflection, base_contrast + if req.autofocus: + if req.autofocus.reflection is not None: + reflection = req.autofocus.reflection + if req.autofocus.contrast is not None: + contrast = req.autofocus.contrast + return reflection, contrast + + def _z_reference_check(self, req: AcquisitionRequest, base_flags_fn): + """Build the named ``z_reference`` preflight check for this request. + + ``base_flags_fn`` returns the run's base (reflection, contrast) AF flags; it is + called lazily so a yaml-backed run can read the parsed flags from ``ctx``. + """ + + def check_z_reference(): + zref = req.z_reference + if isinstance(zref, ZMillimeters): + zcfg = self._microscope.stage.get_config().Z_AXIS + if not (zcfg.MIN_POSITION <= zref.z_mm <= zcfg.MAX_POSITION): + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_OUT_OF_RANGE, + f"z_reference.z_mm {zref.z_mm} outside stage Z limits " + f"[{zcfg.MIN_POSITION}, {zcfg.MAX_POSITION}]", + component="stage.z", + detail={"z_mm": zref.z_mm, "min": zcfg.MIN_POSITION, "max": zcfg.MAX_POSITION}, + ) + ) + elif zref == "autofocus": + reflection, contrast = self._effective_af_flags(req, *base_flags_fn()) + if not (reflection or contrast): + raise F.FaultError( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + "z_reference='autofocus' requires an autofocus mode enabled for this run " + "(set autofocus.reflection/contrast or the method's laser_af/contrast_af)", + component="z_reference", + ) + ) + if reflection and not self.autofocus_status()["reference_set"]: + raise F.FaultError( + F.make_fault( + F.FaultCategory.AUTOFOCUS, + F.AUTOFOCUS_NOT_READY, + "z_reference='autofocus' requires a stored reflection-AF reference; " + "store one (POST /v1/autofocus/store_reference) before acquiring", + component="autofocus", + ) + ) + if not reflection and getattr(self._mpc, "autofocusController", None) is None: + raise F.FaultError( + F.make_fault( + F.FaultCategory.AUTOFOCUS, + F.AUTOFOCUS_NOT_READY, + "z_reference='autofocus' requires the contrast-autofocus controller, " + "which is not attached to this service", + component="autofocus", + ) + ) + # "current": nothing to validate; z0 comes from the stage at run start. + + return check_z_reference + + def _resolve_z_reference(self, req: AcquisitionRequest) -> float: + """Return the Z baseline z0 for this run. z_mm limits are already enforced by + the ``z_reference`` preflight check that runs before this in start_acquisition.""" + zref = req.z_reference + if isinstance(zref, ZMillimeters): + return zref.z_mm + # "current" and "autofocus" both baseline on the current stage z. + return self._microscope.stage.get_pos().z_mm + + # -- controller configuration -- + + def _configure_regions(self, yaml_data, raw: dict, wells_override, sample_format_override, z0: float) -> None: + import control._def + + sc = self._scan_coordinates + sc.clear_regions() + scan_size = yaml_data.scan_size_mm or 2.0 + shape = yaml_data.scan_shape or "Square" + # Region precedence: overrides.wells -> yaml wells -> explicit yaml regions. + effective_wells = wells_override or yaml_data.wells + if effective_wells: + fmt = sample_format_override or raw.get("sample", {}).get("wellplate_format", "96 well plate") + settings = control._def.get_wellplate_settings(fmt) + for name in parse_well_names(effective_wells): + x, y = well_center_mm(name, settings) + sc.add_region( + well_id=name, + center_x=x, + center_y=y, + scan_size_mm=scan_size, + overlap_percent=yaml_data.overlap_percent, + shape=shape, + ) + if name in sc.region_centers: + sc.region_centers[name][2] = z0 + else: + for region in yaml_data.wellplate_regions: + name = region.get("name", "region") + center = region.get("center_mm", [0, 0, 0]) + sc.add_region( + well_id=name, + center_x=center[0], + center_y=center[1], + scan_size_mm=scan_size, + overlap_percent=yaml_data.overlap_percent, + shape=region.get("shape", shape), + ) + if name in sc.region_centers: + sc.region_centers[name][2] = center[2] if len(center) > 2 else z0 + sc.sort_coordinates() + + def _configure_grid_regions(self, grid, z0: float) -> None: + import control._def + + sc = self._scan_coordinates + sc.clear_regions() + settings = control._def.get_wellplate_settings(grid.wellplate_format) + for name in parse_well_names(grid.wells): + x, y = well_center_mm(name, settings) + sc.add_flexible_region( + region_id=name, + center_x=x, + center_y=y, + center_z=z0, + Nx=grid.nx, + Ny=grid.ny, + overlap_percent=grid.overlap_percent, + ) + sc.sort_coordinates() + + def _reset_z_range_and_focus_map(self, z0: float, nz: int, delta_z_um: float) -> None: + """Set z_range from the resolved baseline z0 and clear focus-map state. + + Mirrors the GUI's pre-run path (widgets.py toggle_acquisition ~6472-6487): the + GUI ALWAYS sets z_range = (baseline_z, baseline_z + span) regardless of + z_stacking_config -- i.e. z_range[0] is the baseline and the FROM CENTER / FROM + TOP shifts are performed by the worker at run time (multi_point_worker.py: + initialize_z_stack moves to z_range[0]; prepare_z_stack / move_z_back_after_stack + do the FROM CENTER half-stack shift per FOV). So we deliberately do NOT pre-shift + z_range for FROM CENTER here -- that would double-shift. The caller sets the + controller's z_stacking_config so the worker applies the shift. + + MultiPointController.run_acquisition only derives z_range when it is None + (multi_point_controller.py:701), so without this the first run's range would + leak into every later API run. The GUI likewise always calls set_z_range and + set_focus_map(None) before starting; the API exposes no focus-map option, so + we clear focus_map/gen_focus_map/use_manual_focus_map outright to prevent a + stale focus map from a GUI session from bleeding into an API acquisition. + """ + self._mpc.set_z_range(z0, z0 + delta_z_um / 1000.0 * (nz - 1)) + self._mpc.set_focus_map(None) + self._mpc.gen_focus_map = False + self._mpc.use_manual_focus_map = False + + def _configure_controller(self, yaml_data, z0: float) -> None: + self._mpc.set_NX(1) + self._mpc.set_NY(1) + self._mpc.set_NZ(yaml_data.nz) + self._mpc.set_deltaZ(yaml_data.delta_z_um) + self._mpc.set_Nt(yaml_data.nt) + self._mpc.set_deltat(yaml_data.delta_t_s) + self._mpc.do_autofocus = yaml_data.contrast_af + self._mpc.do_reflection_af = yaml_data.laser_af + self._mpc.use_piezo = yaml_data.use_piezo + # Honor the method's z_stacking_config so the worker applies FROM CENTER / FROM + # TOP shifts (the API path previously left this at the controller default). + self._mpc.z_stacking_config = yaml_data.z_stacking_config + self._mpc.set_selected_configurations(yaml_data.channel_names) + self._reset_z_range_and_focus_map(z0, yaml_data.nz, yaml_data.delta_z_um) + + def _configure_grid_controller(self, grid, z0: float) -> None: + import control._def + + self._mpc.set_NX(1) + self._mpc.set_NY(1) + self._mpc.set_NZ(1) + self._mpc.set_deltaZ(1.0) + self._mpc.set_Nt(1) + self._mpc.set_deltat(0.0) + self._mpc.do_autofocus = False + self._mpc.do_reflection_af = False + self._mpc.use_piezo = False + self._mpc.z_stacking_config = control._def.Z_STACKING_CONFIG # single plane; reset any stale value + self._mpc.set_selected_configurations(grid.channels) + self._reset_z_range_and_focus_map(z0, 1, 0.0) # grid mode: single z plane -> (z0, z0) + + def _apply_autofocus_override(self, req: AcquisitionRequest) -> None: + """URS API-ACQ-003: request overrides beat both YAML and grid defaults.""" + if req.autofocus: + if req.autofocus.reflection is not None: + self._mpc.do_reflection_af = req.autofocus.reflection + if req.autofocus.contrast is not None: + self._mpc.do_autofocus = req.autofocus.contrast + + def _write_api_request_json(self, req: AcquisitionRequest, output_dir: str, source: str) -> None: + """URS ERR-OBS-003/005: persist the originating request alongside the data. + Log-and-continue on failure so a write hiccup never blocks acquisition.""" + try: + payload = { + "operator": req.operator, + "scheduler_job_id": req.scheduler_job_id, + "experiment_id": self._mpc.experiment_ID, + "source": source, + "api_version": API_VERSION, + "software_version": self.version().get("software_version"), + "firmware_version": self._firmware_version_str(), + "accepted_at": utc_now_iso(), + } + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, "api_request.json"), "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + except Exception as e: + self._log.warning(f"Could not write api_request.json: {e}") + + # -- start / jobs -- + + def start_acquisition(self, req: AcquisitionRequest) -> dict: + if self._mpc is None or self._scan_coordinates is None: + self._fail( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_CAPABILITY_MISSING, + "Acquisition controller not attached to the core service", + ) + ) + with self._exclusive("acquisition"): + if self._mpc.acquisition_in_progress(): + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_WRONG_STATE, + "Acquisition already in progress", + detail={"current_state": self.state.value}, + ) + ) + checks, ctx = self._acquisition_checks(req) + try: + for _, fn in checks: + fn() + except F.FaultError as e: + raise F.FaultError(self._record_fault(e.fault)) + base_path = ctx["base_path"] + + # Resolve the Z baseline z0 for this run (z_reference policy). z_mm limits + # were already enforced by the z_reference check above. + z0 = self._resolve_z_reference(req) + if req.grid is not None: + grid = req.grid + self._configure_grid_regions(grid, z0) + self._configure_grid_controller(grid, z0) + channel_count = len(grid.channels) + nz, nt = 1, 1 + source = "grid" + yaml_data = None + else: + yaml_data, raw = ctx["yaml_data"], ctx["raw"] + self._configure_regions(yaml_data, raw, req.overrides.wells, req.overrides.sample_format, z0) + self._configure_controller(yaml_data, z0) + channel_count = len(yaml_data.channel_names) + nz, nt = yaml_data.nz, yaml_data.nt + source = req.method if req.method is not None else req.yaml_path + + self._apply_autofocus_override(req) + self._mpc.set_base_path(base_path) + self._mpc.start_new_experiment(req.experiment_id or "api_acquisition") + output_dir = os.path.join(base_path, self._mpc.experiment_ID) + self._write_api_request_json(req, output_dir, source) + + total_fovs = sum(len(v) for v in self._scan_coordinates.region_fov_coordinates.values()) + total_images = total_fovs * channel_count * nz * nt + job = self.jobs.create( + experiment_id=self._mpc.experiment_ID, + origin="api", + expected_total_images=total_images, + expected_total_regions=len(self._scan_coordinates.region_fov_coordinates), + expected_total_timepoints=nt, + operator=req.operator, + scheduler_job_id=req.scheduler_job_id, + ) + self._api_yaml_data = yaml_data + if yaml_data is not None: + self._gui_bridge.sync_yaml_to_widgets(yaml_data, ctx.get("yaml_path")) + self._gui_bridge.set_acquisition_state(yaml_data, running=True) + self._state.transition(InstrumentState.ACQUIRING) + try: + self._mpc.run_acquisition() + except Exception as e: + if self.state == InstrumentState.ACQUIRING: + self._state.transition(InstrumentState.INITIALIZED) + fault = self._record_fault( + F.make_fault( + F.FaultCategory.ACQUISITION, + F.ACQUISITION_START_FAILED, + f"Failed to start acquisition: {e}", + terminal=True, + component="acquisition", + ) + ) + self.jobs.complete(job.job_id, JobOutcome.FAILURE, JobResult(end_reason="error"), fault=fault) + raise F.FaultError(fault) + return { + "job_id": job.job_id, + "kind": "acquisition", + "experiment_id": self._mpc.experiment_ID, + "expected_fov_count": total_fovs, + "expected_image_count": total_images, + "output_dir": output_dir, + "accepted_at": job.accepted_at, + } + + def get_job(self, job_id: str) -> dict: + job = self.jobs.get(job_id) + if job is None: + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_UNKNOWN_RESOURCE, + f"Unknown job: {job_id}", + detail={"job_id": job_id}, + ) + ) + return job.model_dump() + + def last_job(self) -> dict: + job = self.jobs.last + if job is None: + self._fail(F.make_fault(F.FaultCategory.PROTOCOL, F.PROTOCOL_UNKNOWN_RESOURCE, "No completed job yet")) + return job.model_dump() + + def abort_job(self, job_id: str, timeout_s: float = 60.0) -> dict: + job = self.jobs.get(job_id) + if job is None: + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_UNKNOWN_RESOURCE, + f"Unknown job: {job_id}", + detail={"job_id": job_id}, + ) + ) + if job.state == JobState.COMPLETED: + return {"clean": job.outcome == JobOutcome.ABORTED, "timed_out": False, "job": job.model_dump()} + self._mpc.request_abort_aquisition() # controller API is misspelled; do not "fix" + finished = self.jobs.wait(job_id, timeout_s=timeout_s) + final = self.jobs.get(job_id) + return { + "clean": bool(finished and final.outcome == JobOutcome.ABORTED), + "timed_out": not finished, + "job": final.model_dump(), + } + + # -- named method registry (URS API-METH-001..005) -- + + def _require_methods(self) -> None: + if self.methods is None: + self._fail( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_CAPABILITY_MISSING, + "Method registry not attached to the core service", + ) + ) + + def list_methods(self) -> dict: + self._require_methods() + return {"methods": self.methods.list()} + + def get_method(self, name: str) -> dict: + self._require_methods() + try: + return self.methods.get(name) + except F.FaultError as e: + raise F.FaultError(self._record_fault(e.fault)) + + def create_method(self, name: str, config: dict) -> dict: + self._require_methods() + try: + self.methods.save(name, config, overwrite=False) + except F.FaultError as e: + raise F.FaultError(self._record_fault(e.fault)) + return {"name": name, "created": True} + + def update_method(self, name: str, config: dict) -> dict: + self._require_methods() + try: + self.methods.save(name, config, overwrite=True) + except F.FaultError as e: + raise F.FaultError(self._record_fault(e.fault)) + return {"name": name, "updated": True} + + def delete_method(self, name: str) -> dict: + self._require_methods() + if self.jobs.active is not None: + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_WRONG_STATE, + "Cannot delete a method while an acquisition is active", + detail={"method": name}, + ) + ) + try: + self.methods.delete(name) + except F.FaultError as e: + raise F.FaultError(self._record_fault(e.fault)) + return {"name": name, "deleted": True} + + def validate_method(self, name: str) -> dict: + """URS API-METH-004: run the yaml/widget/hardware/channels/regions checks + (not output_path) against a stored method and return the preflight-style list.""" + self._require_methods() + try: + path = str(self.methods.path_for(name)) + except F.FaultError as e: + raise F.FaultError(self._record_fault(e.fault)) + checks, ctx = self._yaml_checks(AcquisitionRequest(yaml_path=path)) + return self._run_checks_report(checks, ctx, skip_names={"output_path"}) + + # ---- debug --------------------------------------------------------------- + + def set_python_exec_enabled(self, enabled: bool) -> None: + self._python_exec_enabled = enabled + (self._log.warning if enabled else self._log.info)(f"python_exec {'ENABLED' if enabled else 'disabled'}") + + def python_exec_status(self) -> dict: + return {"enabled": self._python_exec_enabled} + + def python_exec(self, code: str) -> dict: + """Execute arbitrary Python with the microscope objects in scope. + + NOT SANDBOXED. Gated by the GUI opt-in toggle; the service refuses when + disabled. Only expose this endpoint on loopback binds. + """ + import tempfile + + import numpy as np + + if not self._python_exec_enabled: + self._fail( + F.make_fault( + F.FaultCategory.PROTOCOL, + F.PROTOCOL_FORBIDDEN, + "python_exec is disabled; enable it via Settings in the GUI", + ) + ) + namespace = { + "microscope": self._microscope, + "stage": self._microscope.stage, + "camera": self._microscope.camera, + "live_controller": self._microscope.live_controller, + "objective_store": self._microscope.objective_store, + "multipoint_controller": self._mpc, + "scan_coordinates": self._scan_coordinates, + "np": np, + "result": None, + "image": None, + } + try: + exec(code, namespace) # noqa: S102 - intentionally unsandboxed, opt-in debug tool + except Exception as e: + self._fail( + F.make_fault( + F.FaultCategory.INVALID_PARAM, + F.INVALID_PARAM_BAD_VALUE, + f"python_exec failed: {e}", + detail={"exception": type(e).__name__}, + ) + ) + response = {} + result = namespace.get("result") + if result is not None: + try: + json.dumps(result) + response["result"] = result + except (TypeError, ValueError): + response["result"] = str(result) + image = namespace.get("image") + if image is not None and isinstance(image, np.ndarray): + path = os.path.join(tempfile.gettempdir(), "squid_python_exec_image.tiff") + try: + try: + import tifffile + + tifffile.imwrite(path, image) + except ImportError: + path = path.replace(".tiff", ".npy") + np.save(path, image) + except Exception as e: + self._fail( + F.make_fault( + F.FaultCategory.IO, + F.IO_GENERIC, + f"python_exec image save failed: {e}", + detail={"exception": type(e).__name__}, + ) + ) + response["image_path"] = path + response["image_shape"] = list(image.shape) + response["image_dtype"] = str(image.dtype) + return response + + def debug_settings(self) -> dict: + """URS API-COMPAT-002 delta: REST parity for the legacy TCP view/performance + debug commands (_cmd_get_view_settings / _cmd_get_performance_mode). + `performance_mode` is None when no GUI is attached (headless service). + + Note: the legacy `display_plate_view` field is intentionally not + reproduced here -- `control._def.DISPLAY_PLATE_VIEW` no longer exists in + this codebase; plate view was unified into the mosaic view + (UnifiedMosaicWidget), governed solely by `display_mosaic_view`. + """ + import control._def + + return { + "performance_mode": self._gui_bridge.get_performance_mode(), + "save_downsampled_well_images": control._def.SAVE_DOWNSAMPLED_WELL_IMAGES, + "save_downsampled_overview": control._def.SAVE_DOWNSAMPLED_OVERVIEW, + "display_mosaic_view": control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY, + } + + def set_debug_settings(self, req: DebugSettingsRequest) -> dict: + """URS API-COMPAT-002 delta: REST parity for the legacy TCP + _cmd_set_view_settings / _cmd_set_performance_mode commands. View settings + are applied directly to `control._def` (module import, so MCP-driven + reloads and other readers see the change immediately); `performance_mode` + is dispatched fire-and-forget to the GUI thread (see GuiBridge.set_performance_mode) + and so may not be reflected in the returned snapshot yet. + """ + import control._def + + if req.performance_mode is not None: + if not self._gui_bridge.has_gui: + self._fail( + F.make_fault( + F.FaultCategory.CONFIG, + F.CONFIG_CAPABILITY_MISSING, + "No GUI attached; cannot set performance_mode", + component="debug", + ) + ) + self._gui_bridge.set_performance_mode(req.performance_mode) + + if req.save_downsampled_well_images is not None: + control._def.SAVE_DOWNSAMPLED_WELL_IMAGES = req.save_downsampled_well_images + + if req.save_downsampled_overview is not None: + control._def.SAVE_DOWNSAMPLED_OVERVIEW = req.save_downsampled_overview + + if req.display_mosaic_view is not None: + control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY = req.display_mosaic_view + + return self.debug_settings() diff --git a/software/squid_service/state.py b/software/squid_service/state.py new file mode 100644 index 000000000..70d833cd1 --- /dev/null +++ b/software/squid_service/state.py @@ -0,0 +1,94 @@ +"""Instrument state machine (spec §3).""" + +import threading +from enum import Enum +from typing import Callable, Dict, FrozenSet, Optional, Set + + +class InstrumentState(str, Enum): + UNINITIALIZED = "UNINITIALIZED" + INITIALIZING = "INITIALIZING" + INITIALIZED = "INITIALIZED" + RESERVED = "RESERVED" + ACQUIRING = "ACQUIRING" + PROCESSING = "PROCESSING" + ERROR = "ERROR" + RECOVERING = "RECOVERING" + SHUTTING_DOWN = "SHUTTING_DOWN" + + +BUSY_STATES: FrozenSet[InstrumentState] = frozenset( + { + InstrumentState.INITIALIZING, + InstrumentState.ACQUIRING, + InstrumentState.PROCESSING, + InstrumentState.RECOVERING, + InstrumentState.SHUTTING_DOWN, + } +) + +_ALLOWED: Dict[InstrumentState, Set[InstrumentState]] = { + InstrumentState.UNINITIALIZED: {InstrumentState.INITIALIZING, InstrumentState.SHUTTING_DOWN}, + InstrumentState.INITIALIZING: {InstrumentState.INITIALIZED, InstrumentState.ERROR}, + InstrumentState.INITIALIZED: { + InstrumentState.ACQUIRING, + InstrumentState.INITIALIZING, + InstrumentState.RECOVERING, + InstrumentState.ERROR, + InstrumentState.SHUTTING_DOWN, + }, + InstrumentState.ACQUIRING: { + InstrumentState.PROCESSING, + InstrumentState.ERROR, + InstrumentState.INITIALIZED, # abort path collapses PROCESSING when nothing to drain + }, + InstrumentState.PROCESSING: {InstrumentState.INITIALIZED, InstrumentState.ERROR}, + InstrumentState.ERROR: { + InstrumentState.RECOVERING, + InstrumentState.INITIALIZING, + InstrumentState.SHUTTING_DOWN, + }, + InstrumentState.RECOVERING: {InstrumentState.INITIALIZED, InstrumentState.ERROR}, + InstrumentState.RESERVED: set(), + InstrumentState.SHUTTING_DOWN: set(), +} + + +class InvalidTransition(Exception): + pass + + +class StateMachine: + """Thread-safe instrument state with a transition listener. + + The listener is invoked outside the lock so it may call back into the machine. + Self-transitions are silent no-ops (idempotent callers). + """ + + def __init__( + self, + initial: InstrumentState, + on_transition: Optional[Callable[[InstrumentState, InstrumentState], None]] = None, + ): + self._lock = threading.Lock() + self._state = initial + self._on_transition = on_transition + + @property + def state(self) -> InstrumentState: + with self._lock: + return self._state + + def is_busy(self) -> bool: + return self.state in BUSY_STATES + + def transition(self, new: InstrumentState) -> None: + with self._lock: + old = self._state + if new == old: + return + if new not in _ALLOWED[old]: + raise InvalidTransition(f"{old.value} -> {new.value} is not allowed") + self._state = new + if self._on_transition is not None: + self._on_transition(old, new) diff --git a/software/squid_service/timeutil.py b/software/squid_service/timeutil.py new file mode 100644 index 000000000..5c2b58e03 --- /dev/null +++ b/software/squid_service/timeutil.py @@ -0,0 +1,6 @@ +from datetime import datetime, timezone + + +def utc_now_iso() -> str: + """ISO-8601 UTC timestamp with millisecond precision and trailing Z (spec §2.4).""" + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/software/squid_service/wells.py b/software/squid_service/wells.py new file mode 100644 index 000000000..62c1cfc7e --- /dev/null +++ b/software/squid_service/wells.py @@ -0,0 +1,76 @@ +"""Well-name parsing and GUI-consistent well-center coordinates. + +The coordinate formula matches ScanCoordinates.get_selected_wells +(control/core/scan_coordinates.py): a1 + index*spacing + WELLPLATE_OFFSET. +""" + +import re +from typing import List, Tuple + +import control._def # module import: offsets are runtime-modifiable + +_WELL_RE = re.compile(r"^([A-Za-z]+)(\d+)$") + + +def row_to_index(row: str) -> int: + index = 0 + for char in row.upper(): + index = index * 26 + (ord(char) - ord("A") + 1) + return index - 1 + + +def index_to_row(index: int) -> str: + index += 1 + row = "" + while index > 0: + index -= 1 + row = chr(index % 26 + ord("A")) + row + index //= 26 + return row + + +def _parse_one(token: str) -> Tuple[int, int]: + match = _WELL_RE.match(token.strip()) + if not match: + raise ValueError(f"Invalid well name: {token!r}") + return row_to_index(match.group(1)), int(match.group(2)) - 1 + + +def parse_well_names(wells: str) -> List[str]: + if not wells or not wells.strip(): + raise ValueError("Empty well selection") + names: List[str] = [] + for part in wells.split(","): + part = part.strip() + if ":" in part: + start, _, end = part.partition(":") + r0, c0 = _parse_one(start) + r1, c1 = _parse_one(end) + if r1 < r0 or c1 < c0: + raise ValueError(f"Range end before start: {part!r}") + for r in range(r0, r1 + 1): + for c in range(c0, c1 + 1): + names.append(f"{index_to_row(r)}{c + 1}") + else: + r, c = _parse_one(part) + names.append(f"{index_to_row(r)}{c + 1}") + return names + + +def well_center_mm(well_name: str, wellplate_settings: dict) -> Tuple[float, float]: + row_idx, col_idx = _parse_one(well_name) + rows = int(wellplate_settings.get("rows", 8)) + cols = int(wellplate_settings.get("cols", 12)) + if not (0 <= row_idx < rows and 0 <= col_idx < cols): + raise ValueError(f"Well {well_name!r} outside {rows}x{cols} plate") + x = ( + wellplate_settings["a1_x_mm"] + + col_idx * wellplate_settings["well_spacing_mm"] + + getattr(control._def, "WELLPLATE_OFFSET_X_mm", 0.0) + ) + y = ( + wellplate_settings["a1_y_mm"] + + row_idx * wellplate_settings["well_spacing_mm"] + + getattr(control._def, "WELLPLATE_OFFSET_Y_mm", 0.0) + ) + return x, y diff --git a/software/tests/control/test_acquisition_yaml_loader.py b/software/tests/control/test_acquisition_yaml_loader.py index 574abdad0..0f2fb4226 100644 --- a/software/tests/control/test_acquisition_yaml_loader.py +++ b/software/tests/control/test_acquisition_yaml_loader.py @@ -270,6 +270,119 @@ def test_parse_channels_with_missing_names(self, tmp_path): assert result.channel_names == ["Valid Channel", "Another Valid"] +class TestParseWellsByName: + """Tests for the additive wellplate_scan.wells (wells-by-name) field.""" + + def _write(self, tmp_path, body): + yaml_file = tmp_path / "wells.yaml" + yaml_file.write_text(body) + return str(yaml_file) + + def test_wells_as_string(self, tmp_path): + path = self._write( + tmp_path, + """ +acquisition: + widget_type: wellplate +wellplate_scan: + scan_size_mm: 1.0 + wells: "A1:B3" +""", + ) + result = parse_acquisition_yaml(path) + assert result.wells == "A1:B3" + assert result.wellplate_regions is None + + def test_wells_as_list_joined_with_comma(self, tmp_path): + path = self._write( + tmp_path, + """ +acquisition: + widget_type: wellplate +wellplate_scan: + wells: + - A1 + - B2 + - C3 +""", + ) + result = parse_acquisition_yaml(path) + assert result.wells == "A1,B2,C3" + + def test_wells_and_regions_both_specified_raises(self, tmp_path): + path = self._write( + tmp_path, + """ +acquisition: + widget_type: wellplate +wellplate_scan: + wells: "A1" + regions: + - name: A1 + center_mm: [14.3, 11.36, 0.5] + shape: Square +""", + ) + with pytest.raises(ValueError, match="either 'wells' or 'regions', not both"): + parse_acquisition_yaml(path) + + def test_wells_absent_is_none(self, tmp_path): + path = self._write( + tmp_path, + """ +acquisition: + widget_type: wellplate +wellplate_scan: + regions: + - name: A1 + center_mm: [14.3, 11.36, 0.5] + shape: Square +""", + ) + result = parse_acquisition_yaml(path) + assert result.wells is None + assert result.wellplate_regions is not None + assert result.wellplate_regions[0]["name"] == "A1" + + def test_regions_only_yaml_unaffected(self, tmp_path): + """An existing regions-only method parses exactly as before (wells is None).""" + path = self._write( + tmp_path, + """ +acquisition: + widget_type: wellplate +z_stack: + nz: 3 + delta_z_mm: 0.002 +wellplate_scan: + scan_size_mm: 2.1 + overlap_percent: 15.0 + regions: + - name: C4 + center_mm: [38.31, 28.75, 1.2] + shape: Circle +""", + ) + result = parse_acquisition_yaml(path) + assert result.wells is None + assert result.scan_shape == "Circle" + assert len(result.wellplate_regions) == 1 + assert result.nz == 3 + + def test_empty_wells_string_is_none(self, tmp_path): + path = self._write( + tmp_path, + """ +acquisition: + widget_type: wellplate +wellplate_scan: + wells: "" +""", + ) + result = parse_acquisition_yaml(path) + assert result.wells is None + + class TestValidateHardware: """Tests for validate_hardware function.""" diff --git a/software/tests/control/test_core_service.py b/software/tests/control/test_core_service.py new file mode 100644 index 000000000..114c34f6a --- /dev/null +++ b/software/tests/control/test_core_service.py @@ -0,0 +1,260 @@ +import pytest + +import control.microscope +from squid_service.faults import FaultCategory, FaultError +from squid_service.models import ( + AcquireRequest, + AutofocusCorrectRequest, + ExposureRequest, + IntensityRequest, + MoveRequest, +) +from squid_service.service import SquidCoreService +from squid_service.state import InstrumentState + + +@pytest.fixture(scope="module") +def sim_scope(): + scope = control.microscope.Microscope.build_from_global_config(True) + yield scope + scope.close() + + +@pytest.fixture() +def service(sim_scope): + return SquidCoreService(microscope=sim_scope, simulation=True) + + +def test_status_shape(service): + s = service.status() + assert s["state"] == "INITIALIZED" + assert s["current_job_id"] is None + assert s["latest_fault"] is None + assert "session_id" in s and "server_time" in s + + +def test_heartbeat(service): + h = service.heartbeat() + assert h["alive"] is True + assert isinstance(h["monotonic_ns"], int) + assert h["state"] == "INITIALIZED" + + +def test_capabilities(service): + caps = service.capabilities() + assert caps["simulation"] is True + assert isinstance(caps["objectives"], list) and caps["objectives"] + assert {"name", "magnification", "na"} <= set(caps["objectives"][0].keys()) + assert "x_range_mm" in caps["stage"] + assert isinstance(caps["channels"], list) + + +def test_capabilities_includes_version_keys(service): + # URS API-DESC-002: capabilities() must also surface the two version keys. + caps = service.capabilities() + assert caps["software_version"] + assert caps["firmware_version"] + + +def test_version(service): + v = service.version() + assert v["api_version"] == "v1" + assert v["software_version"] + assert v["firmware_version"] + + +def test_move_absolute_and_position(service, sim_scope): + limits = sim_scope.stage.get_config() + x = limits.X_AXIS.MAX_POSITION / 2 + y = limits.Y_AXIS.MAX_POSITION / 2 + result = service.move(MoveRequest(mode="absolute", x=x, y=y)) + pos = service.get_position() + assert pos["x_mm"] == pytest.approx(x, abs=0.01) + assert result["position"]["y_mm"] == pytest.approx(y, abs=0.01) + + +def test_move_relative(service): + before = service.get_position() + service.move(MoveRequest(mode="relative", x=0.1)) + after = service.get_position() + assert after["x_mm"] == pytest.approx(before["x_mm"] + 0.1, abs=0.01) + + +def test_move_out_of_limits_faults(service, sim_scope): + max_x = sim_scope.stage.get_config().X_AXIS.MAX_POSITION + with pytest.raises(FaultError) as exc: + service.move(MoveRequest(mode="absolute", x=max_x + 10)) + assert exc.value.fault.category == FaultCategory.INVALID_PARAM + + +def test_move_rejected_while_busy(service): + service._state.transition(InstrumentState.ACQUIRING) + try: + with pytest.raises(FaultError) as exc: + service.move(MoveRequest(mode="absolute", x=1.0)) + assert exc.value.fault.category == FaultCategory.PROTOCOL + assert exc.value.fault.detail["current_state"] == "ACQUIRING" + finally: + service._state.transition(InstrumentState.PROCESSING) + service._state.transition(InstrumentState.INITIALIZED) + + +def test_channels_and_selection(service, sim_scope): + channels = service.list_channels()["channels"] + assert channels, "simulated scope should expose channels" + name = channels[0]["name"] + result = service.select_channel(name) + assert result["channel"] == name + with pytest.raises(FaultError) as exc: + service.select_channel("No Such Channel") + assert exc.value.fault.category == FaultCategory.CONFIG + + +def test_exposure_and_intensity(service): + channels = service.list_channels()["channels"] + name = channels[0]["name"] + assert service.set_exposure(ExposureRequest(exposure_ms=42.0, channel=name))["exposure_ms"] == 42.0 + assert service.set_intensity(IntensityRequest(channel=name, intensity=55.0))["intensity"] == 55.0 + + +def test_objectives(service, sim_scope): + objs = service.get_objectives() + assert objs["current"] in objs["objectives"] + service.set_objective(objs["current"]) # no-op set succeeds + with pytest.raises(FaultError) as exc: + service.set_objective("nonexistent-objective") + assert exc.value.fault.category == FaultCategory.CONFIG + + +def test_acquire_image(service, tmp_path): + save_path = str(tmp_path / "img.tiff") + result = service.acquire(AcquireRequest(save_path=save_path)) + assert result["acquired"] is True + assert result["saved_to"] + assert result["shape"] + + +def test_state_changed_events_published(service): + q = service.events.subscribe() + service._state.transition(InstrumentState.ACQUIRING) + service._state.transition(InstrumentState.PROCESSING) + service._state.transition(InstrumentState.INITIALIZED) + kinds = [q.get_nowait().event for _ in range(3)] + assert kinds == ["state_changed", "state_changed", "state_changed"] + service.events.unsubscribe(q) + + +def test_reset_noop_from_initialized(service): + r = service.reset() + assert r["no_op"] is True and r["state"] == "INITIALIZED" + + +def test_initialize_noop_from_initialized(service): + r = service.initialize() + assert r["no_op"] is True and r["state"] == "INITIALIZED" + + +# --- URS delta (LA-WC-0001) tests --- + + +def test_initialize_with_home_false_from_initialized_is_noop(service): + # URS API-LIFE-002: initialize(home=False) from INITIALIZED is a no-op and + # skips subsystem probes entirely. + r = service.initialize(home=False) + assert r["no_op"] is True + assert r["state"] == "INITIALIZED" + assert r["verified_components"] == [] + assert r["home_performed"] is False + + +def test_initialize_with_home_true_is_not_a_noop(service): + # Even from INITIALIZED, home=True forces real work (probes + homing). + r = service.initialize(home=True) + assert r["no_op"] is False + assert r["state"] == "INITIALIZED" + assert r["verified_components"] == ["stage", "camera", "mcu"] + assert r["home_performed"] is True + + +def test_autofocus_status_shape(service): + # URS API-AF-001: autofocus_status() always returns these four keys with a + # valid readiness enum value. + status = service.autofocus_status() + assert {"available", "initialized", "reference_set", "readiness"} <= set(status.keys()) + assert status["readiness"] in {"OK", "NO_HARDWARE", "NOT_INITIALIZED", "NO_REFERENCE"} + + +def test_autofocus_store_reference_no_hardware_faults(service): + # URS API-AF-002: the default simulated scope has SUPPORT_LASER_AUTOFOCUS=False, + # so no focus camera is configured. autofocus_store_reference() must guard on + # hardware presence before touching the (nonexistent) controller. + with pytest.raises(FaultError) as exc: + service.autofocus_store_reference() + assert exc.value.fault.category in (FaultCategory.CONFIG, FaultCategory.AUTOFOCUS) + + +def test_autofocus_correct_no_hardware_faults(service): + # URS API-AF-003: same guard applies to autofocus_correct(). + with pytest.raises(FaultError) as exc: + service.autofocus_correct(AutofocusCorrectRequest()) + assert exc.value.fault.category in (FaultCategory.CONFIG, FaultCategory.AUTOFOCUS) + + +def test_initialize_probe_failure_faults_and_enters_error_state(service, sim_scope, monkeypatch): + """Verify that probe failures transition the instrument to ERROR state and raise HARDWARE_FAULT.""" + + def boom(): + raise RuntimeError("stage communication lost") + + monkeypatch.setattr(sim_scope.stage, "get_pos", boom) + with pytest.raises(FaultError) as exc: + service.initialize(home=True) # home=True forces probes even from INITIALIZED + assert exc.value.fault.category == FaultCategory.HARDWARE_FAULT + assert exc.value.fault.code == 5001 # HARDWARE_FAULT_GENERIC + assert exc.value.fault.component == "stage" + assert service.state == InstrumentState.ERROR + + # Recover so later tests (or monkeypatch cleanup) see INITIALIZED + monkeypatch.undo() + result = service.initialize(home=True) + assert result["state"] == "INITIALIZED" + + +def test_initialize_homing_failure_faults_and_enters_error_state(service, sim_scope, monkeypatch): + """A homing failure during initialize(home=True) must transition the instrument + to ERROR and raise HARDWARE_FAULT (component 'stage'), not strand it in + INITIALIZING (busy) forever. initialize() must recover once homing works again. + """ + + def boom(): + raise RuntimeError("homing motor stalled") + + monkeypatch.setattr(sim_scope, "home_xyz", boom) + with pytest.raises(FaultError) as exc: + service.initialize(home=True) # probes pass, homing fails + assert exc.value.fault.category == FaultCategory.HARDWARE_FAULT + assert exc.value.fault.code == 5001 # HARDWARE_FAULT_GENERIC + assert exc.value.fault.component == "stage" + assert service.state == InstrumentState.ERROR + + monkeypatch.undo() + result = service.initialize(home=True) + assert result["state"] == "INITIALIZED" + assert result["home_performed"] is True + + +def test_acquire_camera_failure_is_recoverable_transient_fault(service, sim_scope, monkeypatch): + """Verify that camera acquisition failures produce recoverable HARDWARE_TRANSIENT faults.""" + + def boom(*args, **kwargs): + raise RuntimeError("frame timeout") + + monkeypatch.setattr(sim_scope, "acquire_image", boom) + with pytest.raises(FaultError) as exc: + service.acquire(AcquireRequest()) + fault = exc.value.fault + assert fault.category == FaultCategory.HARDWARE_TRANSIENT + assert fault.code == 4001 # HARDWARE_TRANSIENT_TIMEOUT + assert fault.recoverable is True + assert fault.scheduler_action.value == "RETRY" + assert fault.component == "camera" diff --git a/software/tests/control/test_core_service_acquisition.py b/software/tests/control/test_core_service_acquisition.py new file mode 100644 index 000000000..8374f1097 --- /dev/null +++ b/software/tests/control/test_core_service_acquisition.py @@ -0,0 +1,580 @@ +import queue +import time +import types + +import pytest +import yaml + +import control.microscope +import tests.control.test_stubs as ts +from squid_service.faults import FaultCategory, FaultError +from squid_service.models import AcquisitionRequest, MoveRequest +from squid_service.service import SquidCoreService +from squid_service.state import InstrumentState + + +@pytest.fixture(scope="module") +def sim_scope(): + scope = control.microscope.Microscope.build_from_global_config(True) + yield scope + scope.close() + + +@pytest.fixture() +def service(sim_scope, tmp_path): + mpc = ts.get_test_multi_point_controller(sim_scope) + svc = SquidCoreService( + microscope=sim_scope, + multipoint_controller=mpc, + scan_coordinates=mpc.scanCoordinates, + simulation=True, + job_persist_path=tmp_path / "last_job.json", + methods_dir=tmp_path / "methods", + ) + return svc + + +def _first_channel(sim_scope): + objective = sim_scope.objective_store.current_objective + return sim_scope.live_controller.get_channels(objective)[0].name + + +def _config(sim_scope, wells_region="A1"): + return { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 1, "delta_z_mm": 0.001}, + "time_series": {"nt": 1, "delta_t_s": 0.0}, + "channels": [{"name": _first_channel(sim_scope)}], + "autofocus": {"contrast_af": False, "laser_af": False}, + "wellplate_scan": { + "scan_size_mm": 0.5, + "overlap_percent": 10, + "regions": [{"name": wells_region, "center_mm": [14.3, 11.36, 0.5], "shape": "Square"}], + }, + } + + +def _write_yaml(tmp_path, sim_scope, wells_region="A1"): + path = tmp_path / "acquisition.yaml" + path.write_text(yaml.safe_dump(_config(sim_scope, wells_region))) + return str(path) + + +# ---- preflight ---------------------------------------------------------- + + +def test_preflight_ok(service, sim_scope, tmp_path): + req = AcquisitionRequest(yaml_path=_write_yaml(tmp_path, sim_scope), overrides={"output_path": str(tmp_path)}) + result = service.preflight(req) + assert result["ok"] is True + names = {c["name"] for c in result["checks"]} + assert {"yaml", "widget_type", "hardware", "channels", "regions", "output_path"} <= names + + +def test_preflight_reports_bad_yaml_path(service): + result = service.preflight(AcquisitionRequest(yaml_path="/nonexistent/acq.yaml")) + assert result["ok"] is False + assert any(c["name"] == "yaml" and not c["ok"] for c in result["checks"]) + + +def test_preflight_reports_unknown_channel(service, sim_scope, tmp_path): + path = _write_yaml(tmp_path, sim_scope) + text = (tmp_path / "acquisition.yaml").read_text().replace(_first_channel(sim_scope), "No Such Channel") + (tmp_path / "acquisition.yaml").write_text(text) + result = service.preflight(AcquisitionRequest(yaml_path=path)) + assert result["ok"] is False + assert any(c["name"] == "channels" and not c["ok"] for c in result["checks"]) + + +# ---- full lifecycle ----------------------------------------------------- + + +def test_full_acquisition_lifecycle(service, sim_scope, tmp_path): + req = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + experiment_id="svc_test", + overrides={"output_path": str(tmp_path / "out")}, + ) + q = service.events.subscribe() + handle = service.start_acquisition(req) + assert handle["job_id"] + assert handle["expected_fov_count"] >= 1 + assert handle["expected_image_count"] >= 1 + assert service.state in ( + InstrumentState.ACQUIRING, + InstrumentState.PROCESSING, + InstrumentState.INITIALIZED, + ) + + assert service.jobs.wait(handle["job_id"], timeout_s=120.0), "acquisition did not finish" + job = service.get_job(handle["job_id"]) + assert job["state"] == "COMPLETED" + assert job["outcome"] == "SUCCESS" + assert job["result"]["end_reason"] == "completed" + assert service.state == InstrumentState.INITIALIZED + + seen = [] + while not q.empty(): + seen.append(q.get_nowait()) + kinds = [e.event for e in seen] + assert "job_completed" in kinds + assert any(e.event == "state_changed" and e.data["new"] == "ACQUIRING" for e in seen) + service.events.unsubscribe(q) + + assert service.last_job()["job_id"] == handle["job_id"] + + +def test_second_acquisition_rejected_while_running(service, sim_scope, tmp_path): + req = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + overrides={"output_path": str(tmp_path / "out2")}, + ) + handle = service.start_acquisition(req) + try: + with pytest.raises(FaultError) as exc: + service.start_acquisition(req) + assert exc.value.fault.category == FaultCategory.PROTOCOL + assert exc.value.fault.code == 1002 # PROTOCOL_WRONG_STATE + finally: + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + + +def test_abort_acquisition(service, sim_scope, tmp_path): + # No Slack notifier is configured on this controller (ts.get_test_multi_point_controller + # never calls set_slack_notifier), which is exactly the gap this test guards: without it, + # AcquisitionStats never reaches the service via signal_slack_acquisition_finished, so + # _on_acq_finished must fall back to _derive_end_reason() to report ABORTED correctly. + req = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + overrides={"output_path": str(tmp_path / "out3"), "wells": "A1:D6"}, # enough work to abort mid-run + ) + q = service.events.subscribe() + handle = service.start_acquisition(req) + try: + # Wait for the run to actually reach ACQUIRING before aborting, instead of a + # fixed sleep, so the abort is issued as early (and as reliably) as possible. + deadline = time.monotonic() + 10.0 + reached_acquiring = False + while time.monotonic() < deadline: + remaining = max(0.0, deadline - time.monotonic()) + try: + ev = q.get(timeout=remaining) + except queue.Empty: + break + if ev.event == "state_changed" and ev.data.get("new") == "ACQUIRING": + reached_acquiring = True + break + assert reached_acquiring, "acquisition never reached ACQUIRING before the abort was issued" + + result = service.abort_job(handle["job_id"], timeout_s=120.0) + finally: + service.events.unsubscribe(q) + + assert result["timed_out"] is False + job = result["job"] + assert job["state"] == "COMPLETED" + if job["outcome"] == "SUCCESS": + pytest.skip( + "sim raced to completion before the abort landed (job finished before ABORTED " + "could be observed) even though ACQUIRING was seen first; not a fix regression" + ) + assert job["outcome"] == "ABORTED" + assert result["clean"] is True + assert service.state == InstrumentState.INITIALIZED + + +# ---- end-reason fallback when no Slack notifier is configured ----------- + + +def test_derive_end_reason_used_when_stats_missing(sim_scope, tmp_path): + """_on_acq_finished must consult _derive_end_reason() (and thus the worker's + own _compute_end_reason()) when no AcquisitionStats arrived -- the situation + for every acquisition when no Slack notifier is configured (the default). + + Drives _on_acq_start/_on_acq_finished directly against a service whose mpc is + a plain stub namespace (no real MultiPointController/-Worker involved), with a + fake worker exposing only what the service reads: _compute_end_reason(), + _acquisition_error_count, _laser_af_failures. + """ + svc = SquidCoreService( + microscope=sim_scope, + simulation=True, + job_persist_path=tmp_path / "last_job.json", + ) + + fake_worker = types.SimpleNamespace( + _compute_end_reason=lambda: "error", + _acquisition_error_count=3, + _laser_af_failures=2, + ) + svc._mpc = types.SimpleNamespace( + multiPointWorker=fake_worker, + abort_acqusition_requested=False, + base_path=str(tmp_path), + experiment_ID="stub_exp", + ) + + svc._on_acq_start(types.SimpleNamespace(experiment_ID="stub_exp")) + job_id = svc.jobs.active.job_id + assert svc._acq_stats is None # no signal_slack_acquisition_finished ever fired + + svc._on_acq_finished() + + job = svc.get_job(job_id) + assert job["state"] == "COMPLETED" + assert job["outcome"] == "FAILURE" # _REASON_TO_OUTCOME["error"] + assert job["result"]["end_reason"] == "error" + assert job["result"]["errors_encountered"] == 3 + assert job["progress"]["af_failures"] == 2 + assert job["progress"]["save_failures"] == 3 + assert svc.state == InstrumentState.ERROR + + +def test_derive_end_reason_falls_back_without_worker(sim_scope, tmp_path): + """When multiPointWorker is unavailable, fall back to the abort flag instead + of silently reporting SUCCESS.""" + svc = SquidCoreService( + microscope=sim_scope, + simulation=True, + job_persist_path=tmp_path / "last_job2.json", + ) + svc._mpc = types.SimpleNamespace( + multiPointWorker=None, + abort_acqusition_requested=True, + base_path=str(tmp_path), + experiment_ID="stub_exp2", + ) + + svc._on_acq_start(types.SimpleNamespace(experiment_ID="stub_exp2")) + job_id = svc.jobs.active.job_id + + svc._on_acq_finished() + + job = svc.get_job(job_id) + assert job["state"] == "COMPLETED" + assert job["outcome"] == "ABORTED" # _REASON_TO_OUTCOME["user_abort"] + assert job["result"]["end_reason"] == "user_abort" + assert svc.state == InstrumentState.INITIALIZED + + +def test_get_job_unknown_id_faults(service): + with pytest.raises(FaultError) as exc: + service.get_job("doesnotexist") + assert exc.value.fault.code == 1001 # PROTOCOL_UNKNOWN_RESOURCE + + +# ---- URS delta (LA-WC-0001) --------------------------------------------- + + +def test_run_by_method_e2e(service, sim_scope, tmp_path): + service.create_method("routine_a", _config(sim_scope)) + req = AcquisitionRequest( + method="routine_a", + operator="alice", + scheduler_job_id="sched-42", + overrides={"output_path": str(tmp_path / "meth_out")}, + ) + handle = service.start_acquisition(req) + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + job = service.get_job(handle["job_id"]) + assert job["outcome"] == "SUCCESS" + assert job["operator"] == "alice" + assert job["scheduler_job_id"] == "sched-42" + + import json + import os + + api_json = os.path.join(handle["output_dir"], "api_request.json") + assert os.path.exists(api_json) + with open(api_json) as f: + payload = json.load(f) + assert payload["operator"] == "alice" + assert payload["source"] == "routine_a" + + +def test_method_crud_and_delete_while_running(service, sim_scope, tmp_path): + service.create_method("crud_m", _config(sim_scope)) + assert any(m["name"] == "crud_m" for m in service.list_methods()["methods"]) + assert service.get_method("crud_m")["config"]["acquisition"]["widget_type"] == "wellplate" + + updated = _config(sim_scope) + updated["time_series"]["nt"] = 2 + service.update_method("crud_m", updated) + assert service.get_method("crud_m")["config"]["time_series"]["nt"] == 2 + + # delete-while-running rejection (URS API-METH-005) + service.create_method("run_m", _config(sim_scope)) + req = AcquisitionRequest(method="run_m", overrides={"output_path": str(tmp_path / "crud_out")}) + handle = service.start_acquisition(req) + try: + with pytest.raises(FaultError) as exc: + service.delete_method("run_m") + assert exc.value.fault.category == FaultCategory.PROTOCOL + assert exc.value.fault.code == 1002 # PROTOCOL_WRONG_STATE + finally: + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + + # deletion succeeds once idle + service.delete_method("crud_m") + assert not any(m["name"] == "crud_m" for m in service.list_methods()["methods"]) + + +def test_grid_acquisition_e2e(service, sim_scope, tmp_path): + req = AcquisitionRequest( + grid={"wells": "A1", "channels": [_first_channel(sim_scope)], "nx": 1, "ny": 1}, + overrides={"output_path": str(tmp_path / "grid_out")}, + ) + handle = service.start_acquisition(req) + assert handle["expected_fov_count"] >= 1 + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + assert service.get_job(handle["job_id"])["outcome"] == "SUCCESS" + + +def test_autofocus_override_respected(service, sim_scope, tmp_path): + req = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + autofocus={"reflection": False, "contrast": False}, + overrides={"output_path": str(tmp_path / "af_out")}, + ) + handle = service.start_acquisition(req) + try: + assert service._mpc.do_reflection_af is False + assert service._mpc.do_autofocus is False + finally: + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + + +def test_validate_method_ok(service, sim_scope): + service.create_method("valid_m", _config(sim_scope)) + result = service.validate_method("valid_m") + assert result["ok"] is True + names = {c["name"] for c in result["checks"]} + assert "output_path" not in names + assert {"yaml", "widget_type", "hardware", "channels", "regions"} <= names + + +# ---- ERROR-state rejection + recovery (URS API-LIFE-003) ---------------- + + +def test_error_state_rejects_commands_creates_no_job_then_recovers(service, sim_scope, tmp_path, monkeypatch): + """While ERROR, state-changing commands must be rejected with a canonical + PROTOCOL_WRONG_STATE (409) carrying detail.current_state=="ERROR" and must NOT + create a job. Only reset()/initialize() recover; afterwards a real acquisition + runs end-to-end. + """ + + # Drive the service into ERROR via a probe failure during initialize(home=True). + def boom(): + raise RuntimeError("stage communication lost") + + monkeypatch.setattr(sim_scope.stage, "get_pos", boom) + with pytest.raises(FaultError): + service.initialize(home=True) + monkeypatch.undo() + assert service.state == InstrumentState.ERROR + + req = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + overrides={"output_path": str(tmp_path / "err_out")}, + ) + + # start_acquisition is rejected before any job is created. + with pytest.raises(FaultError) as exc: + service.start_acquisition(req) + assert exc.value.fault.category == FaultCategory.PROTOCOL + assert exc.value.fault.code == 1002 # PROTOCOL_WRONG_STATE + assert exc.value.fault.detail["current_state"] == "ERROR" + assert service.jobs.active is None # no phantom active job + + # move gets the same rejection. + with pytest.raises(FaultError) as exc2: + service.move(MoveRequest(mode="absolute", x=1.0)) + assert exc2.value.fault.code == 1002 + assert exc2.value.fault.detail["current_state"] == "ERROR" + + # reset() recovers to INITIALIZED, then a real acquisition succeeds. + assert service.reset()["state"] == "INITIALIZED" + assert service.state == InstrumentState.INITIALIZED + + handle = service.start_acquisition(req) + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + assert service.get_job(handle["job_id"])["outcome"] == "SUCCESS" + + +# ---- z_range refresh per run (no stale leakage) ------------------------- + + +def test_start_acquisition_refreshes_z_range_from_current_stage_z(service, sim_scope, tmp_path): + """_configure_controller must derive z_range from the *current* stage z on every + run (mirroring the GUI pre-run path), not reuse a value derived once by the + controller. With nz=1 the range collapses to (z, z). + """ + z1 = 1.0 + service.move(MoveRequest(mode="absolute", z=z1)) + req1 = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + overrides={"output_path": str(tmp_path / "z1_out")}, + ) + handle1 = service.start_acquisition(req1) + try: + assert service._mpc.z_range == pytest.approx([z1, z1], abs=0.01) + finally: + assert service.jobs.wait(handle1["job_id"], timeout_s=120.0) + + # Move Z, run again: the second run's z_range must update, not reuse z1's value. + z2 = 2.0 + service.move(MoveRequest(mode="absolute", z=z2)) + req2 = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + overrides={"output_path": str(tmp_path / "z2_out")}, + ) + handle2 = service.start_acquisition(req2) + try: + assert service._mpc.z_range == pytest.approx([z2, z2], abs=0.01) + finally: + assert service.jobs.wait(handle2["job_id"], timeout_s=120.0) + + +# ---- wells-by-name method + z_reference policy (Task 16) ---------------- + + +def _wells_config(sim_scope, wells="A1", **overrides): + """A wellplate method that specifies wells by NAME (no regions).""" + cfg = { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 1, "delta_z_mm": 0.001}, + "time_series": {"nt": 1, "delta_t_s": 0.0}, + "channels": [{"name": _first_channel(sim_scope)}], + "autofocus": {"contrast_af": False, "laser_af": False}, + "wellplate_scan": {"scan_size_mm": 0.5, "overlap_percent": 10, "wells": wells}, + } + for section, value in overrides.items(): + cfg[section] = {**cfg.get(section, {}), **value} if isinstance(value, dict) else value + return cfg + + +def test_method_with_wells_e2e(service, sim_scope, tmp_path): + """A method specifying wells by name (no regions) runs end-to-end, and the derived + region X/Y match the plate-definition coordinates from well_center_mm.""" + import control._def + from squid_service.wells import well_center_mm + + service.create_method("wells_method", _wells_config(sim_scope, wells="A1")) + req = AcquisitionRequest(method="wells_method", overrides={"output_path": str(tmp_path / "wells_out")}) + handle = service.start_acquisition(req) + try: + settings = control._def.get_wellplate_settings("96 well plate") + exp_x, exp_y = well_center_mm("A1", settings) + centers = service._scan_coordinates.region_centers + assert "A1" in centers + assert centers["A1"][0] == pytest.approx(exp_x, abs=1e-6) + assert centers["A1"][1] == pytest.approx(exp_y, abs=1e-6) + finally: + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + assert service.get_job(handle["job_id"])["outcome"] == "SUCCESS" + + +def test_z_reference_z_mm_sets_z_range_and_region_z(service, sim_scope, tmp_path): + """z_reference={"z_mm": v} makes v the run's Z baseline: z_range[0]==v and each + derived region's z==v (independent of the current stage z).""" + service.move(MoveRequest(mode="absolute", z=1.0)) # current z deliberately != z_mm + z_mm = 3.0 # inside the sim Z limits [0.05, 7] + service.create_method("wells_zmm", _wells_config(sim_scope, wells="A1")) + req = AcquisitionRequest( + method="wells_zmm", + z_reference={"z_mm": z_mm}, + overrides={"output_path": str(tmp_path / "zmm_out")}, + ) + handle = service.start_acquisition(req) + try: + assert service._mpc.z_range == pytest.approx([z_mm, z_mm], abs=1e-6) + assert service._scan_coordinates.region_centers["A1"][2] == pytest.approx(z_mm, abs=1e-6) + finally: + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) + + +def test_z_reference_z_mm_out_of_limits_faults_no_job(service, sim_scope, tmp_path): + """An out-of-limits z_mm is rejected with INVALID_PARAM_OUT_OF_RANGE (2001, + component stage.z) and creates no job.""" + zmax = sim_scope.stage.get_config().Z_AXIS.MAX_POSITION + service.create_method("wells_bad_z", _wells_config(sim_scope, wells="A1")) + req = AcquisitionRequest( + method="wells_bad_z", + z_reference={"z_mm": zmax + 100.0}, + overrides={"output_path": str(tmp_path / "badz_out")}, + ) + # preflight reports the failed z_reference check + pre = service.preflight(req) + assert pre["ok"] is False + assert any(c["name"] == "z_reference" and not c["ok"] for c in pre["checks"]) + + with pytest.raises(FaultError) as exc: + service.start_acquisition(req) + assert exc.value.fault.category == FaultCategory.INVALID_PARAM + assert exc.value.fault.code == 2001 # INVALID_PARAM_OUT_OF_RANGE + assert exc.value.fault.component == "stage.z" + assert service.jobs.active is None + + +def test_z_reference_autofocus_without_reference_rejected(service, sim_scope, tmp_path): + """z_reference='autofocus' with laser AF enabled but no stored reference: preflight + reports the failed check and start_acquisition raises AUTOFOCUS_NOT_READY (8002), + creating no job. (Sim boots with support_laser_autofocus but no reference set.)""" + assert service.autofocus_status()["reference_set"] is False + service.create_method("wells_af", _wells_config(sim_scope, wells="A1", autofocus={"laser_af": True})) + req = AcquisitionRequest( + method="wells_af", + z_reference="autofocus", + overrides={"output_path": str(tmp_path / "af_out")}, + ) + pre = service.preflight(req) + assert pre["ok"] is False + assert any(c["name"] == "z_reference" and not c["ok"] for c in pre["checks"]) + + with pytest.raises(FaultError) as exc: + service.start_acquisition(req) + assert exc.value.fault.category == FaultCategory.AUTOFOCUS + assert exc.value.fault.code == 8002 # AUTOFOCUS_NOT_READY + assert service.jobs.active is None + + +def test_z_reference_autofocus_with_af_disabled_is_invalid_param(service, sim_scope, tmp_path): + """z_reference='autofocus' but no AF mode enabled for the run -> INVALID_PARAM.""" + service.create_method("wells_noaf", _wells_config(sim_scope, wells="A1")) + req = AcquisitionRequest( + method="wells_noaf", + z_reference="autofocus", + overrides={"output_path": str(tmp_path / "noaf_out")}, + ) + with pytest.raises(FaultError) as exc: + service.start_acquisition(req) + assert exc.value.fault.category == FaultCategory.INVALID_PARAM + assert exc.value.fault.code == 2002 # INVALID_PARAM_BAD_VALUE + assert service.jobs.active is None + + +def test_z_stacking_from_center_baseline(service, sim_scope, tmp_path): + """FROM CENTER semantics (verified against widgets.py/multi_point_worker.py): the + service does NOT pre-shift z_range. z_range[0] is set to the baseline z0 and the + controller's z_stacking_config is set to "FROM CENTER" so the WORKER performs the + half-stack shift at run time (prepare_z_stack / move_z_back_after_stack). Headlessly + we therefore assert z_range[0]==z0 and z_stacking_config=="FROM CENTER"; the physical + center-at-z0 is realized by the worker's stage moves, not by a z_range midpoint. + """ + z_mm = 3.0 + cfg = _wells_config(sim_scope, wells="A1", z_stack={"nz": 3, "delta_z_mm": 0.002, "config": "FROM CENTER"}) + service.create_method("wells_center", cfg) + req = AcquisitionRequest( + method="wells_center", + z_reference={"z_mm": z_mm}, + overrides={"output_path": str(tmp_path / "center_out")}, + ) + handle = service.start_acquisition(req) + try: + assert service._mpc.z_stacking_config == "FROM CENTER" + assert service._mpc.z_range[0] == pytest.approx(z_mm, abs=1e-6) + assert service._mpc.z_range[1] == pytest.approx(z_mm + 0.002 * (3 - 1), abs=1e-6) + finally: + assert service.jobs.wait(handle["job_id"], timeout_s=120.0) diff --git a/software/tests/control/test_core_service_rest.py b/software/tests/control/test_core_service_rest.py new file mode 100644 index 000000000..e00653fe3 --- /dev/null +++ b/software/tests/control/test_core_service_rest.py @@ -0,0 +1,590 @@ +import asyncio +import json +import time + +import pytest +import yaml +from fastapi.testclient import TestClient + +import control.microscope +import tests.control.test_stubs as ts +from squid_service.config import ServiceConfig +from squid_service.events import EventBus +from squid_service.rest.app import create_app +from squid_service.rest.sse import sse_event_stream +from squid_service.service import SquidCoreService + + +@pytest.fixture(scope="module") +def sim_scope(): + scope = control.microscope.Microscope.build_from_global_config(True) + yield scope + scope.close() + + +@pytest.fixture() +def service(sim_scope, tmp_path): + mpc = ts.get_test_multi_point_controller(sim_scope) + return SquidCoreService( + microscope=sim_scope, + multipoint_controller=mpc, + scan_coordinates=mpc.scanCoordinates, + simulation=True, + job_persist_path=tmp_path / "last_job.json", + methods_dir=tmp_path / "methods", + ) + + +@pytest.fixture() +def client(service): + app = create_app(service, ServiceConfig()) + return TestClient(app) + + +def _first_channel(sim_scope): + objective = sim_scope.objective_store.current_objective + return sim_scope.live_controller.get_channels(objective)[0].name + + +def _method_config(sim_scope, wells_region="A1"): + return { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 1, "delta_z_mm": 0.001}, + "time_series": {"nt": 1, "delta_t_s": 0.0}, + "channels": [{"name": _first_channel(sim_scope)}], + "autofocus": {"contrast_af": False, "laser_af": False}, + "wellplate_scan": { + "scan_size_mm": 0.5, + "overlap_percent": 10, + "regions": [{"name": wells_region, "center_mm": [14.3, 11.36, 0.5], "shape": "Square"}], + }, + } + + +def test_healthz_and_status(client): + assert client.get("/v1/healthz").json() == {"alive": True} + status = client.get("/v1/system/status") + assert status.status_code == 200 + assert status.json()["state"] == "INITIALIZED" + + +def test_openapi_and_docs(client): + assert client.get("/openapi.json").status_code == 200 + assert client.get("/docs").status_code == 200 + + +def test_move_and_fault_shape(client, sim_scope): + max_x = sim_scope.stage.get_config().X_AXIS.MAX_POSITION + ok = client.post("/v1/motion/move", json={"mode": "absolute", "x": max_x / 2}) + assert ok.status_code == 200 + bad = client.post("/v1/motion/move", json={"mode": "absolute", "x": max_x + 100}) + assert bad.status_code == 400 + error = bad.json()["error"] + assert error["category"] == "INVALID_PARAM" + assert error["code"] == 2001 + assert error["scheduler_action"] in ( + "RETRY", + "ABORT_PLATE", + "REJECT_PLATE", + "PAUSE_INSTRUMENT", + "ESCALATE_OPERATOR", + ) + + +def test_schema_violation_is_canonical_fault(client): + r = client.post("/v1/motion/move", json={"mode": "sideways"}) + assert r.status_code == 422 + assert r.json()["error"]["category"] == "PROTOCOL" + assert r.json()["error"]["code"] == 1003 + + +def test_unknown_channel_is_config_fault(client): + r = client.post("/v1/imaging/channel", json={"name": "No Such Channel"}) + assert r.status_code == 422 + assert r.json()["error"]["category"] == "CONFIG" + + +def test_reserved_endpoints_501(client): + for path in ("/v1/system/reserve", "/v1/system/release", "/v1/system/shutdown"): + r = client.post(path) + assert r.status_code == 501 + assert r.json()["error"]["code"] == 1006 + + +def test_auth_enforced_when_enabled(service): + app = create_app(service, ServiceConfig(auth_enabled=True, auth_token="s3cret")) + client = TestClient(app) + assert client.get("/v1/system/status").status_code == 401 + assert client.get("/v1/system/status").json()["error"]["category"] == "PROTOCOL" + assert client.get("/v1/healthz").status_code == 200 # open path + auth_status = client.get("/v1/system/auth_status") + assert auth_status.status_code == 200 and auth_status.json()["auth_enabled"] is True + ok = client.get("/v1/system/status", headers={"Authorization": "Bearer s3cret"}) + assert ok.status_code == 200 + + +def test_jobs_last_404_when_none(client): + r = client.get("/v1/jobs/last") + assert r.status_code == 404 + assert r.json()["error"]["code"] == 1001 + + +async def _never_disconnected() -> bool: + return False + + +def test_sse_replays_with_last_event_id(service): + # The SSE stream is an infinite live tail. Starlette's TestClient (and httpx's + # ASGITransport) buffer the ENTIRE response before returning and only deliver + # http.disconnect once the response completes, so consuming an infinite stream + # through them deadlocks. Drive the async generator directly instead: read a + # bounded number of events, then close it (running the finally that unsubscribes). + service.events.publish("progress", {"n": 1}) + service.events.publish("progress", {"n": 2}) + + async def collect(): + received = [] + gen = sse_event_stream(service, "0", _never_disconnected) + try: + async for event in gen: + received.append(event["event"]) + if len(received) >= 3: + break + finally: + await gen.aclose() + return received + + received = asyncio.run(collect()) + assert received == ["session_started", "progress", "progress"] + assert service.events._subscribers == [] # finally unsubscribed on close + + +def test_sse_live_tail_stops_on_disconnect(service): + # Exercises the tail loop that previously deadlocked: session_started, then a + # live event delivered via the subscriber queue, then the is_disconnected() + # check breaking the loop and the finally unsubscribing. + disconnected = {"value": False} + + async def is_disconnected() -> bool: + return disconnected["value"] + + async def collect(): + received = [] + gen = sse_event_stream(service, None, is_disconnected) # no Last-Event-Id -> no replay + received.append(await gen.__anext__()) # session_started (subscription now active) + service.events.publish("progress", {"n": 99}) # arrives via the queue, not replay + received.append(await gen.__anext__()) # live tail delivers it + disconnected["value"] = True # next loop iteration must break + with pytest.raises(StopAsyncIteration): + await gen.__anext__() + return received + + received = asyncio.run(collect()) + assert received[0]["event"] == "session_started" + assert received[1]["event"] == "progress" + assert json.loads(received[1]["data"])["n"] == 99 + assert service.events._subscribers == [] # finally unsubscribed + + +def test_sse_emits_resume_gap_on_evicted_history(service): + # Swap in a small-buffer bus BEFORE publishing so the ring buffer actually + # evicts history: with buffer_size=3 and 10 published events, only ids + # 8-10 survive. Requesting Last-Event-Id=1 must surface a resume_gap + # (the client missed evicted events 2-7) followed by the surviving tail. + service.events = EventBus(buffer_size=3) + for n in range(1, 11): + service.events.publish("progress", {"n": n}) + + async def collect(): + received = [] + gen = sse_event_stream(service, "1", _never_disconnected) + try: + async for event in gen: + received.append(event) + if len(received) >= 5: + break + finally: + await gen.aclose() + return received + + received = asyncio.run(collect()) + assert [e["event"] for e in received] == [ + "session_started", + "resume_gap", + "progress", + "progress", + "progress", + ] + replayed_ids = [int(e["id"]) for e in received[2:]] + assert replayed_ids == [8, 9, 10] # only the surviving buffered events, in id order + assert service.events._subscribers == [] # finally unsubscribed + + +def test_sse_dedupes_replayed_events_from_live_queue(service): + # The generator subscribes BEFORE replay is computed, so events published + # between the session_started yield and the replay call land in both the + # ring buffer (replayed) and the already-active subscriber queue (live). + # The yielded_up_to guard in the tail loop must skip those live-queue + # copies so nothing is delivered twice. + async def collect(): + received = [] + gen = sse_event_stream(service, "0", _never_disconnected) + try: + received.append(await gen.__anext__()) # session_started; subscription now active + service.events.publish("progress", {"n": 1}) + service.events.publish("progress", {"n": 2}) + service.events.publish("progress", {"n": 3}) + for _ in range(3): + received.append(await gen.__anext__()) # delivered via bus.replay_since + service.events.publish("progress", {"n": 4}) + received.append(await gen.__anext__()) # live tail; must not repeat 1-3 + finally: + await gen.aclose() + return received + + received = asyncio.run(collect()) + assert [e["event"] for e in received] == [ + "session_started", + "progress", + "progress", + "progress", + "progress", + ] + ids = [int(e["id"]) for e in received] + assert ids[1:] == [1, 2, 3, 4] + assert len(ids) == len(set(ids)) # no id delivered twice (replay vs. live queue) + assert service.events._subscribers == [] # finally unsubscribed + + +def test_rest_acquisition_end_to_end(client, service, sim_scope, tmp_path): + objective = sim_scope.objective_store.current_objective + channel = sim_scope.live_controller.get_channels(objective)[0].name + yaml_path = tmp_path / "acq.yaml" + yaml_path.write_text( + yaml.safe_dump( + { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 1, "delta_z_mm": 0.001}, + "time_series": {"nt": 1, "delta_t_s": 0.0}, + "channels": [{"name": channel}], + "autofocus": {"contrast_af": False, "laser_af": False}, + "wellplate_scan": { + "scan_size_mm": 0.5, + "overlap_percent": 10, + "regions": [{"name": "A1", "center_mm": [14.3, 11.36, 0.5], "shape": "Square"}], + }, + } + ) + ) + body = { + "yaml_path": str(yaml_path), + "experiment_id": "rest_e2e", + "overrides": {"output_path": str(tmp_path / "out")}, + } + pre = client.post("/v1/acquisitions/preflight", json=body) + assert pre.status_code == 200 and pre.json()["ok"] is True + + accepted = client.post("/v1/acquisitions", json=body) + assert accepted.status_code == 202 + job_id = accepted.json()["job_id"] + assert accepted.headers["location"] == f"/v1/jobs/{job_id}" + + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + job = client.get(f"/v1/jobs/{job_id}").json() + if job["state"] == "COMPLETED": + break + time.sleep(0.5) + assert job["state"] == "COMPLETED" + assert job["outcome"] == "SUCCESS" + assert client.get("/v1/jobs/last").json()["job_id"] == job_id + + +# ---- URS delta (LA-WC-0001) ------------------------------------------------- + + +def test_initialize_accepts_optional_body(client): + r = client.post("/v1/system/initialize") + assert r.status_code == 200 + assert r.json()["no_op"] is True + + r2 = client.post("/v1/system/initialize", json={"home": False}) + assert r2.status_code == 200 + assert r2.json()["no_op"] is True + + +def test_methods_crud_over_rest(client, sim_scope): + config = _method_config(sim_scope) + + created = client.post("/v1/methods", json={"name": "rest_method", "config": config}) + assert created.status_code == 201 + assert created.json() == {"name": "rest_method", "created": True} + + listed = client.get("/v1/methods") + assert listed.status_code == 200 + assert any(m["name"] == "rest_method" for m in listed.json()["methods"]) + + got = client.get("/v1/methods/rest_method") + assert got.status_code == 200 + assert got.json()["config"]["acquisition"]["widget_type"] == "wellplate" + + validated = client.post("/v1/methods/rest_method/validate") + assert validated.status_code == 200 + assert validated.json()["ok"] is True + + deleted = client.delete("/v1/methods/rest_method") + assert deleted.status_code == 200 + assert deleted.json() == {"name": "rest_method", "deleted": True} + + missing = client.delete("/v1/methods/rest_method") + assert missing.status_code == 404 + assert missing.json()["error"]["code"] == 1001 + + +def test_acquisition_by_method_name_e2e(client, sim_scope, tmp_path): + config = _method_config(sim_scope) + created = client.post("/v1/methods", json={"name": "rest_e2e_method", "config": config}) + assert created.status_code == 201 + + body = { + "method": "rest_e2e_method", + "experiment_id": "rest_method_e2e", + "overrides": {"output_path": str(tmp_path / "out_method")}, + } + accepted = client.post("/v1/acquisitions", json=body) + assert accepted.status_code == 202 + job_id = accepted.json()["job_id"] + assert accepted.headers["location"] == f"/v1/jobs/{job_id}" + + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + job = client.get(f"/v1/jobs/{job_id}").json() + if job["state"] == "COMPLETED": + break + time.sleep(0.5) + assert job["state"] == "COMPLETED" + assert job["outcome"] == "SUCCESS" + + +def test_acquisition_wells_method_with_z_reference_e2e(client, sim_scope, tmp_path): + """A method specifying wells by name, run with an explicit z_reference in the body, + is accepted (202) and runs to completion over REST.""" + config = { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 1, "delta_z_mm": 0.001}, + "time_series": {"nt": 1, "delta_t_s": 0.0}, + "channels": [{"name": _first_channel(sim_scope)}], + "autofocus": {"contrast_af": False, "laser_af": False}, + "wellplate_scan": {"scan_size_mm": 0.5, "overlap_percent": 10, "wells": "A1"}, + } + created = client.post("/v1/methods", json={"name": "rest_wells_method", "config": config}) + assert created.status_code == 201 + + body = { + "method": "rest_wells_method", + "experiment_id": "rest_wells_zref", + "z_reference": {"z_mm": 3.0}, + "overrides": {"output_path": str(tmp_path / "out_wells")}, + } + accepted = client.post("/v1/acquisitions", json=body) + assert accepted.status_code == 202 + job_id = accepted.json()["job_id"] + assert accepted.headers["location"] == f"/v1/jobs/{job_id}" + + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + job = client.get(f"/v1/jobs/{job_id}").json() + if job["state"] == "COMPLETED": + break + time.sleep(0.5) + assert job["state"] == "COMPLETED" + assert job["outcome"] == "SUCCESS" + + +def test_sample_formats_endpoint(client): + r = client.get("/v1/sample_formats") + assert r.status_code == 200 + formats = {f["name"]: f for f in r.json()["formats"]} + assert "96 well plate" in formats + fmt = formats["96 well plate"] + for key in ("rows", "cols", "well_spacing_mm", "well_size_mm", "a1_x_mm", "a1_y_mm"): + assert isinstance(fmt[key], (int, float)) + + +def test_autofocus_store_reference_and_correct_rest(client): + # The default simulated scope has no reflection-AF hardware, so these + # ops must guard on hardware presence (CONFIG) or controller readiness + # (AUTOFOCUS) rather than crash. Assert the actual sim behavior. + r = client.post("/v1/autofocus/store_reference") + assert r.json()["error"]["category"] in ("CONFIG", "AUTOFOCUS") + assert r.status_code in (422, 503) + + r2 = client.post("/v1/autofocus/correct", json={}) + assert r2.json()["error"]["category"] in ("CONFIG", "AUTOFOCUS") + assert r2.status_code in (422, 503) + + +def test_autofocus_acquire_image_not_ready_is_canonical_fault(client): + # This fixture's config has laser-AF hardware present (support_laser_autofocus + # = True in configuration_Squid+.ini) but no frame has been streamed yet, so + # the default use_last_frame=True request must surface AUTOFOCUS_NOT_READY + # rather than crash or silently return no image. On a config with no AF + # hardware at all this would instead be CONFIG_CAPABILITY_MISSING -- assert + # both possibilities like the sibling store_reference/correct tests do. + r = client.post("/v1/autofocus/acquire_image", json={}) + assert r.json()["error"]["category"] in ("CONFIG", "AUTOFOCUS") + assert r.status_code in (422, 503) + + +# ---- Task 11: python_exec debug endpoint + URS delta (/v1/debug/settings) --- + + +def test_python_exec_disabled_by_default(client): + status = client.get("/v1/debug/python_exec/status") + assert status.json() == {"enabled": False} + r = client.post("/v1/debug/python_exec", json={"code": "result = 1 + 1"}) + assert r.status_code == 403 + assert r.json()["error"]["code"] == 1005 + + +def test_python_exec_when_enabled(client, service): + service.set_python_exec_enabled(True) + try: + r = client.post("/v1/debug/python_exec", json={"code": "result = {'x': stage.get_pos().x_mm}"}) + assert r.status_code == 200 + assert "x" in r.json()["result"] + finally: + service.set_python_exec_enabled(False) + + +def test_python_exec_bad_code_is_invalid_param_fault(client, service): + service.set_python_exec_enabled(True) + try: + r = client.post("/v1/debug/python_exec", json={"code": "this is not valid python"}) + assert r.status_code == 400 + assert r.json()["error"]["category"] == "INVALID_PARAM" + assert r.json()["error"]["code"] == 2002 + finally: + service.set_python_exec_enabled(False) + + +def test_python_exec_image_autosave(client, service): + service.set_python_exec_enabled(True) + try: + r = client.post( + "/v1/debug/python_exec", + json={"code": "image = np.zeros((4, 4), dtype=np.uint16)"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["image_shape"] == [4, 4] + assert body["image_dtype"] == "uint16" + assert body["image_path"].endswith((".tiff", ".npy")) + import os + + assert os.path.exists(body["image_path"]) + finally: + service.set_python_exec_enabled(False) + + +def test_debug_settings_view_settings_roundtrip_headless(client): + # client's service has NO gui attached (see fixtures above), which is exactly + # what's needed to exercise the headless performance_mode behavior below. + import control._def + + original_wells = control._def.SAVE_DOWNSAMPLED_WELL_IMAGES + original_mosaic = control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY + try: + got = client.get("/v1/debug/settings") + assert got.status_code == 200 + body = got.json() + assert body["performance_mode"] is None # headless -> null + assert body["save_downsampled_well_images"] == original_wells + assert body["display_mosaic_view"] == original_mosaic + + flipped = { + "save_downsampled_well_images": not original_wells, + "display_mosaic_view": not original_mosaic, + } + posted = client.post("/v1/debug/settings", json=flipped) + assert posted.status_code == 200 + assert posted.json()["save_downsampled_well_images"] == flipped["save_downsampled_well_images"] + assert posted.json()["display_mosaic_view"] == flipped["display_mosaic_view"] + assert control._def.SAVE_DOWNSAMPLED_WELL_IMAGES == flipped["save_downsampled_well_images"] + assert control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY == flipped["display_mosaic_view"] + + got2 = client.get("/v1/debug/settings") + assert got2.json()["save_downsampled_well_images"] == flipped["save_downsampled_well_images"] + assert got2.json()["display_mosaic_view"] == flipped["display_mosaic_view"] + + # Set the two settings back independently (both directions exercised). + restored = client.post( + "/v1/debug/settings", + json={"save_downsampled_well_images": original_wells, "display_mosaic_view": original_mosaic}, + ) + assert restored.status_code == 200 + assert restored.json()["save_downsampled_well_images"] == original_wells + assert restored.json()["display_mosaic_view"] == original_mosaic + finally: + control._def.SAVE_DOWNSAMPLED_WELL_IMAGES = original_wells + control._def.USE_NAPARI_FOR_MOSAIC_DISPLAY = original_mosaic + + +def test_debug_settings_performance_mode_headless_is_config_fault(client): + r = client.post("/v1/debug/settings", json={"performance_mode": True}) + assert r.status_code == 422 + assert r.json()["error"]["category"] == "CONFIG" + assert r.json()["error"]["code"] == 3003 + + +def test_debug_settings_get_performance_mode_null_headless(client): + r = client.get("/v1/debug/settings") + assert r.status_code == 200 + assert r.json()["performance_mode"] is None + + +def test_debug_settings_save_downsampled_overview_roundtrip(client): + import control._def + + original = control._def.SAVE_DOWNSAMPLED_OVERVIEW + try: + got = client.get("/v1/debug/settings") + assert got.json()["save_downsampled_overview"] == original + + posted = client.post("/v1/debug/settings", json={"save_downsampled_overview": not original}) + assert posted.status_code == 200 + assert posted.json()["save_downsampled_overview"] == (not original) + assert control._def.SAVE_DOWNSAMPLED_OVERVIEW == (not original) + finally: + control._def.SAVE_DOWNSAMPLED_OVERVIEW = original + + +# ---- catch-all fault handlers (URS API-ERR-003) ----------------------------- + + +def test_internal_error_handler_is_canonical_and_sanitized(service, monkeypatch): + # TestClient re-raises server exceptions by default; disable that so we can + # observe the 500 response the ServerErrorMiddleware produces from our handler. + app = create_app(service, ServiceConfig()) + client = TestClient(app, raise_server_exceptions=False) + + def boom(): + raise ValueError("SECRET internal detail that must not leak") + + monkeypatch.setattr(service, "get_position", boom) + r = client.get("/v1/motion/position") + assert r.status_code == 500 + error = r.json()["error"] + assert error["code"] == 5999 # HARDWARE_FAULT_INTERNAL + assert error["category"] == "HARDWARE_FAULT" + assert error["message"] == "Internal server error" + assert "SECRET" not in json.dumps(error) # no exception detail leaked + + +def test_unknown_route_is_canonical_404(client): + r = client.get("/v1/no/such/route") + assert r.status_code == 404 + assert r.json()["error"]["code"] == 1001 # PROTOCOL_UNKNOWN_RESOURCE diff --git a/software/tests/control/test_headless_service.py b/software/tests/control/test_headless_service.py new file mode 100644 index 000000000..62d293bfd --- /dev/null +++ b/software/tests/control/test_headless_service.py @@ -0,0 +1,85 @@ +"""End-to-end test of the headless wiring (squid_service.headless). + +The other core-service tests build their MultiPointController from test stubs; +these go through the production factory used by main_headless.py, proving a +GUI-free process can serve the full API and run acquisitions. +""" + +import pytest +import yaml + +import control._def +import control.microscope +from squid_service.headless import create_headless_service +from squid_service.models import AcquisitionRequest, MoveRequest +from squid_service.state import InstrumentState + + +@pytest.fixture(scope="module") +def sim_scope(): + scope = control.microscope.Microscope.build_from_global_config(True) + yield scope + scope.close() + + +@pytest.fixture() +def service(sim_scope, tmp_path): + return create_headless_service( + sim_scope, + simulation=True, + job_persist_path=tmp_path / "last_job.json", + methods_dir=tmp_path / "methods", + ) + + +def _write_yaml(tmp_path, sim_scope): + objective = sim_scope.objective_store.current_objective + channel = sim_scope.live_controller.get_channels(objective)[0].name + config = { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 1, "delta_z_mm": 0.001}, + "time_series": {"nt": 1, "delta_t_s": 0.0}, + "channels": [{"name": channel}], + "autofocus": {"contrast_af": False, "laser_af": False}, + "wellplate_scan": { + "scan_size_mm": 0.5, + "overlap_percent": 10, + "regions": [{"name": "A1", "center_mm": [14.3, 11.36, 0.5], "shape": "Square"}], + }, + } + path = tmp_path / "acquisition.yaml" + path.write_text(yaml.safe_dump(config)) + return str(path) + + +def test_headless_service_basic_commands(service): + status = service.status() + assert status["state"] == InstrumentState.INITIALIZED.value + assert service.capabilities()["channels"] + + before = service.get_position() + moved = service.move(MoveRequest(mode="relative", x=1.0)) + assert moved["position"]["x_mm"] == pytest.approx(before["x_mm"] + 1.0, abs=0.01) + + +def test_headless_laser_af_shares_microscope_instance(service, sim_scope): + if not (control._def.SUPPORT_LASER_AUTOFOCUS and sim_scope.addons.camera_focus): + pytest.skip("laser AF not enabled in this configuration") + assert sim_scope.laser_autofocus_controller is not None + assert service._mpc.laserAutoFocusController is sim_scope.laser_autofocus_controller + + +def test_headless_full_acquisition(service, sim_scope, tmp_path): + req = AcquisitionRequest( + yaml_path=_write_yaml(tmp_path, sim_scope), + experiment_id="headless_test", + overrides={"output_path": str(tmp_path / "out")}, + ) + handle = service.start_acquisition(req) + assert service.jobs.wait(handle["job_id"], timeout_s=120.0), "acquisition did not finish" + job = service.get_job(handle["job_id"]) + assert job["state"] == "COMPLETED" + assert job["outcome"] == "SUCCESS" + assert job["progress"]["images_acquired"] > 0 + assert service.state == InstrumentState.INITIALIZED diff --git a/software/tests/control/test_mcp_bridge.py b/software/tests/control/test_mcp_bridge.py new file mode 100644 index 000000000..6a90e81f3 --- /dev/null +++ b/software/tests/control/test_mcp_bridge.py @@ -0,0 +1,212 @@ +import asyncio + +import httpx +import pytest +from fastapi.testclient import TestClient # noqa: F401 (ensures fastapi present) + +import control.microscope +import mcp_microscope_server as bridge +from squid_service.config import ServiceConfig +from squid_service.rest.app import create_app +from squid_service.service import SquidCoreService + + +@pytest.fixture(scope="module") +def sim_scope(): + scope = control.microscope.Microscope.build_from_global_config(True) + yield scope + scope.close() + + +@pytest.fixture() +def asgi_client(sim_scope): + service = SquidCoreService(microscope=sim_scope, simulation=True) + app = create_app(service, ServiceConfig()) + transport = httpx.ASGITransport(app=app) + return bridge.make_client(transport=transport) + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def test_tool_list_is_static_and_curated(): + tools = bridge.tool_definitions() + names = {t.name for t in tools} + assert "microscope_ping" in names + assert "microscope_move_to" in names + assert "microscope_run_acquisition_from_yaml" in names + assert "microscope_python_exec" in names + move = next(t for t in tools if t.name == "microscope_move_to") + assert "x_mm" in move.inputSchema["properties"] # legacy arg names preserved + + +def test_dispatch_ping_and_position(asgi_client): + result = _run(bridge.dispatch(asgi_client, "microscope_ping", {})) + assert result["alive"] is True + pos = _run(bridge.dispatch(asgi_client, "microscope_get_position", {})) + assert set(pos) == {"x_mm", "y_mm", "z_mm"} + + +def test_dispatch_move_maps_legacy_args(asgi_client): + result = _run(bridge.dispatch(asgi_client, "microscope_move_to", {"x_mm": 10.0, "y_mm": 10.0})) + assert result["position"]["x_mm"] == pytest.approx(10.0, abs=0.01) + + +def test_dispatch_surfaces_canonical_fault(asgi_client): + result = _run(bridge.dispatch(asgi_client, "microscope_set_channel", {"channel_name": "No Such"})) + assert result["error"]["category"] == "CONFIG" + assert result["error"]["code"] == 3001 + + +def test_unknown_tool_rejected(asgi_client): + with pytest.raises(ValueError): + _run(bridge.dispatch(asgi_client, "microscope_nonexistent", {})) + + +# ---- URS delta (API-COMPAT-002, binding, added 2026-07-02) ----------------- +# +# Legacy TCP-era tool names not covered above, mapped onto the new REST API, +# plus four brand-new tools. `microscope_set_display_plate_view` is +# intentionally NOT present: the underlying `control._def.DISPLAY_PLATE_VIEW` +# flag no longer exists on master (plate view was unified into the mosaic +# view / UnifiedMosaicWidget, governed solely by `display_mosaic_view`), so +# there is nothing left for that tool to control. + + +def test_tool_list_includes_urs_delta_and_skips_display_plate_view(): + tools = bridge.tool_definitions() + names = {t.name for t in tools} + for name in ( + "microscope_run_acquisition", + "microscope_set_performance_mode", + "microscope_get_performance_mode", + "microscope_get_view_settings", + "microscope_set_view_settings", + "microscope_set_save_downsampled_images", + "microscope_set_save_downsampled_overview", + "microscope_set_display_mosaic_view", + "microscope_get_methods", + "microscope_run_method", + "microscope_autofocus_status", + "microscope_store_af_reference", + ): + assert name in names, name + assert "microscope_set_display_plate_view" not in names + + +def test_tool_list_includes_acquire_laser_af_image(): + # URS API-COMPAT-002 follow-up: the legacy TCP `_cmd_acquire_laser_af_image` + # command was dropped when the bridge was rewritten; it must be restored + # with its original argument names. + tools = bridge.tool_definitions() + tool = next(t for t in tools if t.name == "microscope_acquire_laser_af_image") + assert set(tool.inputSchema["properties"]) == {"save_path", "use_last_frame"} + + +def test_dispatch_run_acquisition_maps_legacy_grid_args(asgi_client, sim_scope): + # asgi_client's service has no MultiPointController/ScanCoordinates attached + # (see the fixture above), so a body that's well-formed enough to pass + # AcquisitionRequest/GridSpec validation reaches the service layer and + # fails there with CONFIG_CAPABILITY_MISSING -- NOT a PROTOCOL schema + # violation. That distinguishes "legacy args mapped into a valid GridSpec" + # from "mapped into garbage that 422s at the FastAPI boundary". + objective = sim_scope.objective_store.current_objective + channel = sim_scope.live_controller.get_channels(objective)[0].name + result = _run( + bridge.dispatch( + asgi_client, + "microscope_run_acquisition", + { + "wells": "A1", + "channels": [channel], + "nx": 1, + "ny": 1, + "experiment_id": "grid_test", + "base_path": "/tmp", + }, + ) + ) + assert result["error"]["category"] == "CONFIG" + assert result["error"]["code"] == 3003 + + +def test_dispatch_run_method_maps_legacy_args(asgi_client): + result = _run( + bridge.dispatch( + asgi_client, + "microscope_run_method", + {"method": "some_method", "wells": "A1", "base_path": "/tmp", "operator": "tester"}, + ) + ) + assert result["error"]["category"] == "CONFIG" + assert result["error"]["code"] == 3003 + + +def test_dispatch_performance_mode_headless(asgi_client): + got = _run(bridge.dispatch(asgi_client, "microscope_get_performance_mode", {})) + assert got["performance_mode"] is None # headless service -> no GUI attached + + result = _run(bridge.dispatch(asgi_client, "microscope_set_performance_mode", {"enabled": True})) + assert result["error"]["category"] == "CONFIG" + assert result["error"]["code"] == 3003 + + +def test_dispatch_view_settings_roundtrip(asgi_client): + import control._def as _def + + original_wells = _def.SAVE_DOWNSAMPLED_WELL_IMAGES + original_mosaic = _def.USE_NAPARI_FOR_MOSAIC_DISPLAY + try: + got = _run(bridge.dispatch(asgi_client, "microscope_get_view_settings", {})) + assert "save_downsampled_well_images" in got + assert "display_mosaic_view" in got + + flipped_wells = _run( + bridge.dispatch(asgi_client, "microscope_set_save_downsampled_images", {"enabled": not original_wells}) + ) + assert flipped_wells["save_downsampled_well_images"] == (not original_wells) + + flipped_mosaic = _run( + bridge.dispatch(asgi_client, "microscope_set_display_mosaic_view", {"enabled": not original_mosaic}) + ) + assert flipped_mosaic["display_mosaic_view"] == (not original_mosaic) + + restored = _run( + bridge.dispatch( + asgi_client, + "microscope_set_view_settings", + {"save_downsampled_well_images": original_wells, "display_mosaic_view": original_mosaic}, + ) + ) + assert restored["save_downsampled_well_images"] == original_wells + assert restored["display_mosaic_view"] == original_mosaic + finally: + _def.SAVE_DOWNSAMPLED_WELL_IMAGES = original_wells + _def.USE_NAPARI_FOR_MOSAIC_DISPLAY = original_mosaic + + +def test_dispatch_get_methods_without_registry(asgi_client): + result = _run(bridge.dispatch(asgi_client, "microscope_get_methods", {})) + assert result["error"]["category"] == "CONFIG" + assert result["error"]["code"] == 3003 + + +def test_dispatch_autofocus_status_and_store_reference(asgi_client): + status = _run(bridge.dispatch(asgi_client, "microscope_autofocus_status", {})) + assert set(status) >= {"available", "initialized", "reference_set", "readiness"} + + # The default simulated scope has no reflection-AF hardware, so this must + # surface a canonical fault rather than crash. + result = _run(bridge.dispatch(asgi_client, "microscope_store_af_reference", {})) + assert result["error"]["category"] in ("CONFIG", "AUTOFOCUS") + + +def test_dispatch_acquire_laser_af_image_not_ready(asgi_client): + # Same sim scope as test_dispatch_autofocus_status_and_store_reference: + # AF hardware is configured but no frame has been streamed, so the + # default use_last_frame=True request must surface a canonical fault + # (AUTOFOCUS_NOT_READY here, or CONFIG_CAPABILITY_MISSING on a config + # with no AF hardware at all) rather than being dropped/unmapped. + result = _run(bridge.dispatch(asgi_client, "microscope_acquire_laser_af_image", {})) + assert result["error"]["category"] in ("CONFIG", "AUTOFOCUS") diff --git a/software/tests/squid_service/test_config.py b/software/tests/squid_service/test_config.py new file mode 100644 index 000000000..ace9d63dc --- /dev/null +++ b/software/tests/squid_service/test_config.py @@ -0,0 +1,96 @@ +import pytest +from pydantic import ValidationError + +from squid_service.config import ServiceConfig +from squid_service.models import AcquisitionRequest, ExposureRequest, MoveRequest, ZMillimeters + + +def test_defaults_are_loopback_no_auth(): + cfg = ServiceConfig() + assert cfg.host == "127.0.0.1" + assert cfg.port == 8060 + assert cfg.auth_enabled is False + + +def test_non_loopback_requires_auth_token(): + with pytest.raises(ValidationError): + ServiceConfig(host="0.0.0.0") + with pytest.raises(ValidationError): + ServiceConfig(host="0.0.0.0", auth_enabled=True, auth_token="") + cfg = ServiceConfig(host="0.0.0.0", auth_enabled=True, auth_token="s3cret") + assert cfg.auth_enabled + + +def test_from_def_reads_globals(monkeypatch): + import control._def + + monkeypatch.setattr(control._def, "CORE_SERVICE_HOST", "127.0.0.1", raising=False) + monkeypatch.setattr(control._def, "CORE_SERVICE_PORT", 5099, raising=False) + monkeypatch.setattr(control._def, "CORE_SERVICE_AUTH_ENABLED", False, raising=False) + monkeypatch.setattr(control._def, "CORE_SERVICE_AUTH_TOKEN", "", raising=False) + cfg = ServiceConfig.from_def() + assert cfg.port == 5099 + + +def test_move_request_rejects_extras(): + with pytest.raises(ValidationError): + MoveRequest(x_mm=1.0) # old TCP field name must NOT validate silently + req = MoveRequest(mode="relative", x=1.5) + assert req.block_until_complete is True + + +def test_exposure_bounds(): + with pytest.raises(ValidationError): + ExposureRequest(exposure_ms=0) + with pytest.raises(ValidationError): + ExposureRequest(exposure_ms=20000) + + +def test_acquisition_request_shape(): + req = AcquisitionRequest(yaml_path="/tmp/a.yaml", overrides={"wells": "A1:B2"}) + assert req.overrides.wells == "A1:B2" + assert req.overrides.output_path is None + assert req.overrides.sample_format is None + + +def test_acquisition_request_requires_exactly_one_source(): + with pytest.raises(ValidationError): + AcquisitionRequest() # none of method/yaml_path/grid + with pytest.raises(ValidationError): + AcquisitionRequest(method="m1", yaml_path="/tmp/a.yaml") # two sources + req = AcquisitionRequest(method="spheroid_4ch_20x", autofocus={"reflection": True}) + assert req.method == "spheroid_4ch_20x" + assert req.autofocus.reflection is True and req.autofocus.contrast is None + + +def test_grid_spec_validation(): + req = AcquisitionRequest(grid={"wells": "A1:B2", "channels": ["BF LED matrix full"]}) + assert req.grid.nx == 2 and req.grid.wellplate_format == "96 well plate" + with pytest.raises(ValidationError): + AcquisitionRequest(grid={"wells": "A1", "channels": []}) # empty channels + + +def test_z_reference_defaults_to_current(): + req = AcquisitionRequest(yaml_path="/tmp/a.yaml") + assert req.z_reference == "current" + + +def test_z_reference_accepts_literals(): + assert AcquisitionRequest(yaml_path="/tmp/a.yaml", z_reference="current").z_reference == "current" + assert AcquisitionRequest(yaml_path="/tmp/a.yaml", z_reference="autofocus").z_reference == "autofocus" + + +def test_z_reference_accepts_z_mm_object(): + req = AcquisitionRequest(yaml_path="/tmp/a.yaml", z_reference={"z_mm": 3.2}) + assert isinstance(req.z_reference, ZMillimeters) + assert req.z_reference.z_mm == 3.2 + + +def test_z_reference_rejects_garbage(): + with pytest.raises(ValidationError): + AcquisitionRequest(yaml_path="/tmp/a.yaml", z_reference="somewhere") + with pytest.raises(ValidationError): + AcquisitionRequest(yaml_path="/tmp/a.yaml", z_reference=3.2) # bare float, not {"z_mm": ...} + with pytest.raises(ValidationError): + # extra keys are forbidden on the strict z_mm model + AcquisitionRequest(yaml_path="/tmp/a.yaml", z_reference={"z_mm": 3.2, "unit": "mm"}) diff --git a/software/tests/squid_service/test_events.py b/software/tests/squid_service/test_events.py new file mode 100644 index 000000000..a6d2100e1 --- /dev/null +++ b/software/tests/squid_service/test_events.py @@ -0,0 +1,49 @@ +import queue + +from squid_service.events import EventBus + + +def test_publish_monotonic_ids_and_subscriber_delivery(): + bus = EventBus() + q = bus.subscribe() + e1 = bus.publish("state_changed", {"old": "A", "new": "B"}) + e2 = bus.publish("progress", {"n": 1}) + assert (e1.id, e2.id) == (1, 2) + assert q.get_nowait() is e1 + assert q.get_nowait() is e2 + bus.unsubscribe(q) + bus.publish("progress", {"n": 2}) + with __import__("pytest").raises(queue.Empty): + q.get_nowait() + + +def test_replay_since(): + bus = EventBus() + for i in range(5): + bus.publish("progress", {"n": i}) + events, gap = bus.replay_since(2) + assert [e.id for e in events] == [3, 4, 5] + assert gap is False + + +def test_replay_gap_when_buffer_overflows(): + bus = EventBus(buffer_size=3) + for i in range(10): + bus.publish("progress", {"n": i}) + events, gap = bus.replay_since(1) # id 2 has been evicted + assert gap is True + assert [e.id for e in events] == [8, 9, 10] + + +def test_replay_since_current_is_empty_no_gap(): + bus = EventBus(buffer_size=3) + for i in range(10): + bus.publish("progress", {"n": i}) + events, gap = bus.replay_since(10) + assert events == [] and gap is False + + +def test_session_id_stable(): + bus = EventBus() + assert bus.session_id == bus.session_id + assert len(bus.session_id) >= 8 diff --git a/software/tests/squid_service/test_faults.py b/software/tests/squid_service/test_faults.py new file mode 100644 index 000000000..dc1619862 --- /dev/null +++ b/software/tests/squid_service/test_faults.py @@ -0,0 +1,66 @@ +from squid_service import faults as F + + +def test_make_fault_shape(): + f = F.make_fault(F.FaultCategory.INVALID_PARAM, F.INVALID_PARAM_OUT_OF_RANGE, "x out of range") + d = f.model_dump() + for key in ( + "category", + "code", + "recoverable", + "scheduler_action", + "sequence", + "component", + "message", + "detail", + "timestamp", + "terminal", + "operator_intervention_required", + "plate_removable", + "resolved_at", + "resolved_by", + ): + assert key in d + assert d["category"] == "INVALID_PARAM" + assert d["code"] == 2001 + assert d["timestamp"].endswith("Z") + + +def test_http_status_mapping(): + cases = [ + (F.FaultCategory.PROTOCOL, F.PROTOCOL_UNKNOWN_RESOURCE, 404), + (F.FaultCategory.PROTOCOL, F.PROTOCOL_WRONG_STATE, 409), + (F.FaultCategory.PROTOCOL, F.PROTOCOL_SCHEMA_VIOLATION, 422), + (F.FaultCategory.PROTOCOL, F.PROTOCOL_AUTH, 401), + (F.FaultCategory.PROTOCOL, F.PROTOCOL_FORBIDDEN, 403), + (F.FaultCategory.PROTOCOL, F.PROTOCOL_NOT_IMPLEMENTED, 501), + (F.FaultCategory.INVALID_PARAM, F.INVALID_PARAM_OUT_OF_RANGE, 400), + (F.FaultCategory.CONFIG, F.CONFIG_UNKNOWN_CHANNEL, 422), + (F.FaultCategory.HARDWARE_TRANSIENT, F.HARDWARE_TRANSIENT_TIMEOUT, 503), + (F.FaultCategory.HARDWARE_FAULT, F.HARDWARE_FAULT_GENERIC, 503), + (F.FaultCategory.HARDWARE_FAULT, F.HARDWARE_FAULT_INTERNAL, 500), + (F.FaultCategory.ACQUISITION, F.ACQUISITION_RUNTIME, 503), + (F.FaultCategory.IO, F.IO_DISK_FULL, 507), + (F.FaultCategory.IO, F.IO_GENERIC, 500), + (F.FaultCategory.AUTOFOCUS, F.AUTOFOCUS_FAILURE, 503), + ] + for category, code, expected in cases: + fault = F.make_fault(category, code, "msg") + assert F.http_status_for(fault) == expected, (category, code) + + +def test_fault_log_sequences_and_since(): + log = F.FaultLog() + f1 = log.record(F.make_fault(F.FaultCategory.IO, F.IO_GENERIC, "one")) + f2 = log.record(F.make_fault(F.FaultCategory.IO, F.IO_GENERIC, "two")) + assert (f1.sequence, f2.sequence) == (1, 2) + assert log.latest.message == "two" + assert [f.message for f in log.since(1)] == ["two"] + assert log.since(2) == [] + + +def test_fault_error_carries_fault(): + fault = F.make_fault(F.FaultCategory.CONFIG, F.CONFIG_UNKNOWN_CHANNEL, "nope") + err = F.FaultError(fault) + assert err.fault is fault + assert "nope" in str(err) diff --git a/software/tests/squid_service/test_jobs.py b/software/tests/squid_service/test_jobs.py new file mode 100644 index 000000000..8d68c18e6 --- /dev/null +++ b/software/tests/squid_service/test_jobs.py @@ -0,0 +1,51 @@ +from pathlib import Path + +from squid_service.jobs import JobOutcome, JobResult, JobState, JobStore + + +def test_job_lifecycle(): + store = JobStore() + job = store.create(experiment_id="exp1", expected_total_images=10) + assert job.state == JobState.ACCEPTED + assert store.active.job_id == job.job_id + store.mark_running(job.job_id) + assert store.get(job.job_id).state == JobState.RUNNING + assert store.get(job.job_id).started_at is not None + store.update_progress(job.job_id, images_acquired=4, elapsed_s=2.0) + prog = store.get(job.job_id).progress + assert prog.images_acquired == 4 + assert prog.total_images == 10 + done = store.complete(job.job_id, JobOutcome.SUCCESS, JobResult(image_count_written=10)) + assert done.state == JobState.COMPLETED + assert done.outcome == JobOutcome.SUCCESS + assert done.completed_at is not None + assert store.active is None + assert store.last.job_id == job.job_id + + +def test_wait_returns_true_after_complete(): + store = JobStore() + job = store.create(experiment_id=None) + assert store.wait(job.job_id, timeout_s=0.05) is False + store.complete(job.job_id, JobOutcome.ABORTED, JobResult()) + assert store.wait(job.job_id, timeout_s=0.05) is True + + +def test_last_job_persists_across_stores(tmp_path: Path): + path = tmp_path / "last_job.json" + store = JobStore(persist_path=path) + job = store.create(experiment_id="exp2") + store.complete(job.job_id, JobOutcome.FAILURE, JobResult(errors_encountered=3)) + reloaded = JobStore(persist_path=path) + assert reloaded.last is not None + assert reloaded.last.job_id == job.job_id + assert reloaded.last.outcome == JobOutcome.FAILURE + + +def test_estimated_remaining_computed(): + store = JobStore() + job = store.create(experiment_id="e", expected_total_images=100) + store.mark_running(job.job_id) + store.update_progress(job.job_id, images_acquired=25, elapsed_s=50.0) + est = store.get(job.job_id).progress.estimated_remaining_s + assert est is not None and 149.0 < est < 151.0 diff --git a/software/tests/squid_service/test_methods.py b/software/tests/squid_service/test_methods.py new file mode 100644 index 000000000..e6391513c --- /dev/null +++ b/software/tests/squid_service/test_methods.py @@ -0,0 +1,140 @@ +"""Unit tests for the named acquisition-method registry (URS API-METH-001..005). + +Pure Python: no hardware, no Microscope. Uses tmp_path for the methods dir. +""" + +import pytest +import yaml + +from squid_service.faults import FaultCategory, FaultError +from squid_service.methods import MethodRegistry + + +def _valid_config(channel="BF LED matrix full"): + return { + "acquisition": {"widget_type": "wellplate"}, + "sample": {"wellplate_format": "96 well plate"}, + "z_stack": {"nz": 1, "delta_z_mm": 0.001}, + "time_series": {"nt": 1, "delta_t_s": 0.0}, + "channels": [{"name": channel}], + "autofocus": {"contrast_af": False, "laser_af": False}, + "wellplate_scan": { + "scan_size_mm": 0.5, + "overlap_percent": 10, + "regions": [{"name": "A1", "center_mm": [14.3, 11.36, 0.5], "shape": "Square"}], + }, + } + + +def test_save_list_get_roundtrip(tmp_path): + reg = MethodRegistry(tmp_path) + reg.save("scan_a", _valid_config(), overwrite=False) + + listed = reg.list() + assert len(listed) == 1 + summary = listed[0] + assert summary["name"] == "scan_a" + assert summary["widget_type"] == "wellplate" + assert summary["nz"] == 1 + assert summary["nt"] == 1 + assert summary["wellplate_format"] == "96 well plate" + + got = reg.get("scan_a") + assert got["name"] == "scan_a" + assert got["config"]["acquisition"]["widget_type"] == "wellplate" + + +def test_save_existing_without_overwrite_faults(tmp_path): + reg = MethodRegistry(tmp_path) + reg.save("dup", _valid_config(), overwrite=False) + with pytest.raises(FaultError) as exc: + reg.save("dup", _valid_config(), overwrite=False) + assert exc.value.fault.category == FaultCategory.INVALID_PARAM + + +def test_overwrite_missing_faults_unknown(tmp_path): + reg = MethodRegistry(tmp_path) + with pytest.raises(FaultError) as exc: + reg.save("ghost", _valid_config(), overwrite=True) + assert exc.value.fault.category == FaultCategory.PROTOCOL + assert exc.value.fault.code == 1001 # PROTOCOL_UNKNOWN_RESOURCE + + +def test_update_existing_with_overwrite(tmp_path): + reg = MethodRegistry(tmp_path) + reg.save("m", _valid_config(), overwrite=False) + updated = _valid_config() + updated["time_series"]["nt"] = 3 + reg.save("m", updated, overwrite=True) + assert reg.get("m")["config"]["time_series"]["nt"] == 3 + + +def test_delete_unknown_faults(tmp_path): + reg = MethodRegistry(tmp_path) + with pytest.raises(FaultError) as exc: + reg.delete("nope") + assert exc.value.fault.category == FaultCategory.PROTOCOL + assert exc.value.fault.code == 1001 + + +def test_delete_roundtrip(tmp_path): + reg = MethodRegistry(tmp_path) + reg.save("gone", _valid_config(), overwrite=False) + assert reg.exists("gone") + reg.delete("gone") + assert not reg.exists("gone") + + +@pytest.mark.parametrize("bad_name", ["../evil", "", "a/b", "with space", ".hidden"]) +def test_invalid_name_faults(tmp_path, bad_name): + reg = MethodRegistry(tmp_path) + with pytest.raises(FaultError) as exc: + reg.path_for(bad_name) + assert exc.value.fault.category == FaultCategory.INVALID_PARAM + assert reg.exists(bad_name) is False + + +def test_save_invalid_name_faults(tmp_path): + reg = MethodRegistry(tmp_path) + with pytest.raises(FaultError) as exc: + reg.save("../evil", _valid_config(), overwrite=False) + assert exc.value.fault.category == FaultCategory.INVALID_PARAM + + +def test_save_invalid_config_faults(tmp_path): + reg = MethodRegistry(tmp_path) + bad = {"acquisition": {"widget_type": "not_a_real_type"}} + with pytest.raises(FaultError) as exc: + reg.save("bad_cfg", bad, overwrite=False) + assert exc.value.fault.category == FaultCategory.INVALID_PARAM + + +def test_list_carries_error_for_unparseable(tmp_path): + reg = MethodRegistry(tmp_path) + reg.save("good", _valid_config(), overwrite=False) + (tmp_path / "broken.yaml").write_text("{ this is not: valid: yaml :::") + + listed = {s["name"]: s for s in reg.list()} + assert "good" in listed and "error" not in listed["good"] + assert "broken" in listed and "error" in listed["broken"] + + +def test_list_empty_when_dir_missing(tmp_path): + reg = MethodRegistry(tmp_path / "does_not_exist") + assert reg.list() == [] + + +def test_get_unknown_faults(tmp_path): + reg = MethodRegistry(tmp_path) + with pytest.raises(FaultError) as exc: + reg.get("missing") + assert exc.value.fault.code == 1001 + + +def test_saved_file_is_yaml(tmp_path): + reg = MethodRegistry(tmp_path) + reg.save("y", _valid_config(), overwrite=False) + path = tmp_path / "y.yaml" + assert path.exists() + loaded = yaml.safe_load(path.read_text()) + assert loaded["acquisition"]["widget_type"] == "wellplate" diff --git a/software/tests/squid_service/test_server.py b/software/tests/squid_service/test_server.py new file mode 100644 index 000000000..433c01d3a --- /dev/null +++ b/software/tests/squid_service/test_server.py @@ -0,0 +1,36 @@ +import socket +import time + +import httpx + +from squid_service.config import ServiceConfig +from squid_service.rest.app import create_app +from squid_service.rest.server import CoreServiceServer + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def test_server_start_serve_stop(): + # /v1/healthz touches neither service nor hardware, a bare object suffices + app = create_app(service=object(), config=ServiceConfig()) + port = _free_port() + server = CoreServiceServer(app, "127.0.0.1", port) + server.start() + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + r = httpx.get(f"http://127.0.0.1:{port}/v1/healthz", timeout=1.0) + if r.status_code == 200: + break + except httpx.TransportError: + time.sleep(0.1) + assert r.json() == {"alive": True} + assert server.is_running() + finally: + server.stop() + assert not server.is_running() diff --git a/software/tests/squid_service/test_state.py b/software/tests/squid_service/test_state.py new file mode 100644 index 000000000..08ac9455f --- /dev/null +++ b/software/tests/squid_service/test_state.py @@ -0,0 +1,53 @@ +import pytest + +from squid_service.state import BUSY_STATES, InstrumentState, InvalidTransition, StateMachine + + +def test_initial_state_and_legal_transition(): + transitions = [] + sm = StateMachine(InstrumentState.INITIALIZED, on_transition=lambda o, n: transitions.append((o, n))) + assert sm.state == InstrumentState.INITIALIZED + sm.transition(InstrumentState.ACQUIRING) + assert sm.state == InstrumentState.ACQUIRING + assert transitions == [(InstrumentState.INITIALIZED, InstrumentState.ACQUIRING)] + + +def test_illegal_transition_raises_and_preserves_state(): + sm = StateMachine(InstrumentState.INITIALIZED) + with pytest.raises(InvalidTransition): + sm.transition(InstrumentState.PROCESSING) + assert sm.state == InstrumentState.INITIALIZED + + +def test_full_acquisition_lifecycle(): + sm = StateMachine(InstrumentState.INITIALIZED) + for target in ( + InstrumentState.ACQUIRING, + InstrumentState.PROCESSING, + InstrumentState.INITIALIZED, + ): + sm.transition(target) + assert sm.state == InstrumentState.INITIALIZED + + +def test_error_and_recovery_paths(): + sm = StateMachine(InstrumentState.ACQUIRING) + sm.transition(InstrumentState.ERROR) + sm.transition(InstrumentState.RECOVERING) + sm.transition(InstrumentState.INITIALIZED) + assert sm.state == InstrumentState.INITIALIZED + + +def test_busy_states(): + assert InstrumentState.ACQUIRING in BUSY_STATES + assert InstrumentState.INITIALIZED not in BUSY_STATES + sm = StateMachine(InstrumentState.ACQUIRING) + assert sm.is_busy() + + +def test_self_transition_is_noop_no_listener(): + calls = [] + sm = StateMachine(InstrumentState.INITIALIZED, on_transition=lambda o, n: calls.append(1)) + sm.transition(InstrumentState.INITIALIZED) + assert sm.state == InstrumentState.INITIALIZED + assert calls == [] diff --git a/software/tests/squid_service/test_timeutil.py b/software/tests/squid_service/test_timeutil.py new file mode 100644 index 000000000..44332f9c1 --- /dev/null +++ b/software/tests/squid_service/test_timeutil.py @@ -0,0 +1,8 @@ +import re + +from squid_service.timeutil import utc_now_iso + + +def test_utc_now_iso_format(): + ts = utc_now_iso() + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", ts) diff --git a/software/tests/squid_service/test_wells.py b/software/tests/squid_service/test_wells.py new file mode 100644 index 000000000..ad41af85c --- /dev/null +++ b/software/tests/squid_service/test_wells.py @@ -0,0 +1,47 @@ +import pytest + +import control._def +from squid_service.wells import index_to_row, parse_well_names, row_to_index, well_center_mm + +SETTINGS = {"a1_x_mm": 14.3, "a1_y_mm": 11.36, "well_spacing_mm": 9.0, "rows": 8, "cols": 12} + + +def test_row_index_roundtrip(): + for name, idx in (("A", 0), ("H", 7), ("Z", 25), ("AA", 26), ("AF", 31)): + assert row_to_index(name) == idx + assert index_to_row(idx) == name + + +def test_parse_single_and_list(): + assert parse_well_names("A1") == ["A1"] + assert parse_well_names("a1, b12") == ["A1", "B12"] + + +def test_parse_range_expands_rectangle(): + assert parse_well_names("A1:B3") == ["A1", "A2", "A3", "B1", "B2", "B3"] + + +def test_parse_mixed_range_and_list(): + assert parse_well_names("A1:A2,C5") == ["A1", "A2", "C5"] + + +def test_parse_rejects_garbage(): + with pytest.raises(ValueError): + parse_well_names("1A") + with pytest.raises(ValueError): + parse_well_names("") + + +def test_well_center_includes_wellplate_offset(monkeypatch): + monkeypatch.setattr(control._def, "WELLPLATE_OFFSET_X_mm", 2.0, raising=False) + monkeypatch.setattr(control._def, "WELLPLATE_OFFSET_Y_mm", -1.0, raising=False) + x, y = well_center_mm("B3", SETTINGS) + assert x == pytest.approx(14.3 + 2 * 9.0 + 2.0) + assert y == pytest.approx(11.36 + 1 * 9.0 - 1.0) + + +def test_well_center_rejects_out_of_plate(): + with pytest.raises(ValueError): + well_center_mm("I1", SETTINGS) # row 9 on an 8-row plate + with pytest.raises(ValueError): + well_center_mm("A13", SETTINGS)