diff --git a/asyncroscopy/data/data_reader.py b/asyncroscopy/data/data_reader.py new file mode 100644 index 00000000..f888a653 --- /dev/null +++ b/asyncroscopy/data/data_reader.py @@ -0,0 +1,79 @@ +"""Shared logic for reading Tiled dataset metadata and small previews. + +Both the MCP bridge (``get_data_from_key``) and the electron microscope's +legacy byte-over-Tango command (``get_image_data_cached``) need the same +thing: given an already-resolved Tiled node, describe its shape/dtype/attrs +and a small flattened preview. This module is the one place that logic +lives; callers are responsible for resolving the Tiled client/node +themselves, since that involves a DeviceProxy in one case and an in-process +DeviceProxy in the other. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def numpy_to_python(obj: Any) -> Any: + """Recursively convert numpy types to Python types for JSON serialization.""" + if isinstance(obj, np.ndarray): + return numpy_to_python(obj.tolist()) + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, dict): + return {k: numpy_to_python(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + conv = [numpy_to_python(v) for v in obj] + return tuple(conv) if isinstance(obj, tuple) else conv + return obj + + +def describe_tiled_node(key: str, uri: str, node: Any, max_values: int = 64) -> dict[str, Any]: + """Build shape/dtype/attrs metadata plus a small flattened preview for one Tiled node.""" + limit = max(0, int(max_values)) + suffix = key.rsplit(".", 1)[-1].lower() if "." in key else "unknown" + result: dict[str, Any] = { + "key": key, + "uri": uri, + "format": "hdf5" if suffix in {"h5", "hdf5"} else suffix, + "attrs": numpy_to_python(dict(getattr(node, "metadata", {}) or {})), + } + datasets: list[dict[str, Any]] = [] + + def visit(current: Any, name: str = "") -> None: + read = getattr(current, "read", None) + if callable(read): + shape = tuple(getattr(current, "shape", ()) or ()) + if limit == 0: + array = np.asarray([], dtype=getattr(current, "dtype", float)) + elif shape: + remaining = limit + slices = [] + for size in reversed(shape): + take = min(int(size), max(1, remaining)) + slices.append(slice(0, take)) + remaining = (remaining + take - 1) // take + array = np.asarray(read(tuple(reversed(slices)))) + else: + array = np.asarray(read()) + item: dict[str, Any] = { + "name": name, + "shape": list(shape or array.shape), + "dtype": str(getattr(current, "dtype", array.dtype)), + "attrs": numpy_to_python(dict(getattr(current, "metadata", {}) or {})), + "preview": numpy_to_python(array.reshape(-1)[:limit]), + } + datasets.append(item) + return + + keys = getattr(current, "keys", None) + if callable(keys): + for child_name in keys(): + child_path = f"{name}/{child_name}" if name else str(child_name) + visit(current[child_name], child_path) + + visit(node) + result["datasets"] = datasets + return result diff --git a/asyncroscopy/data/data_writer.py b/asyncroscopy/data/data_writer.py index 3bc173e0..1457f0ce 100644 --- a/asyncroscopy/data/data_writer.py +++ b/asyncroscopy/data/data_writer.py @@ -71,6 +71,8 @@ def save_acquisition( raise ValueError(f"Unsupported output_format {output_format!r}; expected '.h5' or '.tiff'") detector_list = list(detectors) if isinstance(detectors, (list, tuple)) else [detectors] data_list = list(data) if isinstance(data, (list, tuple)) else [data] + if not data_list: + raise ValueError("save_acquisition called with no data to save (empty detector/data list)") attrs_list = dataset_attrs if isinstance(dataset_attrs, list) else [dataset_attrs] * len(data_list) if output_format == ".tiff": diff --git a/asyncroscopy/instruments/electron_microscope/digital_twin.py b/asyncroscopy/instruments/electron_microscope/digital_twin.py index fc6bbc57..d6e2c2fb 100644 --- a/asyncroscopy/instruments/electron_microscope/digital_twin.py +++ b/asyncroscopy/instruments/electron_microscope/digital_twin.py @@ -16,6 +16,7 @@ from tango.server import Device, attribute, device_property from asyncroscopy.instruments.electron_microscope.electron_microscope import ElectronMicroscope +from asyncroscopy.instruments.electron_microscope.detectors.camera import CAMERA from asyncroscopy.data.data_writer import save_acquisition DEFAULT_ACQUISITION_DIR = "outputs/tiled_acquisitions" @@ -129,6 +130,7 @@ def _connect(self): def _connect_detector_proxies(self) -> None: """Build DeviceProxy objects for each configured detector device.""" addresses: dict[str, str] = { + "camera": self.camera_device_address, "eds": self.eds_device_address, "stage": self.stage_device_address, "scan": self.scan_device_address, @@ -529,6 +531,25 @@ def _acquire_scanned_image( images.append(image) return save_acquisition(self, data_server, "stem_image", detector_list, images, output_format=output_format) + def _acquire_camera_image( + self, + imsize: int, + exposure_time: float, + detector: str, + readout_area: str, + frame_combining: int = 1, + electron_counting: bool = True, + output_format: str = ".h5", + ) -> str: + """Simulate a single-shot camera acquisition. + + DigitalTwin only models STEM-probe imaging (see _render_stem_image), not a + separate TEM-mode camera; reuse the same HAADF renderer as + acquire_scanned_image so acquire_camera_image returns a usable fake image + instead of the "unsupported" error from the base ElectronMicroscope class. + """ + return self._acquire_scanned_image(int(imsize), float(exposure_time), ["haadf"], [0.0, 0.0, 1.0, 1.0], output_format) + def _simulate_spectrum(self, detector_name: str, exposure_time: float) -> dict[str, float]: """Simulate EDS spectrum acquisition at the current beam position weighted by surrounding particles.""" self._sync_stage_from_proxy() @@ -587,7 +608,15 @@ def _acquire_spectrum(self, detector_name: str, exposure_time: float) -> str: spectrum = self._simulate_spectrum(detector_name, exposure_time) data_server = self._detector_proxies.get("data") spectrum_array = np.array(list(spectrum.values()), dtype=np.float64) - return save_acquisition(self, data_server, "spectrum", detector_name, spectrum_array, dataset_name="spectrum") + return save_acquisition( + self, + data_server, + "spectrum", + detector_name, + spectrum_array, + dataset_name="spectrum", + dataset_attrs={"elements": list(spectrum.keys())}, + ) def _place_beam(self, position) -> None: """Place the electron beam at the specified [x, y] coordinates.""" @@ -634,6 +663,35 @@ def _move_stage(self, position): self._stage_position = target self._update_view_cache(force=False) + def _get_parameters(self) -> str: + """Return all simulated status parameters as a JSON string. + + The base class exposes this through the get_parameters Tango command, + which is typed DevString — returning a dict (or the inherited abstract + stub's None) makes the command fail with a Tango translation error. + + The detector keys are deliberately split by what they mean: device_proxies + are the twin's connected settings devices (scan, stage, data, ... — not + detectors), scan_detectors is what the twin's STEM renderer actually + produces (a simulated HAADF signal, whatever label is requested), + spectrum_detectors is what acquire_spectrum accepts, and camera_detectors + are the names the CAMERA device validates writes against. An earlier + draft published the proxy keys under a "detectors" key, which told an + agent that "stage" and "data" were detectors it could scan with. + """ + self._sync_stage_from_proxy() + parameters = { + "manufacturer": self._manufacturer, + "stem_mode": bool(self._stem_mode), + "defocus_m": float(self._defocus), + "device_proxies": sorted(self._detector_proxies.keys()), + "scan_detectors": ["haadf"], + "spectrum_detectors": ["eds"], + "camera_detectors": sorted(CAMERA._CAMERA_DETECTORS), + **self._viewport_metadata(), + } + return json.dumps(parameters) + def get_viewport_metadata(self) -> str: """Return JSON-formatted metadata regarding the current simulation viewport and environment state.""" self._sync_stage_from_proxy() diff --git a/asyncroscopy/instruments/electron_microscope/electron_microscope.py b/asyncroscopy/instruments/electron_microscope/electron_microscope.py index 7294deed..e022a45c 100644 --- a/asyncroscopy/instruments/electron_microscope/electron_microscope.py +++ b/asyncroscopy/instruments/electron_microscope/electron_microscope.py @@ -81,7 +81,10 @@ def read_instrument_type(self) -> str: @abstractmethod def _connect(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _connect; " + "this vendor backend is missing the override" + ) def _disconnect(self): self._microscope = None @@ -89,11 +92,17 @@ def _disconnect(self): @abstractmethod def _connect_hardware(self) -> None: - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _connect_hardware; " + "this vendor backend is missing the override" + ) @abstractmethod def _connect_detector_proxies(self) -> None: - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _connect_detector_proxies; " + "this vendor backend is missing the override" + ) def read_stem_mode(self) -> bool: return self._stem_mode @@ -104,19 +113,40 @@ def Disconnect(self) -> None: self.set_state(DevState.OFF) self._disconnect() - @command(dtype_in=str, dtype_out=str) + @command( + dtype_in=str, + dtype_out=str, + doc_in=":param detector_name: Spectrum detector name, e.g. 'eds'.", + ) def acquire_spectrum(self, detector_name: str) -> str: """Acquire a single spectrum and return its DATA/Tiled unique id.""" detector_name = detector_name.lower().strip() proxy = self._detector_proxies.get(detector_name) + if proxy is None: + tango.Except.throw_exception( + 'UnknownDetector', + f"No spectrum detector named '{detector_name}'. " + f"Configured detector devices: {sorted(self._detector_proxies.keys())}.", + 'acquire_spectrum()', + ) return self._acquire_spectrum(detector_name, proxy.exposure_time) - @command(dtype_in=DevVarStringArray, dtype_out=str) + @command( + dtype_in=DevVarStringArray, + dtype_out=str, + doc_in=":param detector_list: Scanning detector names, e.g. ['haadf']. " + "An empty list uses ['haadf'].", + ) def acquire_scanned_image(self, detector_list: list[str] = ['haadf']) -> str: """ Acquire an image with scanning detectors and return its DATA/Tiled key. The default detector list is ['haadf']. """ + # Tango's wire protocol has no concept of "omitted", so remote callers (e.g. the + # MCP/LLM bridge) can and do send an empty list instead of relying on the Python + # default above; treat that the same as not specifying detectors. + if not detector_list: + detector_list = ['haadf'] scan = self._detector_proxies.get('scan') return self._acquire_scanned_image(scan.imsize, scan.dwell_time, detector_list, list(scan.scan_region), scan.output_format) @@ -286,7 +316,10 @@ def _acquire_scanned_image( output_format: str = '.h5', ) -> str: """Vendor-specific scanned image acquisition implementation.""" - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _acquire_scanned_image; " + "this vendor backend is missing the override" + ) def _acquire_camera_image( self, @@ -327,71 +360,122 @@ def _set_defocus(self, defocus): @abstractmethod def _get_defocus(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_defocus; " + "this vendor backend is missing the override" + ) @abstractmethod def _set_screen(self, position): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _set_screen; " + "this vendor backend is missing the override" + ) @abstractmethod def _set_screen_current(self, current): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _set_screen_current; " + "this vendor backend is missing the override" + ) @abstractmethod def _calibrate_screen_current(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _calibrate_screen_current; " + "this vendor backend is missing the override" + ) @abstractmethod def _get_screen_current(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_screen_current; " + "this vendor backend is missing the override" + ) @abstractmethod def _move_stage(self, position): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _move_stage; " + "this vendor backend is missing the override" + ) @abstractmethod def _get_stage(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_stage; " + "this vendor backend is missing the override" + ) @abstractmethod def _get_image_shift(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_image_shift; " + "this vendor backend is missing the override" + ) @abstractmethod def _get_beam_tilt(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_beam_tilt; " + "this vendor backend is missing the override" + ) @abstractmethod def _set_beam_tilt(self,tilt): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _set_beam_tilt; " + "this vendor backend is missing the override" + ) @abstractmethod def _get_diffraction_shift(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_diffraction_shift; " + "this vendor backend is missing the override" + ) @abstractmethod def _set_diffraction_shift(self, tilt): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _set_diffraction_shift; " + "this vendor backend is missing the override" + ) @abstractmethod def _get_parameters(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_parameters; " + "this vendor backend is missing the override" + ) @abstractmethod def _set_fov(self, fov): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _set_fov; " + "this vendor backend is missing the override" + ) @abstractmethod def _get_fov(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _get_fov; " + "this vendor backend is missing the override" + ) @abstractmethod def _auto_focus(self): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _auto_focus; " + "this vendor backend is missing the override" + ) @abstractmethod def _set_image_shift(self, shift): - pass + raise NotImplementedError( + f"{type(self).__name__} does not implement _set_image_shift; " + "this vendor backend is missing the override" + ) if __name__ == '__main__': diff --git a/asyncroscopy/mcp/llm.py b/asyncroscopy/mcp/llm.py index 6f44e034..ef17f1e5 100644 --- a/asyncroscopy/mcp/llm.py +++ b/asyncroscopy/mcp/llm.py @@ -22,7 +22,7 @@ try: from langchain.chat_models import init_chat_model from langchain_core.tools import BaseTool - from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage + from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage from langchain.agents import create_agent from langchain_mcp_adapters.client import MultiServerMCPClient @@ -64,7 +64,7 @@ class LLM(Device): async def init_device(self) -> None: await Device.init_device(self) self.set_state(tango.DevState.INIT) - self._max_steps = 5 + self._max_steps = 10 # Registries self._agents: list[Agent] = [] @@ -126,6 +126,15 @@ def agents(self) -> list[str]: """Return a list of the names of all currently spawned agents.""" return [agent.name for agent in self._agents] + @attribute(dtype=str) + def tools(self) -> str: + """JSON list of MCP tools inherited via ConnectMCP, e.g. [{"name": "..."}, ...]. + + Consumed by scripts/llm_bridge.py (in the sciagentgui repo) for its /health + endpoint and startup tool count. + """ + return json.dumps([{"name": t.name} for t in self._tools]) + async def ensure_ollama_running(self, host: str = "http://localhost:11434", timeout: int = 10) -> None: """Check if Ollama server is running, offloaded to prevent blocking the Tango loop.""" @@ -172,6 +181,76 @@ async def Query(self, prompt: str) -> str: finally: self.set_state(tango.DevState.ON) + @command( + dtype_in=str, + doc_in="OpenAI-style {'messages': [...], 'tools': [...]}", + dtype_out=str, + doc_out="JSON {'message': {...}} on success, or {'error': {'message': ...}} on failure", + ) + async def Complete(self, request_json: str) -> str: + """OpenAI-compatible single-step chat completion for the llm_bridge.py HTTP bridge. + + Unlike Query, this does not run the LangGraph swarm or execute any tools + itself — it converts the request into one LangChain model call and returns + the model's raw decision (tool_calls or final text) so the caller (e.g. + SciAgentGUI's own agent loop) can execute tools and drive the conversation. + """ + try: + request = json.loads(request_json) + messages = self._openai_messages_to_langchain(request.get("messages") or []) + tools = request.get("tools") or [] + model = self._model.bind_tools(tools) if tools else self._model + response = await model.ainvoke(messages) + return json.dumps({"message": self._langchain_message_to_openai(response)}) + except Exception as e: + return json.dumps({"error": {"message": str(e)}}) + + @staticmethod + def _openai_messages_to_langchain(messages: list[dict]) -> list[BaseMessage]: + """Convert OpenAI-style chat messages into LangChain message objects.""" + converted: list[BaseMessage] = [] + for message in messages: + role = message.get("role", "user") + content = message.get("content") + if role == "system": + converted.append(SystemMessage(content=content or "")) + elif role == "assistant": + tool_calls = [ + { + "name": call["function"]["name"], + "args": json.loads(call["function"].get("arguments") or "{}"), + "id": call.get("id", ""), + } + for call in (message.get("tool_calls") or []) + ] + converted.append(AIMessage(content=content or "", tool_calls=tool_calls)) + elif role == "tool": + converted.append( + ToolMessage(content=content or "", tool_call_id=message.get("tool_call_id", "")) + ) + else: + converted.append(HumanMessage(content=content or "")) + return converted + + @staticmethod + def _langchain_message_to_openai(message: BaseMessage) -> dict: + """Convert a LangChain AIMessage into an OpenAI-style assistant message dict.""" + result: dict = {"role": "assistant", "content": message.content or ""} + tool_calls = getattr(message, "tool_calls", None) or [] + if tool_calls: + result["tool_calls"] = [ + { + "id": call.get("id") or f"call_{index}", + "type": "function", + "function": { + "name": call["name"], + "arguments": json.dumps(call.get("args") or {}), + }, + } + for index, call in enumerate(tool_calls) + ] + return result + @command( dtype_in=str, doc_in="JSON config of the MCP server: {'url': '...', 'transport': '...'}", @@ -352,23 +431,18 @@ async def supervisor_node(state: AgentState): # Check if agent has contributed if there's another AI/Human message beyond the original user prompt has_delegated = len(state["messages"]) > 1 + instructions = ( + f"Below are the available agents and what each is for:\n{agent_roster}\n\n" + "Based on the conversation, decide which agent should act next to progress the user's request. " + "Only output FINISH if the user's request has been fully and concretely answered — " + "not if an agent asked a question, refused, said it lacks the ability, or otherwise failed to " + "complete the task; in that case, route to a different, more suitable agent instead." + ) + if not has_delegated: - # First turn forces subagent routing - instructions = ( - f"Below are the available agents and what each is for:\n{agent_roster}\n\n" - "Based on the conversation, decide which agent should act next to progress the user's request.\n" - "Only output FINISH if the user's request has been fully and concretely answered — " - "not if an agent asked a question or said it couldn't complete the task; in that case, " - "route to a different agent who might be able to help instead." - ) + # First turn forces subagent routing; FINISH isn't a valid choice yet. valid_options, fallback = agent_names, agent_names[0] else: - # Later turns either do normal routing or FINISH - instructions = ( - f"Active agents: {agent_names}.\n" - "Based on the conversation, decide who should act next.\n" - "If the user's request is fully resolved, output FINISH." - ) valid_options, fallback = options, "FINISH" sys_prompt = SystemMessage( diff --git a/asyncroscopy/mcp/mcp_server.py b/asyncroscopy/mcp/mcp_server.py index 19be7813..e494f44d 100644 --- a/asyncroscopy/mcp/mcp_server.py +++ b/asyncroscopy/mcp/mcp_server.py @@ -3,12 +3,20 @@ import argparse import base64 import inspect +import io import re import json +import socket import traceback from typing import Annotated, Any, Callable +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt import numpy as np +from PIL import Image as PILImage from pydantic import Field from tiled.client import from_uri @@ -24,8 +32,27 @@ ) from fastmcp import FastMCP -from fastmcp.tools import tool, Tool +from fastmcp.tools import tool, Tool, ToolResult from fastmcp.server.server import Transport +from fastmcp.utilities.types import Image as MCPImage + +from asyncroscopy.data.data_reader import describe_tiled_node + +# Tango commands that acquire and save data, returning its DATA/Tiled key as a +# plain string. Their tool wrappers additionally fetch the array back from Tiled and +# attach an inline PNG preview, so chat clients that already render MCP image content +# blocks (e.g. SciAgentGUI) display the capture without any extra tool call. +# Image commands are previewed as a grayscale rendering of the first 2D dataset; +# spectrum commands as a plot of the first 1D dataset. +IMAGE_PREVIEW_COMMANDS = {"acquire_camera_image", "acquire_scanned_image"} +SPECTRUM_PREVIEW_COMMANDS = {"acquire_spectrum"} + +# Tango's client default of 3000 ms is shorter than a real acquisition: the digital +# twin's first acquire_camera_image takes over 3 s cold, so the call died with +# API_DeviceTimedOut while the command kept running server-side. SciAgentGUI allows +# 60 s per MCP HTTP request, so 30 s lets slow commands finish while still failing +# inside the client's window with a readable Tango error rather than an HTTP timeout. +COMMAND_TIMEOUT_MILLIS = 30_000 class MCPServer: @@ -37,6 +64,7 @@ def __init__( blocked_functions: dict[str, list[str]], blocked_classes: list[str], data_device_address: str, + include_only_functions: list[str] | None = None, verbose: bool = True, ): """ @@ -48,6 +76,7 @@ def __init__( Use "*" for global blocks. blocked_classes: Tango device class names to skip entirely. data_device_address: Tango DATA device used by get_data_from_key. + include_only_functions (list[str], optional): Command names/patterns to allow exclusively. verbose (bool, optional): If True, print device discovery and tool registration progress to stdout. Defaults to True. """ @@ -58,6 +87,7 @@ def __init__( self.blocked_classes = list(blocked_classes) self._blocked_classes_normalized = {cls_name.lower() for cls_name in self.blocked_classes} self.data_device_address = data_device_address + self.include_only_functions = list(include_only_functions) if include_only_functions else [] self.verbose = verbose self.tools: dict[str, dict[str, Callable]] = {} @@ -115,53 +145,227 @@ def get_data_from_key( f"Could not resolve data key {key!r} from Tiled server {uri!r}" ) from exc - limit = max(0, int(max_values)) - suffix = key.rsplit(".", 1)[-1].lower() if "." in key else "unknown" - result: dict[str, Any] = { - "key": key, - "uri": uri, - "format": "hdf5" if suffix in {"h5", "hdf5"} else suffix, - "attrs": self._numpy_to_python(dict(getattr(node, "metadata", {}) or {})), - } - datasets: list[dict[str, Any]] = [] + return describe_tiled_node(key, uri, node, max_values=max_values) + + @staticmethod + def _find_first_2d_array(node: Any, prefer_key: str | None = None) -> np.ndarray | None: + """Recursively search a Tiled node for the first readable 2D dataset. - def visit(current: Any, name: str = "") -> None: + Acquisition writers (see asyncroscopy/data/data_writer.py) group image + datasets under an "image/" path, so ``prefer_key`` lets callers + check that group first before falling back to a full walk. + """ + + def search(current: Any) -> np.ndarray | None: read = getattr(current, "read", None) if callable(read): - shape = tuple(getattr(current, "shape", ()) or ()) - if limit == 0: - array = np.asarray([], dtype=getattr(current, "dtype", float)) - elif shape: - remaining = limit - slices = [] - for size in reversed(shape): - take = min(int(size), max(1, remaining)) - slices.append(slice(0, take)) - remaining = (remaining + take - 1) // take - array = np.asarray(read(tuple(reversed(slices)))) - else: + try: array = np.asarray(read()) - item: dict[str, Any] = { - "name": name, - "shape": list(shape or array.shape), - "dtype": str(getattr(current, "dtype", array.dtype)), - "attrs": self._numpy_to_python( - dict(getattr(current, "metadata", {}) or {}) - ), - "preview": self._numpy_to_python(array.reshape(-1)[:limit]), - } - datasets.append(item) - return + except Exception: + return None + return array if array.ndim == 2 else None keys = getattr(current, "keys", None) if callable(keys): for child_name in keys(): - child_path = f"{name}/{child_name}" if name else str(child_name) - visit(current[child_name], child_path) + try: + found = search(current[child_name]) + except Exception: + continue + if found is not None: + return found + return None + + if prefer_key is not None: + try: + found = search(node[prefer_key]) + if found is not None: + return found + except Exception: + pass + return search(node) + + @staticmethod + def _array_to_png_bytes(array: np.ndarray, max_side: int = 1024) -> bytes: + """Normalize a 2D array to 8-bit grayscale and encode it as PNG.""" + values = np.asarray(array, dtype=np.float64) + finite = values[np.isfinite(values)] + low, high = (float(finite.min()), float(finite.max())) if finite.size else (0.0, 1.0) + normalized = (values - low) / (high - low) if high > low else np.zeros_like(values) + pixels = np.clip(normalized, 0.0, 1.0) + image = PILImage.fromarray((pixels * 255).astype(np.uint8), mode="L") + if max(image.size) > max_side: + scale = max_side / max(image.size) + new_size = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) + image = image.resize(new_size, PILImage.NEAREST) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + def _resolve_tiled_node(self, key: str) -> Any: + """Resolve a DATA/Tiled key to its Tiled node via the DATA device's config.""" + data = DeviceProxy(self.data_device_address) + config = json.loads(data.get_config()) + uri = config.get("uri") + if not uri: + raise RuntimeError("the DATA device's config carries no Tiled uri") + return from_uri(uri)[key] - visit(node) - result["datasets"] = datasets - return result + def _fetch_image_preview(self, key: str) -> tuple[MCPImage | None, str | None]: + """Fetch a captured image from Tiled and render it as a PNG preview. + + Returns (preview, None) on success and (None, reason) on any failure + (unreachable Tiled server, key not yet registered, no 2D dataset found), + so the acquisition tool can still return its text key and state why no + preview accompanies it. Swallowing the reason made a Tiled outage + indistinguishable from a command that never produces images. + """ + try: + node = self._resolve_tiled_node(key) + array = self._find_first_2d_array(node, prefer_key="image") + if array is None: + return None, f"no 2D dataset was found under key {key!r} in Tiled" + return MCPImage(data=self._array_to_png_bytes(array), format="png"), None + except Exception as exc: + if self.verbose: + print(f"[image preview] could not build preview for {key!r}: {exc}") + return None, f"{type(exc).__name__}: {exc}" + + @staticmethod + def _find_first_1d_array( + node: Any, prefer_key: str | None = None + ) -> tuple[np.ndarray, dict[str, Any]] | None: + """Recursively search a Tiled node for the first readable 1D dataset. + + Returns (array, metadata) so the caller can label the plot from the + dataset's HDF5 attributes (see data_writer.save_acquisition, which + stores spectra under a "spectrum" dataset name). + """ + + def search(current: Any) -> tuple[np.ndarray, dict[str, Any]] | None: + read = getattr(current, "read", None) + if callable(read): + try: + array = np.asarray(read()) + except Exception: + return None + if array.ndim != 1 or array.size == 0: + return None + metadata = dict(getattr(current, "metadata", {}) or {}) + return array, metadata + + keys = getattr(current, "keys", None) + if callable(keys): + for child_name in keys(): + try: + found = search(current[child_name]) + except Exception: + continue + if found is not None: + return found + return None + + if prefer_key is not None: + try: + found = search(node[prefer_key]) + if found is not None: + return found + except Exception: + pass + return search(node) + + @staticmethod + def _element_labels(metadata: dict[str, Any]) -> list[str] | None: + """Extract per-channel element labels from a spectrum dataset's attrs. + + data_writer json-encodes non-scalar HDF5 attrs, so "elements" may arrive + as a JSON string or as a list. Returns None when absent or malformed, + in which case the preview falls back to an unlabeled channel axis. + """ + raw = metadata.get("elements") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return None + if isinstance(raw, (list, tuple)) and raw and all(isinstance(item, str) for item in raw): + return list(raw) + return None + + @staticmethod + def _spectrum_to_png_bytes(values: np.ndarray, labels: list[str] | None = None) -> bytes: + """Render a 1D spectrum as a PNG plot. + + With one label per value (the digital twin's per-element composition + spectra) this draws a labeled bar chart; otherwise a line plot against + channel index, which is the only axis honestly known without calibration. + """ + counts = np.asarray(values, dtype=np.float64) + figure, axes = plt.subplots(figsize=(6.0, 3.5), dpi=120) + try: + if labels is not None and len(labels) == len(counts): + positions = np.arange(len(counts)) + axes.bar(positions, counts) + axes.set_xticks(positions) + axes.set_xticklabels(labels) + axes.set_xlabel("element") + axes.set_ylabel("relative intensity") + else: + axes.plot(np.arange(counts.size), counts, linewidth=1.0) + axes.set_xlabel("channel") + axes.set_ylabel("counts") + figure.tight_layout() + buffer = io.BytesIO() + figure.savefig(buffer, format="png") + return buffer.getvalue() + finally: + plt.close(figure) + + def _fetch_spectrum_preview(self, key: str) -> tuple[MCPImage | None, str | None]: + """Fetch an acquired spectrum from Tiled and render it as a PNG plot. + + Same contract as _fetch_image_preview: (preview, None) on success, + (None, reason) on failure so the tool result states why no preview + accompanies the key. + """ + try: + node = self._resolve_tiled_node(key) + found = self._find_first_1d_array(node, prefer_key="spectrum") + if found is None: + return None, f"no 1D dataset was found under key {key!r} in Tiled" + array, metadata = found + labels = self._element_labels(metadata) + return MCPImage(data=self._spectrum_to_png_bytes(array, labels), format="png"), None + except Exception as exc: + if self.verbose: + print(f"[spectrum preview] could not build preview for {key!r}: {exc}") + return None, f"{type(exc).__name__}: {exc}" + + def _augment_with_preview(self, command_name: str, result: Any) -> Any: + """Attach an inline image preview to known acquisition commands. + + Returns a ToolResult with both a text content block (the Tiled key, + unchanged for existing callers) and an image content block, plus + structured_content matching the tool's auto-generated {"result": str} + output schema — a bare (text, Image) tuple would leave structured_content + empty and fail client-side output-schema validation. When the preview + cannot be built, a text block states the reason instead of the image, so + the failure is visible to the operator and the model rather than silent. + """ + if not isinstance(result, str) or not result: + return result + if command_name in IMAGE_PREVIEW_COMMANDS: + preview, failure = self._fetch_image_preview(result) + elif command_name in SPECTRUM_PREVIEW_COMMANDS: + preview, failure = self._fetch_spectrum_preview(result) + else: + return result + if preview is None: + return ToolResult( + content=[result, f"image preview unavailable: {failure}"], + structured_content={"result": result}, + ) + return ToolResult(content=[result, preview], structured_content={"result": result}) @staticmethod def _hdf5_attrs_to_json(attrs: Any) -> dict[str, Any]: @@ -301,7 +505,6 @@ def _create_wrapper( match = re.search(r'(?::param|@param)\s+(\w+):', in_desc) if match: param_name = match.group(1) - print(param_name) if in_desc and in_desc.lower() not in ( "uninitialised", @@ -318,7 +521,8 @@ def _create_wrapper( if in_type == CmdArgType.DevVoid: def wrapper(): result = func() - return self._normalize_command_result(out_type, result) + normalized = self._normalize_command_result(out_type, result) + return self._augment_with_preview(command_name, normalized) params = [] @@ -331,7 +535,8 @@ def wrapper(**kwargs): arg_input = kwargs result = func(arg_input) - return self._normalize_command_result(out_type, result) + normalized = self._normalize_command_result(out_type, result) + return self._augment_with_preview(command_name, normalized) # Use VAR_KEYWORD (**kwargs) to make Pydantic accept any incoming fields params = [inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD)] @@ -342,7 +547,8 @@ def wrapper(*args, **kwargs): # Get first positional arg or parameter name out of kwargs arg = args[0] if args else kwargs.get(param_name) result = func(arg) - return self._normalize_command_result(out_type, result) + normalized = self._normalize_command_result(out_type, result) + return self._augment_with_preview(command_name, normalized) params = [inspect.Parameter(param_name, inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=arg_type)] @@ -371,6 +577,7 @@ def _find_tools(self) -> dict[str, dict[str, tuple[Callable, CommandInfo]]]: continue try: dev = DeviceProxy(device_name) + dev.set_timeout_millis(COMMAND_TIMEOUT_MILLIS) info = dev.info() dev_class = info.dev_class except Exception as exc: @@ -393,6 +600,15 @@ def _find_tools(self) -> dict[str, dict[str, tuple[Callable, CommandInfo]]]: global_blocks = self.blocked_functions.get("*", []) if command_name in global_blocks or f"{dev_class}.{command_name}" in global_blocks or command_name in self.blocked_functions.get(dev_class, []): continue + + if self.include_only_functions: + allowed = ( + command_name in self.include_only_functions + or f"{dev_class}.{command_name}" in self.include_only_functions + or any(item.endswith(f".{command_name}") for item in self.include_only_functions) + ) + if not allowed: + continue try: func = getattr(dev, command_name) except Exception as exc: @@ -449,6 +665,10 @@ def setup(self, print_summary: bool = True): print(f"Failed to wrap {dev_class}.{command_name}: {e}") traceback.print_exc() + # Printed unconditionally (unlike the verbose summary below) so GUIs can + # parse the final count from stdout even when quiet mode is on. + print(f"MCP ready: {len(native_tools) + num_device_tools} tool(s) registered", flush=True) + if print_summary and self.verbose: print(f"\nRegistered {len(native_tools)} native tool(s)") print(f"Registered {num_device_tools} Tango device command tool(s)") @@ -491,20 +711,51 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--http-port", type=int, required=True) parser.add_argument("--blocked-classes-json", required=True) parser.add_argument("--blocked-functions-json", required=True) + parser.add_argument("--include-only-functions-json", default="[]") parser.add_argument("--data-device-address", required=True) parser.add_argument("--quiet", action="store_true") return parser.parse_args(argv) +def check_port_free(host: str, port: int) -> str | None: + """Return an error string if (host, port) cannot be bound, else None. + + Catches the common failure where a previous MCP server (possibly started + from a different config) is still holding the port, before setup() prints + the "MCP ready" line that GUIs parse as success. + """ + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + probe.bind((host, port)) + except OSError as exc: + return str(exc) + finally: + probe.close() + return None + + def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + if args.transport == "streamable-http": + bind_error = check_port_free(args.http_host, args.http_port) + if bind_error: + # "MCP ERROR:" is parsed by startup GUIs; keep the prefix stable. + print( + f"MCP ERROR: http://{args.http_host}:{args.http_port} is already in use - " + f"another MCP server is likely still running with a different config. " + f"Stop it or choose a different port. ({bind_error})", + flush=True, + ) + return 1 + server = MCPServer( name=args.name, tango_host=args.tango_host, tango_port=args.tango_port, blocked_classes=json.loads(args.blocked_classes_json), blocked_functions=json.loads(args.blocked_functions_json), + include_only_functions=json.loads(args.include_only_functions_json), data_device_address=args.data_device_address, verbose=not args.quiet, ) diff --git a/asyncroscopy/test_readme.md b/asyncroscopy/test_readme.md new file mode 100644 index 00000000..1756566e --- /dev/null +++ b/asyncroscopy/test_readme.md @@ -0,0 +1,282 @@ + + + + +## Current Capabilities + +### Discovery and Readiness + +1. List available microscopes. +2. List available devices. +3. List available detectors. +4. Which microscope should be used for a STEM workflow? +5. Which detectors are exposed for the current instrument? +6. Which supporting devices must be online before acquisition can start? +7. Which commands should be checked before attempting image acquisition? +8. What is the safest first step before running any acquisition workflow? +9. How would you confirm the active instrument is reachable? +10. Which device classes are currently exported in the stack? + +### STEM and HAADF Imaging + +11. Set the microscope to STEM mode and acquire a HAADF image. +12. Acquire a low-magnification HAADF overview image. +13. Acquire an atomic-resolution HAADF image. +14. Reacquire the HAADF image after changing the field of view. +15. Reacquire the HAADF image after changing focus. +16. Reacquire the HAADF image after correcting astigmatism. +17. Align the gun lens by adjusting the screen value to maximum. +18. Center the beam before acquiring the next HAADF image. +19. Optimize the HAADF image for contrast before saving it. +20. Compare two HAADF images and report the likely cause of the change. + +### Alignment and Tuning + +21. How would you decide whether the image needs focus or astigmatism correction first? +22. Which parameter would you tune first when the HAADF image looks blurred? +23. Which parameter would you tune first when the image is asymmetric? +24. How would you tell whether a bad image is caused by mode, focus, or beam placement? +25. How would you adjust the microscope if the beam is off-axis? +26. How would you handle a request to maximize signal without changing the sample position? +27. What would you do if a requested acquisition seems inconsistent with the current microscope state? +28. What should the agent ask for before making a destructive or irreversible change? +29. How would you report that tuning improved the image but did not fully solve the issue? +30. Which commands are useful for reading back current tuning values before changing them? + +### EDS Grid Acquisition + +31. Acquire EDS spectra on a 9×9 grid centered on the HAADF image at 300 pA beam current. +32. Recenter the EDS map on the HAADF image and acquire again. +33. Acquire a coarse EDS grid and explain why it is coarser than the previous one. +34. Acquire a denser EDS grid and explain the tradeoff. +35. Which detector or mode should be used for an EDS workflow? +36. What beam current would you prefer before collecting a point spectrum versus a map? +37. How would you confirm the EDS acquisition is aligned to the image center? +38. How would you summarize an EDS grid acquisition in one sentence? +39. How would you decide whether to repeat an EDS map after a failed acquisition? +40. How would you report the relationship between image contrast and EDS sampling density? + +### Device and State Reasoning + +41. Report the current microscope state. +42. Which state is the microscope in right now, and is it ready for acquisition? +43. Which detector is active for the current acquisition path? +44. Which supporting device values should be checked before an image is acquired? +45. What current scan settings matter most for a HAADF image? +46. What current detector settings matter most for an EDS workflow? +47. Which state values should be considered before moving from discovery to acquisition? +48. How would you explain the difference between a ready device and a ready microscope? +49. How would you detect that a microscope is configured for the wrong mode? +50. How would you recover from a state mismatch without assuming the hardware is wrong? + +### Data and Return Values + +51. How would you retrieve the data produced by a microscope acquisition? +52. How would you identify the saved key for an acquired image? +53. How would you describe the metadata attached to an acquired dataset? +54. How would you determine whether an acquisition returned image data or a spectrum? +55. How would you inspect a dataset preview before downloading the full result? +56. How would you report the acquisition type stored in the returned file? +57. How would you explain where the acquired data was written? +58. How would you distinguish between an image acquisition and a detector configuration query? +59. How would you confirm the returned key is valid before using it downstream? +60. How would you summarize the outputs of an acquisition for a user who only wants the result type and location? + +### Safety and Sequencing + +61. Which step should come first: discovery, state check, or acquisition? +62. Which step should come first: beam placement or detector selection? +63. Which step should come first: focus correction or EDS mapping? +64. Which step should come first: mode selection or image acquisition? +65. Which step should come first: checking detector readiness or saving the result? +66. How would you avoid repeating an acquisition with stale settings? +67. How would you decide whether a follow-up acquisition should reuse the current beam current? +68. How would you respond if a user asks for two conflicting actions in a single turn? +69. How would you separate safe planning from an action that changes microscope state? +70. How would you explain why an acquisition was deferred until the instrument was ready? + +## Near-Term Goals + +### Image Acquisition with Detector Choice + +71. Acquire an image after checking the microscope mode first. +72. Acquire an image after deciding which detector should be used. +73. List available image detectors. +74. Choose the detector for a STEM image when multiple options are available. +75. Explain why one detector is better than another for the requested image. +76. Acquire a STEM image with a detector chosen from the available image detectors. +77. Recompute the detector choice after the microscope mode changes. +78. How would you answer if the microscope mode is unknown but an image is requested? +79. How would you decide whether the requested image belongs in STEM or another mode? +80. How would you report the detector choice before running the acquisition? + +### EELS Grid Acquisition + +81. Acquire EELS on a 9×9 grid at 10 pA beam current. +82. Choose the EELS grid center from the current image context. +83. Explain why EELS might require a different beam current than EDS. +84. Describe the sequencing needed before collecting EELS on a grid. +85. What detector or spectrometer choice would you expect for EELS? +86. How would you compare the EELS plan to the EDS plan already supported today? +87. How would you report that EELS is a near-term target rather than a current capability? +88. Which microscope state details should be rechecked before EELS begins? +89. How would you handle a request for EELS if the required device is not yet exposed? +90. How would you make the EELS request fail gracefully while preserving the rest of the workflow? + +### Convergence and CBED Preparation + +91. Set the convergence angle to 10 mrad. +92. Acquire a convergent beam electron diffraction pattern with the Ceta camera. +93. Explain the detector choice for a CBED acquisition. +94. Explain how the convergence angle affects the CBED pattern. +95. How would you prepare the microscope for a diffraction-style acquisition? +96. What additional alignment would you want before CBED if the probe is unstable? +97. How would you report the expected output of a CBED acquisition? +98. How would you distinguish CBED from a standard STEM image request? +99. Which microscope settings would you want to confirm before CBED begins? +100. How would you reject a CBED request if the camera path is unavailable? + +### Screen and Beam Control + +101. Ensure the main screen is down whenever possible. +102. Explain why the main screen should stay down whenever possible. +103. Report whether the main screen is currently up or down. +104. Lower the screen before the next acquisition if it is safe to do so. +105. Keep the screen down during image acquisition unless the workflow requires otherwise. +106. Explain whether the screen state changes the valid detector choice. +107. Explain whether the screen state should be checked before a diffraction acquisition. +108. Explain whether the screen state should be checked before a spectroscopy acquisition. +109. What is the safest order for checking screen state, detector state, and microscope mode? +110. How would you explain a refusal to change the screen when the state is already correct? + +### Microscope State Reporting + +111. Report current microscope state. +112. Report current microscope mode and readiness in one sentence. +113. Report the most relevant current values before a requested acquisition. +114. Report whether the microscope is ready for imaging, spectroscopy, or diffraction. +115. Report the active detector family and why it matters. +116. Report the current state without assuming the microscope is in STEM mode. +117. Report what must change before the requested workflow can proceed. +118. Report the smallest set of state facts needed to justify the next action. +119. Report whether the next action is blocked by configuration or by missing hardware. +120. Report the state in a way that helps the user decide the next experiment step. + +### State-Aware Planning + +121. How would you plan an acquisition if the mode, detector, and current are all specified? +122. How would you plan an acquisition if only the experiment goal is specified? +123. How would you choose between image, spectrum, and diffraction acquisition from the current state? +124. How would you decide whether to reuse a previously centered region of interest? +125. How would you sequence the steps for an image that precedes an EELS map? +126. How would you sequence the steps for a CBED pattern that follows a beam alignment check? +127. How would you explain when a requested workflow is blocked by unavailable support devices? +128. How would you adapt the plan if the microscope state changed after the first check? +129. How would you handle a request that needs a detector not currently exported? +130. How would you ask for the minimum extra information needed to continue safely? + +### Graceful Fallbacks + +131. If the detector list is incomplete, how should the agent respond? +132. If the microscope mode cannot be confirmed, how should the agent respond? +133. If the acquisition type is near-term but not yet exposed, how should the agent respond? +134. If the requested beam current is outside the supported range, how should the agent respond? +135. If the requested camera is unavailable, how should the agent respond? +136. If the screen cannot be lowered automatically, how should the agent respond? +137. If a request is valid but cannot be executed safely right now, how should the agent respond? +138. If the agent needs to stop after discovery, how should it explain the limitation? +139. If a user asks for EELS and CBED in the same turn, how should the agent prioritize? +140. If a request is partially supported, how should the agent separate supported from unsupported steps? + +## Future Functionality + +### Zone-Axis Tilt + +141. Tilt to zone axis. +142. Tilt the specimen to a requested zone axis and report success. +143. Explain what information you would need before attempting a zone-axis tilt. +144. Describe how zone-axis tilt would change the downstream imaging plan. +145. Explain how you would verify that the tilt reached the intended orientation. +146. Explain how you would recover if the first tilt estimate overshoots the zone axis. +147. Explain how you would use a pre-tilt image to guide the zone-axis search. +148. Explain what state checks should precede a zone-axis tilt. +149. Explain what safety checks should precede a zone-axis tilt. +150. Explain how a zone-axis tilt request should fail if the capability is not yet available. + +### General Tilt Control + +151. Perform general tilt control. +152. Tilt by a small increment and report the new position. +153. Tilt to a target alpha value and then hold position. +154. Tilt to a target beta value and then hold position. +155. Describe how general tilt would be different from zone-axis tilt. +156. Describe how you would combine tilt control with image reacquisition. +157. Describe how you would combine tilt control with diffraction acquisition. +158. Describe how you would combine tilt control with EDS or EELS mapping. +159. Explain how you would check tilt limits before commanding motion. +160. Explain how you would recover from a tilt request that exceeds safe bounds. + +### Tilt Safety and Limits + +161. What tilt limits should be checked before motion begins? +162. What sample or holder conditions should be confirmed before tilting? +163. What image or diffraction cue would indicate that the tilt should stop early? +164. What should happen if the requested tilt conflicts with the current microscope mode? +165. What should happen if the requested tilt conflicts with the available stage hardware? +166. How should the agent describe a tilt that is possible in principle but unsafe right now? +167. How should the agent describe a tilt that is unsupported in the current build? +168. How should the agent decide whether to ask for a target orientation or a target angle first? +169. How should the agent verify that the sample is still centered after tilt? +170. How should the agent report the risk of sample drift after tilt? + +### Multi-Step Future Workflows + +171. Tilt to zone axis, then acquire a HAADF image. +172. Tilt to zone axis, then acquire a CBED pattern. +173. Tilt to zone axis, then acquire EELS on a grid. +174. Tilt to zone axis, then acquire EDS on a grid. +175. Perform a tilt sequence and then re-center the beam. +176. Perform a tilt sequence and then refine focus before acquisition. +177. Perform a tilt sequence and then recheck detector choice. +178. Perform a tilt sequence and then report the final microscope state. +179. Explain which part of a multi-step tilt workflow should be validated first. +180. Explain how a multi-step tilt workflow should fail if any intermediate step is unsupported. + +### Diffraction-Centric Workflows + +181. Acquire a diffraction pattern after tilt compensation. +182. Acquire a zone-axis diffraction pattern after general tilt adjustment. +183. Compare a CBED pattern before and after tilt. +184. Explain how diffraction contrast would change after tilting the sample. +185. Explain how a diffraction workflow should differ from a STEM imaging workflow. +186. Explain how a future diffraction workflow should report orientation metadata. +187. Explain how a future diffraction workflow should capture the final beam current and convergence angle. +188. Explain how a future diffraction workflow should note whether the main screen was down. +189. Explain how a future diffraction workflow should identify the active detector family. +190. Explain how a future diffraction workflow should summarize the acquisition chain. + +### Closed-Loop Automation + +191. Optimize tilt, focus, and detector selection automatically for the requested target. +192. Adjust microscope settings iteratively until the image quality meets a threshold. +193. Decide when to stop optimization and hand control back to the user. +194. Decide when a closed-loop acquisition should abort because the state is unstable. +195. Decide how to rank competing goals such as resolution, speed, and beam dose. +196. Decide how to report uncertainty when the best settings are only estimated. +197. Decide how to preserve provenance across a future multi-step workflow. +198. Decide how to reuse measurements from a prior image when planning the next step. +199. Decide how to compare the outcome of a current run with a prior baseline run. +200. Decide how to present a short rationale for each automatic microscope change. + +### Reporting and Missing Features + +201. Which future capability is the closest match for a user asking for atomic-column diffraction mapping? +202. Which future capability is the closest match for a user asking for automated zone-axis search? +203. Which future capability is the closest match for a user asking for general specimen tilt guidance? +204. Which future capability is the closest match for a user asking for diffraction-aware autofocus? +205. Which future capability is the closest match for a user asking for a tilt-aware EELS map? +206. How should the agent explain that a future capability is not yet available in the current build? +207. How should the agent explain the gap between a benchmark question and a supported command? +208. How should the agent preserve the rest of the workflow when one future step is unavailable? +209. How should the agent phrase an unsupported-request answer without losing the scientific context? +210. How should the agent suggest the next best supported action when a future workflow is requested today? \ No newline at end of file diff --git a/asyncroscopy/utils/process_manager.py b/asyncroscopy/utils/process_manager.py index c2521fc2..4c02b616 100644 --- a/asyncroscopy/utils/process_manager.py +++ b/asyncroscopy/utils/process_manager.py @@ -107,9 +107,12 @@ def start_process( stderr_lines=deque(maxlen=self.max_output_lines), ) self.active_processes.append(managed) - self.history.append(managed) + self.history.append(managed) self._drain(proc.stdout, managed.stdout_lines) self._drain(proc.stderr, managed.stderr_lines) + # Persisted immediately, not just on stop: if this process dies + # ungracefully (crash, force-kill), the next launch's + # _cleanup_stale_state() needs its PID on disk to find and reap it. self.save() return managed @@ -280,7 +283,15 @@ def _cleanup_stale_state(self): self._remove_state_file() def _kill_stale_pid(self, pid: int): - """Best-effort termination of orphan process IDs from a previous crash.""" + """Best-effort termination of orphan process IDs from a previous crash. + + Recorded PIDs are process-group leaders (start_process uses + start_new_session=True), so signaling the whole group is required: + a bare os.kill only reaches a wrapper like `uv run`, and once that + wrapper is SIGKILLed it has no chance to relay the signal to the + actual device-server child it spawned, leaving that child running + and still holding its Tango server-instance name. + """ if os.name == "nt": subprocess.run( ["taskkill", "/F", "/T", "/PID", str(pid)], @@ -288,10 +299,15 @@ def _kill_stale_pid(self, pid: int): ) else: try: - os.kill(pid, signal.SIGTERM) + pgid = os.getpgid(pid) except ProcessLookupError: return + try: + os.killpg(pgid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + return + # Brief poll to see if SIGTERM was honored start = time.time() while time.time() - start < 1.0: @@ -302,8 +318,8 @@ def _kill_stale_pid(self, pid: int): return try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): pass def _remove_state_file(self): @@ -325,11 +341,29 @@ def wipe_databases(self): print(f"Failed to delete {filename}: {e}") def scour_ports(self, ports: list[int]): - """Finds and kills any process squatting on critical ports.""" + """Finds and kills any process squatting on critical ports or stale device server processes.""" for port in ports: count = self.stop_processes_on_port(port) if count > 0: print(f"Cleared {count} stale process(es) on port {port}") + self.stop_stale_device_servers() + + def stop_stale_device_servers(self): + """Finds and kills orphaned Python processes running asyncroscopy device servers.""" + current_pid = os.getpid() + if os.name == "nt": + try: + cmd = ["wmic", "process", "where", "name='python.exe'", "get", "ProcessId,CommandLine"] + res = subprocess.run(cmd, capture_output=True, text=True) + for line in res.stdout.splitlines(): + if ("asyncroscopy.instruments" in line or "asyncroscopy.data" in line) and "run_servers.py" not in line: + parts = line.strip().split() + if parts and parts[-1].isdigit(): + pid = int(parts[-1]) + if pid != current_pid: + subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True) + except Exception: + pass def stop_processes_on_port(self, port: int) -> int: """Identifies and kills processes occupying a specific TCP port.""" diff --git a/configs/DigitalTwin.yaml b/configs/DigitalTwin.yaml index 54d0b3ee..a6fc5271 100644 --- a/configs/DigitalTwin.yaml +++ b/configs/DigitalTwin.yaml @@ -19,6 +19,7 @@ devices: tango: host: localhost port: 9094 + reset_database_file: true tiled: host: localhost diff --git a/configs/gemma-llm.yaml b/configs/gemma-llm.yaml index 793ce53d..13a628f7 100644 --- a/configs/gemma-llm.yaml +++ b/configs/gemma-llm.yaml @@ -4,7 +4,7 @@ tango: host: localhost port: 9094 -mcp_url: "http://127.0.0.1:8001/mcp" +mcp_url: "http://127.0.0.1:8000/mcp" local_model_path: C:\Users\Public\Desktop\Agents\Gemma4-31B-4bit startup_agents: diff --git a/configs/mcp_dt.yaml b/configs/mcp_dt.yaml index c59e72e7..db3e2013 100644 --- a/configs/mcp_dt.yaml +++ b/configs/mcp_dt.yaml @@ -8,7 +8,7 @@ # machine/IP. Set mcp.http_host to 0.0.0.0 when other machines need to connect. tango: - host: 127.0.0.1 + host: localhost port: 9094 mcp: @@ -29,10 +29,4 @@ mcp: # Optional Tango command allowlist. Leave empty to include all non-blocked # commands. Entries may be "command" or "Class.command". # list_devices and get_data_from_key are always included. - include_only_functions: - - DigitalTwinDiffraction.acquire_camera_image - - DigitalTwinDiffraction.acquire_scanned_data_advanced - - DigitalTwinDiffraction.acquire_scanned_image - - DigitalTwinDiffraction.acquire_spectrum - - DigitalTwinDiffraction.place_beam - - DigitalTwinDiffraction.place_beam_list + include_only_functions: [] diff --git a/docs/upcoming_changes.md b/docs/upcoming_changes.md index a5717922..38ff282c 100644 --- a/docs/upcoming_changes.md +++ b/docs/upcoming_changes.md @@ -3,4 +3,4 @@ - **EELS, EDS, CEOS** detector device files are present as stubs. - **Async acquisition** — deferred; architecture is designed to adopt gevent-based async later without structural changes. - **Create benchmarking for model -- **Test UNET Blob Finder and integrate ML as a device to call +- **Test UNET Blob Finder and integrate ML as a device to call** diff --git a/pyproject.toml b/pyproject.toml index 7490a285..24a2fe07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "pyyaml>=6.0.3", "pyqt5>=5.15.11,<5.16; sys_platform != 'darwin' or platform_machine != 'arm64'", "pyqt5-qt5==5.15.2; sys_platform == 'win32'", - "pyqt6>=6.8,<6.13", + "pyqt6>=6.8,<6.10", "ipympl>=0.10.0", "tifffile>=2026.5.15", "pyro5>=5.17", diff --git a/startup_guis/mcp_gui.py b/startup_guis/mcp_gui.py index f202cd72..9f3cc197 100644 --- a/startup_guis/mcp_gui.py +++ b/startup_guis/mcp_gui.py @@ -1,6 +1,7 @@ #!/usr/bin/env python from __future__ import annotations +import re import sys from pathlib import Path @@ -21,7 +22,6 @@ QComboBox, QFileDialog, QFormLayout, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, @@ -33,55 +33,89 @@ QWidget, app_exec, ) -from startup_guis.shared import BODY_FONT, CONFIG_DIR, GENERATED_CONFIG_DIR, SECTION_FONT, TEXT_FONT, TITLE_FONT, ManagedCommand, action_button, append_terminal_text, configure_terminal, load_yaml, write_yaml, yaml_text # noqa: E402 +from startup_guis.shared import BODY_FONT, CONFIG_DIR, DIGITAL_TWIN_HOST, GENERATED_CONFIG_DIR, SPECTRA300_HOST, TEXT_FONT, TITLE_FONT, CheckBox, CollapsibleSection, HostToggle, ManagedCommand, action_button, append_terminal_text, apply_theme, configure_splitter, configure_terminal, load_yaml, scrollable, section_label, set_tool_count_badge, tool_count_badge, write_yaml, yaml_text # noqa: E402 DEFAULT_CONFIG_PATH = CONFIG_DIR / 'mcp.yaml' GENERATED_CONFIG_PATH = GENERATED_CONFIG_DIR / 'mcp_gui.yaml' +# Matches the unconditional "MCP ready: N tool(s) registered" line mcp_server.py +# prints once tool discovery finishes, even in quiet mode. +TOOL_COUNT_PATTERN = re.compile(r'MCP ready: (\d+) tool') +# Matches the fatal "MCP ERROR: ..." line mcp_server.py prints when it cannot +# start (e.g. the port is already held by another server instance). +MCP_ERROR_PATTERN = re.compile(r'MCP ERROR: (.+)') + + +def parse_int_safe(val: str, default: int) -> int: + val_str = str(val).strip().rstrip('\\') + cleaned = re.sub(r'\D', '', val_str) + return int(cleaned) if cleaned else default def mcp_config_from_values(values: dict) -> dict: blocked_functions = yaml.safe_load(values['blocked_functions']) if values['blocked_functions'].strip() else {} - return { - 'tango': {'host': values['tango_host'], 'port': int(values['tango_port'])}, + include_only_raw = values.get('include_only_functions', '') + config = { + 'tango': {'host': values['tango_host'], 'port': parse_int_safe(values['tango_port'], 9094)}, 'mcp': { 'name': values['name'], 'transport': values['transport'], 'http_host': values['http_host'], - 'http_port': int(values['http_port']), + 'http_port': parse_int_safe(values['http_port'], 8000), 'data_device_address': values['data_device_address'], 'quiet': values['quiet'], 'blocked_classes': [item.strip() for item in values['blocked_classes'].split(',') if item.strip()], 'blocked_functions': blocked_functions or {}, }, } + if include_only_raw.strip(): + config['mcp']['include_only_functions'] = [item.strip() for item in include_only_raw.split(',') if item.strip()] + return config class McpGui(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle('Asyncroscopy MCP Startup') - self.resize(1080, 720) + self.resize(1280, 960) + self.setMinimumSize(880, 560) self.command = ManagedCommand(self.enqueue_output, self.process_done) + self.badge_error = False self.default_config = load_yaml(DEFAULT_CONFIG_PATH) self.inputs: dict[str, QLineEdit | QComboBox | QCheckBox] = {} self.build() + # Default to the digital twin regardless of what host the config file + # names, so a fresh launch never points at the real instrument. + self.apply_host_preset(DIGITAL_TWIN_HOST) self.refresh_yaml() def build(self) -> None: - self.setFont(BODY_FONT) + apply_theme(self) root = QSplitter(VERTICAL) top = QSplitter(HORIZONTAL) + configure_splitter(root) + configure_splitter(top) controls = QWidget() preview = QWidget() terminal = QWidget() + # The controls pane scrolls, so collapsing a section never stretches the + # remaining fields and the terminal keeps whatever height it was given. root.addWidget(top) root.addWidget(terminal) - top.addWidget(controls) + top.addWidget(scrollable(controls)) top.addWidget(preview) - root.setSizes([500, 220]) - top.setSizes([500, 580]) - self.setCentralWidget(root) + top.setStretchFactor(0, 0) + top.setStretchFactor(1, 1) + root.setStretchFactor(0, 0) + root.setStretchFactor(1, 1) + root.setSizes([400, 560]) + top.setSizes([620, 640]) + container = QWidget() + container.setObjectName('appRoot') + wrapper = QVBoxLayout(container) + wrapper.setContentsMargins(14, 14, 14, 14) + wrapper.addWidget(root) + self.setCentralWidget(container) self.build_controls(controls) self.build_preview(preview) @@ -89,18 +123,30 @@ def build(self) -> None: def build_controls(self, parent: QWidget) -> None: layout = QVBoxLayout(parent) + layout.setContentsMargins(0, 0, 12, 0) + layout.setSpacing(10) + title_row = QHBoxLayout() + title_row.setSpacing(10) title = QLabel('Asyncroscopy MCP Startup') title.setFont(TITLE_FONT) - layout.addWidget(title) + title_row.addWidget(title) + self.tool_badge = tool_count_badge() + title_row.addWidget(self.tool_badge) + title_row.addStretch() + self.host_toggle = HostToggle(self.apply_host_preset) + title_row.addWidget(self.host_toggle) + layout.addLayout(title_row) tango = self.default_config['tango'] - database = self.section('Database') + database = self.section('Database', expanded=False) self.add_row(database, 'Tango host', self.line_input('tango_host', tango.get('host', 'localhost'))) + self.inputs['tango_host'].textChanged.connect(self.sync_host_toggle) self.add_row(database, 'Tango port', self.line_input('tango_port', tango.get('port', 9094))) layout.addWidget(database) + self.sync_host_toggle() mcp = self.default_config['mcp'] - mcp_server = self.section('MCP server') + mcp_server = self.section('MCP server', expanded=False) self.add_row(mcp_server, 'Name', self.line_input('name', mcp.get('name', 'Spectra300_MCP'))) transport = QComboBox() transport.addItems(['streamable-http']) @@ -111,27 +157,29 @@ def build_controls(self, parent: QWidget) -> None: self.add_row(mcp_server, 'HTTP host', self.line_input('http_host', mcp.get('http_host', '127.0.0.1'))) self.add_row(mcp_server, 'HTTP port', self.line_input('http_port', mcp.get('http_port', 8000))) quiet = self.check_input('quiet', 'Quiet mode', bool(mcp.get('quiet', True))) - mcp_server.layout().addRow('', quiet) + mcp_server.form.addRow('', quiet) layout.addWidget(mcp_server) - data_access = self.section('Data access') + data_access = self.section('Data access', expanded=False) self.add_row(data_access, 'DATA device', self.line_input('data_device_address', mcp.get('data_device_address', 'asyncroscopy/data/default'))) layout.addWidget(data_access) - access_control = self.section('Access control') + access_control = self.section('Access control', expanded=False) self.add_row(access_control, 'Blocked classes', self.line_input('blocked_classes', ', '.join(mcp.get('blocked_classes', [])))) - blocked_label = QLabel('Blocked functions YAML') - blocked_label.setFont(BODY_FONT) - access_control.layout().addRow(blocked_label) + self.add_row(access_control, 'Include only functions', self.line_input('include_only_functions', ', '.join(mcp.get('include_only_functions', [])))) + blocked_label = section_label('Blocked functions YAML') + access_control.form.addRow(blocked_label) self.blocked_functions = QTextEdit() self.blocked_functions.setFont(TEXT_FONT) self.blocked_functions.setLineWrapMode(NO_WRAP) + self.blocked_functions.setMinimumHeight(120) self.blocked_functions.setPlainText(yaml.safe_dump(mcp.get('blocked_functions', {}), sort_keys=False)) self.blocked_functions.textChanged.connect(self.refresh_yaml) - access_control.layout().addRow(self.blocked_functions) + access_control.form.addRow(self.blocked_functions) layout.addWidget(access_control) actions = QHBoxLayout() + actions.setSpacing(8) start = action_button('Start', '#1f7a35', '#2ea043') stop = action_button('Stop', '#b42318', '#dc2626') load = QPushButton('Load config file') @@ -142,15 +190,16 @@ def build_controls(self, parent: QWidget) -> None: save.clicked.connect(self.save_config) for button in (start, stop, load, save): button.setFont(BODY_FONT) + button.setMinimumHeight(40) actions.addWidget(button) layout.addLayout(actions) layout.addStretch() def build_preview(self, parent: QWidget) -> None: layout = QVBoxLayout(parent) - label = QLabel('Configuration (.yaml)') - label.setFont(SECTION_FONT) - layout.addWidget(label) + layout.setContentsMargins(12, 0, 0, 0) + layout.setSpacing(6) + layout.addWidget(section_label('Configuration (.yaml)')) self.yaml_preview = QTextEdit() self.yaml_preview.setFont(TEXT_FONT) self.yaml_preview.setReadOnly(True) @@ -159,21 +208,18 @@ def build_preview(self, parent: QWidget) -> None: def build_terminal(self, parent: QWidget) -> None: layout = QVBoxLayout(parent) - label = QLabel('Terminal output') - label.setFont(SECTION_FONT) - layout.addWidget(label) + layout.setContentsMargins(0, 10, 0, 0) + layout.setSpacing(6) + layout.addWidget(section_label('Terminal output')) self.output = QTextEdit() configure_terminal(self.output) layout.addWidget(self.output) - def section(self, title: str) -> QGroupBox: - group = QGroupBox(title) - group.setFont(SECTION_FONT) - group.setLayout(QFormLayout()) - return group + def section(self, title: str, layout_cls=QFormLayout, expanded: bool = True) -> CollapsibleSection: + return CollapsibleSection(title, layout_cls=layout_cls, expanded=expanded) - def add_row(self, group: QGroupBox, label: str, widget: QWidget) -> None: - group.layout().addRow(label, widget) + def add_row(self, group: CollapsibleSection, label: str, widget: QWidget) -> None: + group.form.addRow(label, widget) def line_input(self, key: str, value) -> QLineEdit: widget = QLineEdit(str(value)) @@ -182,12 +228,21 @@ def line_input(self, key: str, value) -> QLineEdit: return widget def check_input(self, key: str, label: str, checked: bool) -> QCheckBox: - widget = QCheckBox(label) + widget = CheckBox(label) widget.setChecked(checked) widget.stateChanged.connect(self.refresh_yaml) self.inputs[key] = widget return widget + def apply_host_preset(self, host: str) -> None: + self.inputs['tango_host'].setText(host) + + def sync_host_toggle(self) -> None: + """Reflect whether the Tango host field currently matches a known preset.""" + host = self.inputs['tango_host'].text() + key = {DIGITAL_TWIN_HOST: 'digital_twin', SPECTRA300_HOST: 'spectra300'}.get(host) + self.host_toggle.set_active(key) + def current_config(self) -> dict: values = { 'tango_host': self.inputs['tango_host'].text(), @@ -199,6 +254,7 @@ def current_config(self) -> dict: 'data_device_address': self.inputs['data_device_address'].text(), 'quiet': self.inputs['quiet'].isChecked(), 'blocked_classes': self.inputs['blocked_classes'].text(), + 'include_only_functions': self.inputs['include_only_functions'].text() if 'include_only_functions' in self.inputs else '', 'blocked_functions': self.blocked_functions.toPlainText() if hasattr(self, 'blocked_functions') else '', } return mcp_config_from_values(values) @@ -208,8 +264,8 @@ def refresh_yaml(self) -> None: return try: self.yaml_preview.setPlainText(yaml_text(self.current_config())) - except yaml.YAMLError as exc: - self.yaml_preview.setPlainText(f'Invalid blocked_functions YAML: {exc}') + except (ValueError, yaml.YAMLError) as exc: + self.yaml_preview.setPlainText(f'Invalid configuration values or YAML: {exc}') def save_config(self) -> None: path, _ = QFileDialog.getSaveFileName(self, 'Save config', str(CONFIG_DIR / 'mcp_config.yaml'), 'YAML (*.yaml *.yml);;All files (*)') @@ -233,19 +289,36 @@ def read_config(self) -> None: self.inputs['data_device_address'].setText(mcp.get('data_device_address', 'asyncroscopy/data/default')) self.inputs['quiet'].setChecked(bool(mcp.get('quiet', True))) self.inputs['blocked_classes'].setText(', '.join(mcp.get('blocked_classes', []))) + if 'include_only_functions' in self.inputs: + self.inputs['include_only_functions'].setText(', '.join(mcp.get('include_only_functions', []))) self.blocked_functions.setPlainText(yaml.safe_dump(mcp.get('blocked_functions', {}), sort_keys=False)) self.refresh_yaml() self.enqueue_output(f'Loaded {path}\n') def start(self) -> None: + self.badge_error = False + set_tool_count_badge(self.tool_badge, None) config_path = write_yaml(GENERATED_CONFIG_PATH, self.current_config()) self.command.start(['uv', 'run', 'python', '-u', 'startup_scripts/run_mcp.py', '--yaml', str(config_path)]) def enqueue_output(self, text: str) -> None: append_terminal_text(self.output, text) + error_match = MCP_ERROR_PATTERN.search(text) + if error_match: + self.badge_error = True + message = error_match.group(1) + set_tool_count_badge(self.tool_badge, None, error='port in use' if 'in use' in message else 'server error') + return + match = TOOL_COUNT_PATTERN.search(text) + if match: + set_tool_count_badge(self.tool_badge, int(match.group(1))) def process_done(self, returncode: int | None) -> None: self.enqueue_output(f'\nProcess exited with return code {returncode}.\n') + # A dead server serves no tools; drop any stale count but keep an error + # badge visible so the failure reason is not lost. + if not self.badge_error: + set_tool_count_badge(self.tool_badge, None) if __name__ == '__main__': diff --git a/startup_guis/qt_compat.py b/startup_guis/qt_compat.py index b31e2d42..e3703b03 100644 --- a/startup_guis/qt_compat.py +++ b/startup_guis/qt_compat.py @@ -9,10 +9,14 @@ try: if QT_API_ENV == 'pyqt5': raise ImportError('PyQt5 requested by ASYNCROSCOPY_QT_API') - from PyQt6.QtCore import QObject as QObject, Qt as Qt, pyqtSignal as pyqtSignal + from PyQt6.QtCore import QObject as QObject, QPointF as QPointF, Qt as Qt, pyqtSignal as pyqtSignal from PyQt6.QtGui import ( QColor as QColor, QFont as QFont, + QPainter as QPainter, + QPainterPath as QPainterPath, + QPalette as QPalette, + QPen as QPen, QTextCharFormat as QTextCharFormat, QTextCursor as QTextCursor, ) @@ -22,6 +26,7 @@ QComboBox as QComboBox, QFileDialog as QFileDialog, QFormLayout as QFormLayout, + QFrame as QFrame, QGridLayout as QGridLayout, QGroupBox as QGroupBox, QHBoxLayout as QHBoxLayout, @@ -29,7 +34,11 @@ QLineEdit as QLineEdit, QMainWindow as QMainWindow, QPushButton as QPushButton, + QScrollArea as QScrollArea, + QSizePolicy as QSizePolicy, QSplitter as QSplitter, + QStyle as QStyle, + QStyleOptionButton as QStyleOptionButton, QTextEdit as QTextEdit, QVBoxLayout as QVBoxLayout, QWidget as QWidget, @@ -42,15 +51,33 @@ MOVE_END = QTextCursor.MoveOperation.End NO_WRAP = QTextEdit.LineWrapMode.NoWrap FONT_BOLD = QFont.Weight.Bold + FONT_MEDIUM = QFont.Weight.Medium + MONOSPACE_HINT = QFont.StyleHint.Monospace + NO_FRAME = QFrame.Shape.NoFrame + SCROLLBAR_AS_NEEDED = Qt.ScrollBarPolicy.ScrollBarAsNeeded + SCROLLBAR_OFF = Qt.ScrollBarPolicy.ScrollBarAlwaysOff + FIELDS_STAY_AT_SIZE_HINT = QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow + SE_CHECKBOX_INDICATOR = QStyle.SubElement.SE_CheckBoxIndicator + PEN_CAP_ROUND = Qt.PenCapStyle.RoundCap + PEN_JOIN_ROUND = Qt.PenJoinStyle.RoundJoin + RENDER_HINT_ANTIALIASING = QPainter.RenderHint.Antialiasing + PALETTE_WINDOW_ROLE = QPalette.ColorRole.Window def app_exec(app: QApplication) -> int: return app.exec() + def window_palette_color(app: QApplication) -> QColor: + return app.palette().color(PALETTE_WINDOW_ROLE) + except ImportError: - from PyQt5.QtCore import QObject as QObject, Qt as Qt, pyqtSignal as pyqtSignal + from PyQt5.QtCore import QObject as QObject, QPointF as QPointF, Qt as Qt, pyqtSignal as pyqtSignal from PyQt5.QtGui import ( QColor as QColor, QFont as QFont, + QPainter as QPainter, + QPainterPath as QPainterPath, + QPalette as QPalette, + QPen as QPen, QTextCharFormat as QTextCharFormat, QTextCursor as QTextCursor, ) @@ -60,6 +87,7 @@ def app_exec(app: QApplication) -> int: QComboBox as QComboBox, QFileDialog as QFileDialog, QFormLayout as QFormLayout, + QFrame as QFrame, QGridLayout as QGridLayout, QGroupBox as QGroupBox, QHBoxLayout as QHBoxLayout, @@ -67,7 +95,11 @@ def app_exec(app: QApplication) -> int: QLineEdit as QLineEdit, QMainWindow as QMainWindow, QPushButton as QPushButton, + QScrollArea as QScrollArea, + QSizePolicy as QSizePolicy, QSplitter as QSplitter, + QStyle as QStyle, + QStyleOptionButton as QStyleOptionButton, QTextEdit as QTextEdit, QVBoxLayout as QVBoxLayout, QWidget as QWidget, @@ -80,6 +112,20 @@ def app_exec(app: QApplication) -> int: MOVE_END = QTextCursor.End NO_WRAP = QTextEdit.NoWrap FONT_BOLD = QFont.Bold + FONT_MEDIUM = QFont.Medium + MONOSPACE_HINT = QFont.Monospace + NO_FRAME = QFrame.NoFrame + SCROLLBAR_AS_NEEDED = Qt.ScrollBarAsNeeded + SCROLLBAR_OFF = Qt.ScrollBarAlwaysOff + FIELDS_STAY_AT_SIZE_HINT = QFormLayout.AllNonFixedFieldsGrow + SE_CHECKBOX_INDICATOR = QStyle.SE_CheckBoxIndicator + PEN_CAP_ROUND = Qt.RoundCap + PEN_JOIN_ROUND = Qt.RoundJoin + RENDER_HINT_ANTIALIASING = QPainter.Antialiasing + PALETTE_WINDOW_ROLE = QPalette.Window def app_exec(app: QApplication) -> int: return app.exec_() + + def window_palette_color(app: QApplication) -> QColor: + return app.palette().color(PALETTE_WINDOW_ROLE) diff --git a/startup_guis/server_gui.py b/startup_guis/server_gui.py index 65d886fd..e2817dda 100644 --- a/startup_guis/server_gui.py +++ b/startup_guis/server_gui.py @@ -11,30 +11,30 @@ # Import Qt through qt_compat so this GUI can use PyQt6 normally and PyQt5 on # legacy Windows 10 systems that cannot load Qt6. -from startup_guis.qt_compat import ( # noqa: E402 - VERTICAL, - QApplication, - QCheckBox, - QComboBox, - QFileDialog, - QFormLayout, - QGridLayout, - QGroupBox, - QHBoxLayout, - QLabel, - QLineEdit, - QMainWindow, - QPushButton, - QSplitter, - QTextEdit, - QVBoxLayout, - QWidget, - app_exec, -) -from startup_guis.shared import BODY_FONT, CONFIG_DIR, GENERATED_CONFIG_DIR, SECTION_FONT, TITLE_FONT, ManagedCommand, action_button, append_terminal_text, configure_terminal, load_yaml, write_yaml # noqa: E402 - - -DEFAULT_CONFIG_PATH = CONFIG_DIR / 'Spectra300.yaml' +from startup_guis.qt_compat import ( # noqa: E402 + POINTING_HAND_CURSOR, + VERTICAL, + QApplication, + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QPushButton, + QSplitter, + QTextEdit, + QVBoxLayout, + QWidget, + app_exec, +) +from startup_guis.shared import BODY_FONT, CONFIG_DIR, GENERATED_CONFIG_DIR, TITLE_FONT, CheckBox, CollapsibleSection, ManagedCommand, action_button, append_terminal_text, apply_theme, configure_splitter, configure_terminal, load_yaml, scrollable, section_label, write_yaml # noqa: E402 + + +DEFAULT_CONFIG_PATH = CONFIG_DIR / 'DigitalTwin.yaml' GENERATED_CONFIG_PATH = GENERATED_CONFIG_DIR / 'server_gui.yaml' DEVICE_MODULES = { 'aperture': 'asyncroscopy.instruments.electron_microscope.hardware.aperture_autoscript', @@ -120,8 +120,9 @@ def server_config_from_values(values: dict) -> dict: class ServerGui(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle('Asyncroscopy Server Startup') - self.resize(800, 600) + self.setWindowTitle('Asyncroscopy Server Startup') + self.resize(1020, 960) + self.setMinimumSize(720, 560) self.command = ManagedCommand(self.enqueue_output, self.process_done) self.default_config = load_yaml(DEFAULT_CONFIG_PATH) self.device_config = self.default_config.get('devices', {}) @@ -129,129 +130,119 @@ def __init__(self): self.device_checks: dict[str, QCheckBox] = {} self.build() - def build(self) -> None: - self.setFont(BODY_FONT) - root = QSplitter(VERTICAL) - controls = QWidget() - terminal = QWidget() - root.addWidget(controls) - root.addWidget(terminal) - root.setSizes([400, 200]) - self.setCentralWidget(root) - - self.build_controls(controls) - self.build_terminal(terminal) - - def build_controls(self, parent: QWidget) -> None: - layout = QVBoxLayout(parent) - title_layout = QHBoxLayout() - title = QLabel('Asyncroscopy Server Startup') - title.setFont(TITLE_FONT) - title_layout.addWidget(title) - title_layout.addStretch() - self.config_combo = QComboBox() - title_layout.addWidget(self.config_combo) - layout.addLayout(title_layout) - - database = self.section('Database') - self.add_row(database, 'Tango host', self.line_input('tango_host', self.default_config['tango'].get('host', 'localhost'))) - self.add_row(database, 'Tango port', self.line_input('tango_port', self.default_config['tango'].get('port', 9094))) - reset_database = self.check_input('reset_database_file', 'Delete tango_database.db before start', bool(self.default_config['tango'].get('reset_database_file', False))) - database.layout().addRow('', reset_database) - layout.addWidget(database) - - instrument = self.section('Instrument') - default_instrument = self.default_config['instrument'] - self.add_row(instrument, 'Instrument file', self.path_input('instrument_file', default_instrument.get('file', INSTRUMENT_FILES[0]), files=INSTRUMENT_FILES)) - self.add_row(instrument, 'Hardware host', self.line_input('hardware_host', default_instrument.get('hardware_host', ''))) - self.add_row(instrument, 'Hardware port', self.line_input('hardware_port', default_instrument.get('hardware_port', 9095))) - self.add_row(instrument, 'Timeout (seconds)', self.line_input('hardware_timeout_seconds', default_instrument.get('timeout_seconds', 120))) - self.add_row(instrument, 'Device startup timeout', self.line_input('device_timeout_seconds', self.default_config.get('device_timeout_seconds', 120))) - layout.addWidget(instrument) - - tiled = self.default_config['tiled'] - - data_server_container = QWidget() - data_server_layout = QVBoxLayout(data_server_container) - data_server_layout.setContentsMargins(0, 0, 0, 0) - - data_server_header = QPushButton('▼ Data server') - data_server_header.setFlat(True) - data_server_header.setFont(SECTION_FONT) - data_server_layout.addWidget(data_server_header) - - data_server = self.section('') - data_server.setStyleSheet("QGroupBox { border: none; padding-top: 0px; margin-top: 0px; }") - self.add_row(data_server, 'Tiled host', self.line_input('tiled_host', tiled.get('host', 'localhost'))) - self.add_row(data_server, 'Tiled port', self.line_input('tiled_port', tiled.get('port', 9091))) - self.add_row(data_server, 'Acquisition dir', self.path_input('acquisition_dir', tiled.get('acquisition_dir', 'outputs/tiled_acquisitions'), directory=True)) - autostart = self.check_input('tiled_autostart', 'Start Tiled HTTP server', bool(tiled.get('autostart', True))) - data_server.layout().addRow('', autostart) - register_on_startup = self.check_input( - 'tiled_register_on_startup', - 'Register acquisition directory on startup (slow for large folders)', - bool(tiled.get('register_on_startup', False)), - ) - data_server.layout().addRow('', register_on_startup) - data_server.setHidden(True) - data_server_layout.addWidget(data_server) - - def toggle_data_server(): - is_hidden = data_server.isHidden() - data_server.setHidden(not is_hidden) - data_server_header.setText(('▼ ' if is_hidden else '► ') + 'Data server') - - data_server_header.clicked.connect(toggle_data_server) - layout.addWidget(data_server_container) - - devices = QGroupBox('Devices') - devices.setFont(SECTION_FONT) - device_grid = QGridLayout(devices) - for index, key in enumerate(DEVICE_MODULES): - checkbox = QCheckBox(key) - checkbox.setChecked(key in self.device_config) - checkbox.stateChanged.connect(self.refresh_yaml) - self.device_checks[key] = checkbox - device_grid.addWidget(checkbox, index // 3, index % 3) - layout.addWidget(devices) - - actions = QHBoxLayout() - start = action_button('Start', '#1f7a35', '#2ea043') - stop = action_button('Stop', '#b42318', '#dc2626') - load = QPushButton('Load config file') - save = QPushButton('Save current config') - start.clicked.connect(self.start) - stop.clicked.connect(self.command.stop) - load.clicked.connect(self.read_config) - save.clicked.connect(self.save_config) - for button in (start, stop, load, save): - button.setFont(BODY_FONT) - actions.addWidget(button) - layout.addLayout(actions) - layout.addStretch() - - config_files = sorted([p.name for p in CONFIG_DIR.glob('*.yaml')] + [p.name for p in CONFIG_DIR.glob('*.yml')]) - self.config_combo.addItems(config_files) - self.config_combo.setCurrentText(DEFAULT_CONFIG_PATH.name) - self.config_combo.currentTextChanged.connect(self.config_changed) - - def build_terminal(self, parent: QWidget) -> None: - layout = QVBoxLayout(parent) - label = QLabel('Terminal output') - label.setFont(SECTION_FONT) - layout.addWidget(label) - self.output = QTextEdit() - configure_terminal(self.output) - layout.addWidget(self.output) - - def section(self, title: str) -> QGroupBox: - group = QGroupBox(title) - group.setFont(SECTION_FONT) - group.setLayout(QFormLayout()) - return group - - def add_row(self, group: QGroupBox, label: str, widget: QWidget) -> None: - group.layout().addRow(label, widget) + def build(self) -> None: + apply_theme(self) + root = QSplitter(VERTICAL) + configure_splitter(root) + controls = QWidget() + terminal = QWidget() + # The controls pane scrolls, so collapsing a section never stretches the + # remaining fields and the terminal keeps whatever height it was given. + root.addWidget(scrollable(controls)) + root.addWidget(terminal) + root.setStretchFactor(0, 0) + root.setStretchFactor(1, 1) + root.setSizes([380, 580]) + container = QWidget() + container.setObjectName('appRoot') + wrapper = QVBoxLayout(container) + wrapper.setContentsMargins(14, 14, 14, 14) + wrapper.addWidget(root) + self.setCentralWidget(container) + + self.build_controls(controls) + self.build_terminal(terminal) + + def build_controls(self, parent: QWidget) -> None: + layout = QVBoxLayout(parent) + layout.setContentsMargins(0, 0, 12, 0) + layout.setSpacing(10) + title_layout = QHBoxLayout() + title = QLabel('Asyncroscopy Server Startup') + title.setFont(TITLE_FONT) + title_layout.addWidget(title) + title_layout.addStretch() + self.config_combo = QComboBox() + self.config_combo.setMinimumWidth(220) + title_layout.addWidget(self.config_combo) + layout.addLayout(title_layout) + + database = self.section('Database', expanded=False) + self.add_row(database, 'Tango host', self.line_input('tango_host', self.default_config['tango'].get('host', 'localhost'))) + self.add_row(database, 'Tango port', self.line_input('tango_port', self.default_config['tango'].get('port', 9094))) + reset_database = self.check_input('reset_database_file', 'Delete tango_database.db before start', bool(self.default_config['tango'].get('reset_database_file', False))) + database.form.addRow('', reset_database) + layout.addWidget(database) + + instrument = self.section('Instrument', expanded=False) + default_instrument = self.default_config['instrument'] + self.add_row(instrument, 'Hardware host', self.line_input('hardware_host', default_instrument.get('hardware_host', ''))) + self.add_row(instrument, 'Hardware port', self.line_input('hardware_port', default_instrument.get('hardware_port', 9095))) + self.add_row(instrument, 'Timeout (seconds)', self.line_input('hardware_timeout_seconds', default_instrument.get('timeout_seconds', 120))) + self.add_row(instrument, 'Device startup timeout', self.line_input('device_timeout_seconds', self.default_config.get('device_timeout_seconds', 120))) + layout.addWidget(instrument) + + tiled = self.default_config['tiled'] + + data_server = self.section('Data server', expanded=False) + self.add_row(data_server, 'Tiled host', self.line_input('tiled_host', tiled.get('host', 'localhost'))) + self.add_row(data_server, 'Tiled port', self.line_input('tiled_port', tiled.get('port', 9091))) + self.add_row(data_server, 'Acquisition dir', self.path_input('acquisition_dir', tiled.get('acquisition_dir', 'outputs/tiled_acquisitions'), directory=True)) + autostart = self.check_input('tiled_autostart', 'Start Tiled HTTP server', bool(tiled.get('autostart', True))) + data_server.form.addRow('', autostart) + register_on_startup = self.check_input( + 'tiled_register_on_startup', + 'Register acquisition directory on startup (slow for large folders)', + bool(tiled.get('register_on_startup', False)), + ) + data_server.form.addRow('', register_on_startup) + layout.addWidget(data_server) + + devices = self.section('Devices', layout_cls=QGridLayout, expanded=False) + for index, key in enumerate(DEVICE_MODULES): + checkbox = CheckBox(key) + checkbox.setChecked(key in self.device_config) + checkbox.stateChanged.connect(self.refresh_yaml) + self.device_checks[key] = checkbox + devices.form.addWidget(checkbox, index // 3, index % 3) + layout.addWidget(devices) + + actions = QHBoxLayout() + actions.setSpacing(8) + start = action_button('Start', '#1f7a35', '#2ea043') + stop = action_button('Stop', '#b42318', '#dc2626') + load = QPushButton('Load config file') + save = QPushButton('Save current config') + start.clicked.connect(self.start) + stop.clicked.connect(self.command.stop) + load.clicked.connect(self.read_config) + save.clicked.connect(self.save_config) + for button in (start, stop, load, save): + button.setFont(BODY_FONT) + button.setMinimumHeight(40) + actions.addWidget(button) + layout.addLayout(actions) + layout.addStretch() + + config_files = sorted([p.name for p in CONFIG_DIR.glob('*.yaml')] + [p.name for p in CONFIG_DIR.glob('*.yml')]) + self.config_combo.addItems(config_files) + self.config_combo.setCurrentText(DEFAULT_CONFIG_PATH.name) + self.config_combo.currentTextChanged.connect(self.config_changed) + + def build_terminal(self, parent: QWidget) -> None: + layout = QVBoxLayout(parent) + layout.setContentsMargins(0, 10, 0, 0) + layout.setSpacing(6) + layout.addWidget(section_label('Terminal output')) + self.output = QTextEdit() + configure_terminal(self.output) + layout.addWidget(self.output) + + def section(self, title: str, layout_cls=QFormLayout, expanded: bool = True) -> CollapsibleSection: + return CollapsibleSection(title, layout_cls=layout_cls, expanded=expanded) + + def add_row(self, group: CollapsibleSection, label: str, widget: QWidget) -> None: + group.form.addRow(label, widget) def line_input(self, key: str, value) -> QLineEdit: widget = QLineEdit(str(value)) @@ -259,26 +250,28 @@ def line_input(self, key: str, value) -> QLineEdit: self.inputs[key] = widget return widget - def check_input(self, key: str, label: str, checked: bool) -> QCheckBox: - widget = QCheckBox(label) - widget.setChecked(checked) - widget.stateChanged.connect(self.refresh_yaml) - self.inputs[key] = widget - return widget + def check_input(self, key: str, label: str, checked: bool) -> QCheckBox: + widget = CheckBox(label) + widget.setChecked(checked) + widget.stateChanged.connect(self.refresh_yaml) + self.inputs[key] = widget + return widget def path_input(self, key: str, value, files: list[str] | None = None, directory: bool = False) -> QWidget: - row = QWidget() - layout = QHBoxLayout(row) - layout.setContentsMargins(0, 0, 0, 0) - combo = QComboBox() - combo.setEditable(True) - combo.addItems(files or [str(value)]) - combo.setCurrentText(project_path_text(value)) - combo.currentTextChanged.connect(self.refresh_yaml) - browse = QPushButton('Browse') - browse.clicked.connect(lambda: self.browse_path(combo, directory)) - layout.addWidget(combo) - layout.addWidget(browse) + row = QWidget() + layout = QHBoxLayout(row) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + combo = QComboBox() + combo.setEditable(True) + combo.addItems(files or [str(value)]) + combo.setCurrentText(project_path_text(value)) + combo.currentTextChanged.connect(self.refresh_yaml) + browse = QPushButton('Browse') + browse.setCursor(POINTING_HAND_CURSOR) + browse.clicked.connect(lambda: self.browse_path(combo, directory)) + layout.addWidget(combo, 1) + layout.addWidget(browse, 0) self.inputs[key] = combo return row @@ -304,10 +297,10 @@ def set_input_text(self, key: str, value) -> None: return widget.setText(text) - def current_config(self) -> dict: - values = { - 'instrument_file': self.input_text('instrument_file'), - 'hardware_host': self.input_text('hardware_host'), + def current_config(self) -> dict: + values = { + 'instrument_file': project_path_text(self.default_config['instrument'].get('file', INSTRUMENT_FILES[0])), + 'hardware_host': self.input_text('hardware_host'), 'hardware_port': self.input_text('hardware_port'), 'hardware_timeout_seconds': self.input_text('hardware_timeout_seconds'), 'tango_host': self.input_text('tango_host'), @@ -342,11 +335,10 @@ def load_config_from_path(self, path: Path | str) -> None: config = load_yaml(Path(path)) self.default_config = config self.device_config = config.get('devices', {}) - instrument = config.get('instrument', {}) - tango = config.get('tango', {}) - tiled = config.get('tiled', {}) - self.set_input_text('instrument_file', instrument.get('file', INSTRUMENT_FILES[0])) - self.set_input_text('hardware_host', instrument.get('hardware_host', '')) + instrument = config.get('instrument', {}) + tango = config.get('tango', {}) + tiled = config.get('tiled', {}) + self.set_input_text('hardware_host', instrument.get('hardware_host', '')) self.set_input_text('hardware_port', instrument.get('hardware_port', '')) self.set_input_text('hardware_timeout_seconds', instrument.get('timeout_seconds', 120)) self.set_input_text('tango_host', tango.get('host', 'localhost')) diff --git a/startup_guis/shared.py b/startup_guis/shared.py index 1fcb1343..b6b34300 100644 --- a/startup_guis/shared.py +++ b/startup_guis/shared.py @@ -4,6 +4,7 @@ import re import signal import subprocess +import sys import threading from pathlib import Path from typing import Callable @@ -11,27 +12,139 @@ import yaml from startup_guis.qt_compat import ( + FIELDS_STAY_AT_SIZE_HINT, FONT_BOLD, + FONT_MEDIUM, + MONOSPACE_HINT, MOVE_END, + NO_FRAME, + PEN_CAP_ROUND, + PEN_JOIN_ROUND, POINTING_HAND_CURSOR, + RENDER_HINT_ANTIALIASING, + SCROLLBAR_AS_NEEDED, + SE_CHECKBOX_INDICATOR, + QApplication, + QCheckBox, QColor, QFont, + QFormLayout, + QHBoxLayout, + QLabel, QObject, + QPainter, + QPainterPath, + QPen, + QPointF, QPushButton, + QScrollArea, + QSplitter, + QStyleOptionButton, QTextCharFormat, QTextEdit, + QVBoxLayout, + QWidget, pyqtSignal, + window_palette_color, ) PROJECT_DIR = Path(__file__).resolve().parents[1] CONFIG_DIR = PROJECT_DIR / 'configs' GENERATED_CONFIG_DIR = PROJECT_DIR / 'outputs' / 'startup_configs' -BODY_FONT = QFont('Arial', 15) -TITLE_FONT = QFont('Arial', 24, FONT_BOLD) -SECTION_FONT = QFont('Arial', 18, FONT_BOLD) -TEXT_FONT = QFont('Menlo', 16) -ACTION_FONT = QFont('Arial', 18, FONT_BOLD) + +# Two palettes shared by every startup GUI, so the windows read as one app and +# follow whichever appearance (light/dark) the OS is currently set to. +DARK_COLORS = { + 'window': '#0f1319', + 'panel': '#161b22', + 'panel_hover': '#1c232c', + 'field': '#0d1117', + 'border': '#2b3440', + 'border_strong': '#3d4854', + 'text': '#e6edf3', + 'text_dim': '#8b98a5', + 'accent': '#58a6ff', + 'danger': '#f85149', + 'check_mark': '#0b0f14', + 'terminal_bg': '#0b0f14', + 'terminal_text': '#c9d1d9', +} +LIGHT_COLORS = { + 'window': '#f5f6f8', + 'panel': '#ffffff', + 'panel_hover': '#eef0f3', + 'field': '#ffffff', + 'border': '#d0d7de', + 'border_strong': '#aeb7c2', + 'text': '#1f2328', + 'text_dim': '#59636e', + 'accent': '#0969da', + 'danger': '#b42318', + 'check_mark': '#ffffff', + # The terminal keeps its console-style dark background in both themes, so + # output stays readable regardless of the surrounding chrome. + 'terminal_bg': '#0b0f14', + 'terminal_text': '#c9d1d9', +} +# Mutated in place by apply_theme() once the system appearance is known, so +# every helper below (which reads COLORS at call time) picks up the right set. +COLORS = dict(DARK_COLORS) + + +def system_is_dark() -> bool: + """Best-effort detection of the OS light/dark appearance setting.""" + app = QApplication.instance() + if app is None: + return True + return window_palette_color(app).lightness() < 128 + + +def _mono_family() -> str: + if sys.platform == 'darwin': + return 'Menlo' + if os.name == 'nt': + return 'Consolas' + return 'DejaVu Sans Mono' + + +def _ui_font(size: int, weight=None) -> QFont: + # An empty family string leaves Qt on the platform's own UI font (SF on + # macOS, Segoe UI on Windows), which is what makes the window look native. + font = QFont() + font.setPointSize(size) + if weight is not None: + font.setWeight(weight) + return font + + +def _font(name: str) -> QFont: + # Fonts are built lazily (rather than as module-level constants) because + # QFont requires a QApplication to already exist; callers only need them + # once the app is up. + if name == 'BODY_FONT': + return _ui_font(14) + if name == 'TITLE_FONT': + return _ui_font(21, FONT_BOLD) + if name == 'SECTION_FONT': + return _ui_font(15, FONT_MEDIUM) + if name == 'LABEL_FONT': + return _ui_font(12, FONT_MEDIUM) + if name == 'TEXT_FONT': + font = QFont(_mono_family()) + font.setPointSize(13) + font.setStyleHint(MONOSPACE_HINT) + return font + if name == 'ACTION_FONT': + return _ui_font(15, FONT_BOLD) + raise AttributeError(name) + + +def __getattr__(name: str) -> QFont: + try: + return _font(name) + except AttributeError: + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") from None OutputCallback = Callable[[str], None] DoneCallback = Callable[[int | None], None] @@ -52,25 +165,395 @@ def write_yaml(path: Path, config: dict) -> Path: return path +def app_stylesheet() -> str: + """The dark, flat style shared by the startup windows.""" + c = COLORS + return f''' + QMainWindow, QDialog, QWidget#appRoot {{ + background: {c['window']}; + }} + /* Panes stay transparent so only the window and the section cards paint a + background; otherwise nested containers punch dark holes in the cards. */ + QWidget {{ + background: transparent; + color: {c['text']}; + }} + QLabel[role="heading"] {{ + color: {c['text']}; + }} + QLabel[role="caption"] {{ + color: {c['text_dim']}; + }} + QLineEdit, QComboBox, QTextEdit, QAbstractSpinBox {{ + background: {c['field']}; + color: {c['text']}; + border: 1px solid {c['border']}; + border-radius: 6px; + padding: 6px 8px; + selection-background-color: {c['accent']}; + selection-color: #0b0f14; + }} + QLineEdit:hover, QComboBox:hover {{ + border-color: {c['border_strong']}; + }} + QLineEdit:focus, QComboBox:focus, QTextEdit:focus {{ + border-color: {c['accent']}; + }} + /* The drop-down button is left native: overriding it costs the arrow, and a + combo without an arrow is indistinguishable from a line edit. */ + QComboBox QAbstractItemView {{ + background: {c['panel']}; + color: {c['text']}; + border: 1px solid {c['border']}; + selection-background-color: {c['accent']}; + selection-color: #0b0f14; + outline: none; + }} + QPushButton {{ + background: {c['panel']}; + color: {c['text']}; + border: 1px solid {c['border']}; + border-radius: 6px; + padding: 7px 14px; + }} + QPushButton:hover {{ + background: {c['panel_hover']}; + border-color: {c['border_strong']}; + }} + QPushButton:pressed {{ + background: {c['field']}; + }} + QCheckBox {{ + background: transparent; + spacing: 8px; + }} + /* Qt ships no checkmark image we can reference from a stylesheet, so + checked state is a solid accent fill; the unchecked border is kept bright + enough to stay visible against the dark card. */ + QCheckBox::indicator {{ + width: 15px; + height: 15px; + border: 1px solid {c['text_dim']}; + border-radius: 4px; + background: {c['field']}; + }} + QCheckBox::indicator:hover {{ + border-color: {c['accent']}; + }} + QCheckBox::indicator:checked {{ + background: {c['accent']}; + border-color: {c['accent']}; + }} + /* The checkmark glyph itself is painted by CheckBox.paintEvent (Qt style + sheets can't reference an inline image), so the indicator only supplies + the filled square here. */ + QScrollArea {{ + border: none; + }} + QScrollBar:vertical {{ + background: transparent; + width: 10px; + margin: 0px; + }} + QScrollBar::handle:vertical {{ + background: {c['border_strong']}; + border-radius: 5px; + min-height: 32px; + }} + QScrollBar::handle:vertical:hover {{ + background: {c['text_dim']}; + }} + QScrollBar:horizontal {{ + background: transparent; + height: 10px; + margin: 0px; + }} + QScrollBar::handle:horizontal {{ + background: {c['border_strong']}; + border-radius: 5px; + min-width: 32px; + }} + QScrollBar::handle:horizontal:hover {{ + background: {c['text_dim']}; + }} + QScrollBar::add-line, QScrollBar::sub-line {{ + width: 0px; + height: 0px; + }} + QScrollBar::add-page, QScrollBar::sub-page {{ + background: transparent; + }} + ''' + + +def apply_theme(window: QWidget) -> None: + """Apply the shared palette and base font to a top-level window. + + The palette is picked from the OS appearance (light/dark) at call time, + so every startup GUI automatically matches the system theme. + """ + COLORS.clear() + COLORS.update(DARK_COLORS if system_is_dark() else LIGHT_COLORS) + window.setFont(_font('BODY_FONT')) + window.setStyleSheet(app_stylesheet()) + + +class CheckBox(QCheckBox): + """A QCheckBox that paints a checkmark glyph over the filled indicator. + + Qt's stylesheet engine can't reference an inline image for + QCheckBox::indicator, so a plain checked box only shows as a solid color + square; this subclass draws the checkmark on top with QPainter instead. + """ + + def paintEvent(self, event) -> None: # noqa: N802 (Qt override) + super().paintEvent(event) + if not self.isChecked(): + return + option = QStyleOptionButton() + self.initStyleOption(option) + rect = self.style().subElementRect(SE_CHECKBOX_INDICATOR, option, self) + painter = QPainter(self) + painter.setRenderHint(RENDER_HINT_ANTIALIASING) + pen = QPen(QColor(COLORS['check_mark'])) + pen.setWidthF(max(1.6, rect.width() * 0.16)) + pen.setCapStyle(PEN_CAP_ROUND) + pen.setJoinStyle(PEN_JOIN_ROUND) + painter.setPen(pen) + x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height() + path = QPainterPath() + path.moveTo(QPointF(x + w * 0.22, y + h * 0.54)) + path.lineTo(QPointF(x + w * 0.42, y + h * 0.74)) + path.lineTo(QPointF(x + w * 0.80, y + h * 0.28)) + painter.drawPath(path) + painter.end() + + def action_button(text: str, color: str, active_color: str) -> QPushButton: button = QPushButton(text) - button.setFont(ACTION_FONT) + button.setFont(_font('ACTION_FONT')) button.setCursor(POINTING_HAND_CURSOR) + button.setMinimumHeight(40) button.setStyleSheet( 'QPushButton {' - f'background: {color}; color: white; border: 2px solid #222; padding: 12px 18px;' + f'background: {color}; color: #ffffff; border: 1px solid {color};' + 'border-radius: 6px; padding: 8px 18px;' + '}' + 'QPushButton:hover {' + f'background: {active_color}; border-color: {active_color};' '}' - 'QPushButton:hover, QPushButton:pressed {' - f'background: {active_color};' + 'QPushButton:pressed {' + f'background: {color}; border-color: {active_color};' '}' ) return button +def section_label(text: str) -> QLabel: + """A small, dimmed caption used above panes such as the terminal.""" + label = QLabel(text.upper()) + label.setFont(_font('LABEL_FONT')) + label.setStyleSheet(f'color: {COLORS["text_dim"]}; letter-spacing: 1px; background: transparent;') + return label + + +def tool_count_badge() -> QLabel: + """A small pill next to a server name; starts neutral until a live count is known.""" + label = QLabel('no tools yet') + label.setFont(_font('LABEL_FONT')) + set_tool_count_badge(label, None) + return label + + +def set_tool_count_badge(label: QLabel, count: int | None, error: str | None = None) -> None: + """Update a tool_count_badge() label. + + Pass None to reset to the not-yet-known state, or a short error string to + show a red failure state (e.g. 'port in use'). + """ + if error is not None: + label.setText(error) + color = border = COLORS['danger'] + else: + ready = count is not None + label.setText('no tools yet' if count is None else f'{count} tool{"" if count == 1 else "s"}') + color = COLORS['accent'] if ready else COLORS['text_dim'] + border = COLORS['accent'] if ready else COLORS['border_strong'] + label.setStyleSheet( + 'QLabel {' + f'color: {color}; border: 1px solid {border}; border-radius: 9px;' + 'padding: 1px 10px; background: transparent;' + '}' + ) + + +# The two Tango hosts this project's startup GUIs are actually pointed at day +# to day: the local digital-twin stack, and the real Spectra 300 instrument. +DIGITAL_TWIN_HOST = 'localhost' +SPECTRA300_HOST = '10.46.217.241' +HOST_PRESETS = {'digital_twin': DIGITAL_TWIN_HOST, 'spectra300': SPECTRA300_HOST} + + +class HostToggle(QWidget): + """A Digital Twin / Spectra300 segmented switch that sets host field(s) to a known-good preset. + + This only covers the Tango (and optionally Tiled) *host* value(s) - the + recurring pain point where a GUI is left pointed at the wrong machine. + It does not switch instrument file/device classes; that's config-driven + (load DigitalTwin.yaml / Spectra300.yaml) since those differ per instrument, + not just per host. + """ + + def __init__(self, on_change: Callable[[str], None], parent=None): + super().__init__(parent) + self._on_change = on_change + self._buttons: dict[str, QPushButton] = {} + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + for key, label in (('digital_twin', 'Digital Twin'), ('spectra300', 'Spectra300')): + button = QPushButton(label) + button.setCheckable(True) + button.setFont(_font('LABEL_FONT')) + button.setCursor(POINTING_HAND_CURSOR) + button.clicked.connect(lambda _checked, k=key: self._select(k, notify=True)) + layout.addWidget(button) + self._buttons[key] = button + self.set_active(None) + + def _select(self, key: str, notify: bool) -> None: + for button_key, button in self._buttons.items(): + button.setChecked(button_key == key) + self._restyle() + if notify: + self._on_change(HOST_PRESETS[key]) + + def set_active(self, key: str | None) -> None: + """Reflect which preset (if any) a host field currently matches, without firing on_change.""" + self._select(key, notify=False) + + def _restyle(self) -> None: + for key, button in self._buttons.items(): + checked = button.isChecked() + first = key == 'digital_twin' + radius = '6px 0px 0px 6px' if first else '0px 6px 6px 0px' + border_fix = '' if first else 'border-left: none;' + if checked: + button.setStyleSheet( + 'QPushButton {' + f'background: {COLORS["accent"]}; color: #ffffff; border: 1px solid {COLORS["accent"]};' + f'{border_fix} border-radius: {radius}; padding: 4px 12px;' + '}' + ) + else: + button.setStyleSheet( + 'QPushButton {' + f'background: {COLORS["panel"]}; color: {COLORS["text_dim"]}; border: 1px solid {COLORS["border"]};' + f'{border_fix} border-radius: {radius}; padding: 4px 12px;' + '}' + 'QPushButton:hover {' + f'background: {COLORS["panel_hover"]};' + '}' + ) + + def configure_terminal(widget: QTextEdit) -> None: - widget.setFont(TEXT_FONT) + widget.setFont(_font('TEXT_FONT')) widget.setReadOnly(True) - widget.setStyleSheet('background: #0d1117; color: #c9d1d9;') + widget.setMinimumHeight(320) + widget.setStyleSheet( + 'QTextEdit {' + f'background: {COLORS["terminal_bg"]}; color: {COLORS["terminal_text"]};' + f'border: 1px solid {COLORS["border"]}; border-radius: 8px; padding: 10px;' + f'selection-background-color: {COLORS["accent"]}; selection-color: #0b0f14;' + '}' + ) + + +def scrollable(content: QWidget) -> QScrollArea: + """Wrap a controls pane so collapsing sections never squeeze the fields.""" + area = QScrollArea() + area.setWidget(content) + area.setWidgetResizable(True) + area.setFrameShape(NO_FRAME) + # As-needed in both directions: a narrow window scrolls rather than clipping + # the labels of whichever section happens to be open. + area.setHorizontalScrollBarPolicy(SCROLLBAR_AS_NEEDED) + area.setVerticalScrollBarPolicy(SCROLLBAR_AS_NEEDED) + return area + + +def configure_splitter(splitter: QSplitter, handle_width: int = 10) -> None: + """Give a splitter a wide, visibly grabbable handle and stop panes from collapsing to zero.""" + splitter.setHandleWidth(handle_width) + splitter.setChildrenCollapsible(False) + splitter.setOpaqueResize(True) + splitter.setStyleSheet( + 'QSplitter::handle { background: transparent; }' + f'QSplitter::handle:horizontal {{ margin: 0px 4px; border-left: 2px solid {COLORS["border"]}; }}' + f'QSplitter::handle:vertical {{ margin: 4px 0px; border-top: 2px solid {COLORS["border"]}; }}' + f'QSplitter::handle:hover {{ border-color: {COLORS["accent"]}; }}' + f'QSplitter::handle:pressed {{ border-color: {COLORS["accent"]}; }}' + ) + + +class CollapsibleSection(QWidget): + """A titled card whose body can be shown or hidden by clicking the header, like a dropdown.""" + + def __init__(self, title: str, layout_cls=QFormLayout, expanded: bool = True, parent=None): + super().__init__(parent) + self._title = title + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + self.toggle = QPushButton() + self.toggle.setFlat(True) + self.toggle.setFont(_font('SECTION_FONT')) + self.toggle.setCursor(POINTING_HAND_CURSOR) + self.toggle.clicked.connect(lambda: self.set_expanded(not self._expanded)) + outer.addWidget(self.toggle) + + self.body = QWidget() + self.body.setObjectName('sectionBody') + self.form = layout_cls() + self.form.setContentsMargins(14, 10, 14, 12) + self.form.setSpacing(8) + if isinstance(self.form, QFormLayout): + self.form.setFieldGrowthPolicy(FIELDS_STAY_AT_SIZE_HINT) + self.form.setHorizontalSpacing(14) + self.body.setLayout(self.form) + self.body.setStyleSheet( + '#sectionBody {' + f'background: {COLORS["panel"]}; border: 1px solid {COLORS["border"]};' + 'border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;' + '}' + ) + outer.addWidget(self.body) + + self._expanded = expanded + self.set_expanded(expanded) + + def _style_toggle(self, expanded: bool) -> None: + radius = '8px 8px 0px 0px' if expanded else '8px' + self.toggle.setStyleSheet( + 'QPushButton {' + f'background: {COLORS["panel"]}; color: {COLORS["text"]};' + f'border: 1px solid {COLORS["border"]}; border-radius: {radius};' + 'text-align: left; padding: 9px 12px;' + '}' + 'QPushButton:hover {' + f'background: {COLORS["panel_hover"]}; border-color: {COLORS["border_strong"]};' + '}' + ) + + def set_expanded(self, expanded: bool) -> None: + self._expanded = expanded + self.body.setVisible(expanded) + arrow = '⌄' if expanded else '›' + self.toggle.setText(f'{arrow} {self._title}') + self._style_toggle(expanded) def append_terminal_text(widget: QTextEdit, text: str) -> None: @@ -121,20 +604,32 @@ def start(self, command: list[str]) -> None: threading.Thread(target=self._read_output, daemon=True).start() def stop(self) -> None: + """Immediately kill the managed process and every subprocess it spawned. + + The startup scripts launch whole trees (uv -> python -> device servers, + Tiled, ...), so a polite terminate of the root leaves orphans holding + ports. Kill the full tree instead. + """ if not self.running: self.output_ready.emit('No process is running.\n') return assert self.process is not None if os.name == 'nt': - self.process.terminate() + # /T walks the child tree, /F force-kills without waiting. + subprocess.run( + ['taskkill', '/PID', str(self.process.pid), '/T', '/F'], + capture_output=True, + ) else: try: - os.killpg(self.process.pid, signal.SIGTERM) + # start_new_session=True in start() put the whole tree in one + # process group, so SIGKILL to the group takes everything down. + os.killpg(self.process.pid, signal.SIGKILL) except ProcessLookupError: return except OSError: - self.process.terminate() - self.output_ready.emit('Stop requested.\n') + self.process.kill() + self.output_ready.emit('Killed process tree.\n') def _read_output(self) -> None: assert self.process is not None diff --git a/startup_scripts/run_llm.py b/startup_scripts/run_llm.py index 6434eae5..ec30a9ce 100644 --- a/startup_scripts/run_llm.py +++ b/startup_scripts/run_llm.py @@ -54,8 +54,42 @@ def load_config(path: Path) -> LLMConfig: raw = yaml.safe_load(path.read_text(encoding='utf-8')) or {} return LLMConfig(**raw) -def register_device(config: LLMConfig | None): - database = tango.Database() +def ensure_database_running(config: LLMConfig) -> tango.Database: + host = config.tango.host + port = config.tango.port + tango_host = f"{host}:{port}" + + try: + database = tango.Database(host, port) + database.get_class_list("*") + return database + except tango.DevFailed: + pass + + print(f"[SYSTEM]: Tango database not responding at {tango_host}. Launching database server...") + env = {**os.environ, 'TANGO_HOST': tango_host} + subprocess.Popen( + ["uv", "run", "python", "-m", "tango.databaseds.database", "2"], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + start_time = time.time() + while time.time() - start_time < 30: + try: + database = tango.Database(host, port) + database.get_class_list("*") + print(f"[SYSTEM]: Tango database at {tango_host} is now ready!") + return database + except tango.DevFailed: + time.sleep(1) + + raise RuntimeError(f"Could not connect to Tango database at {tango_host}.") + +def register_device(config: LLMConfig): + database = ensure_database_running(config) try: device_info = tango.DbDevInfo() device_info.server = f"LLM/{INSTANCE_NAME}" @@ -96,7 +130,7 @@ def main(argv: list[str] | None = None) -> int: register_device(config) - command = ["uv", "run", "python", "-m", "asyncroscopy.mcp.llm", INSTANCE_NAME] + command = ["uv", "run", "--extra", "agent", "--extra", "ollama", "python", "-m", "asyncroscopy.mcp.llm", INSTANCE_NAME] env = {**os.environ, 'TANGO_HOST': tango_host, 'PYTHONUNBUFFERED': '1'} try: diff --git a/startup_scripts/run_mcp.py b/startup_scripts/run_mcp.py index c6429b16..b27a8cfd 100644 --- a/startup_scripts/run_mcp.py +++ b/startup_scripts/run_mcp.py @@ -8,7 +8,7 @@ import json import os import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import yaml @@ -32,6 +32,7 @@ class MCPConfig: quiet: bool blocked_classes: list[str] blocked_functions: dict[str, list[str]] + include_only_functions: list[str] = field(default_factory=list) @dataclass(frozen=True) @@ -67,6 +68,7 @@ def load_config(path: Path) -> Config: quiet=bool(_require(mcp, 'quiet', 'mcp')), blocked_classes=list(_require(mcp, 'blocked_classes', 'mcp')), blocked_functions={key: list(value) for key, value in _require(mcp, 'blocked_functions', 'mcp').items()}, + include_only_functions=list(mcp.get('include_only_functions', [])), ), ) @@ -96,6 +98,8 @@ def build_command(config: Config) -> list[str]: json.dumps(config.mcp.blocked_classes), '--blocked-functions-json', json.dumps(config.mcp.blocked_functions), + '--include-only-functions-json', + json.dumps(config.mcp.include_only_functions), ] if config.mcp.quiet: command.append('--quiet') diff --git a/startup_scripts/run_servers.py b/startup_scripts/run_servers.py index 9b2df25d..943dafd1 100755 --- a/startup_scripts/run_servers.py +++ b/startup_scripts/run_servers.py @@ -402,6 +402,10 @@ def register_devices(devices: list[DeviceConfig], instrument_properties: dict[st device_info._class = device.class_name device_info.name = device.device_name database.add_device(device_info) + try: + database.unexport_server(device.server_name) + except Exception: + pass status_line("OK", device.device_name) for property_name, property_value in device.properties.items(): database.put_device_property(device.device_name, {property_name: property_value}) @@ -486,7 +490,18 @@ def print_summary( def main(argv: list[str] | None = None) -> int: + shutdown_requested = False + def request_shutdown(_signum, _frame) -> None: + # A GUI's Stop button (or a wrapper like `uv run` forwarding its own + # copy of the same signal) can deliver SIGTERM more than once for a + # single stop request. Only the first should raise: re-raising while + # ProcessManager.shutdown_all() is already unwinding interrupts that + # cleanup mid-flight and can leave child device servers orphaned. + nonlocal shutdown_requested + if shutdown_requested: + return + shutdown_requested = True raise KeyboardInterrupt signal.signal(signal.SIGTERM, request_shutdown) diff --git a/tests/test_digital_twin.py b/tests/test_digital_twin.py index c7cddb61..e4eac9ae 100644 --- a/tests/test_digital_twin.py +++ b/tests/test_digital_twin.py @@ -26,6 +26,22 @@ def test_defocus_commands_round_trip(self, twin_proxy: tango.DeviceProxy): assert twin_proxy.get_defocus() == pytest.approx(8e-9) + def test_get_parameters_returns_status_json(self, twin_proxy: tango.DeviceProxy): + import json + + parameters = json.loads(twin_proxy.get_parameters()) + + assert parameters["manufacturer"] == "UTKTeam" + assert parameters["stem_mode"] is True + assert "defocus_m" in parameters + assert "stage_position" in parameters + assert "fov_m" in parameters + assert parameters["scan_detectors"] == ["haadf"] + assert parameters["spectrum_detectors"] == ["eds"] + assert "BM-Ceta" in parameters["camera_detectors"] + assert "scan" in parameters["device_proxies"] + assert "detectors" not in parameters, "device roles must not be published as detectors" + def test_get_image_returns_saved_hdf5(self, twin_proxy: tango.DeviceProxy, scan_proxy: tango.DeviceProxy): scan_proxy.imsize = 32 scan_proxy.dwell_time = 1e-6 diff --git a/tests/test_llm_device.py b/tests/test_llm_device.py index d4d976a9..a6689646 100644 --- a/tests/test_llm_device.py +++ b/tests/test_llm_device.py @@ -26,6 +26,16 @@ def setup_llm_stubs(): system_msg_cls = type("SystemMessage", (base_msg_cls,), { "__init__": lambda self, content: setattr(self, "content", content), }) + ai_msg_cls = type("AIMessage", (base_msg_cls,), { + "__init__": lambda self, content, tool_calls=None: ( + setattr(self, "content", content) or setattr(self, "tool_calls", tool_calls or []) + ), + }) + tool_msg_cls = type("ToolMessage", (base_msg_cls,), { + "__init__": lambda self, content, tool_call_id=None: ( + setattr(self, "content", content) or setattr(self, "tool_call_id", tool_call_id) + ), + }) langchain_core = types.ModuleType("langchain_core") lc_tools = types.ModuleType("langchain_core.tools") @@ -34,6 +44,8 @@ def setup_llm_stubs(): lc_messages.BaseMessage = base_msg_cls lc_messages.HumanMessage = human_msg_cls lc_messages.SystemMessage = system_msg_cls + lc_messages.AIMessage = ai_msg_cls + lc_messages.ToolMessage = tool_msg_cls langchain_core.tools = lc_tools langchain_core.messages = lc_messages @@ -75,6 +87,7 @@ def setup_llm_stubs(): setup_llm_stubs() from asyncroscopy.mcp.llm import Agent, LLM +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage # --------------------------------------------------------------------------- @@ -321,4 +334,119 @@ async def fake_stream(executor, messages, agent_label=""): asyncio.run(device._run_swarm("scan now")) assert len(captured_messages) == 1 - assert captured_messages[0].content == "scan now" \ No newline at end of file + assert captured_messages[0].content == "scan now" + + +class TestOpenAIMessagesToLangchain: + def test_user_message_becomes_human_message(self): + [msg] = LLM._openai_messages_to_langchain([{"role": "user", "content": "hi"}]) + assert isinstance(msg, HumanMessage) + assert msg.content == "hi" + + def test_system_message_becomes_system_message(self): + [msg] = LLM._openai_messages_to_langchain([{"role": "system", "content": "be careful"}]) + assert isinstance(msg, SystemMessage) + assert msg.content == "be careful" + + def test_assistant_message_without_tool_calls(self): + [msg] = LLM._openai_messages_to_langchain([{"role": "assistant", "content": "done"}]) + assert isinstance(msg, AIMessage) + assert msg.content == "done" + assert msg.tool_calls == [] + + def test_assistant_message_with_tool_calls_parses_arguments(self): + [msg] = LLM._openai_messages_to_langchain([{ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "acquire_image", "arguments": '{"detector": "haadf"}'}, + }], + }]) + assert isinstance(msg, AIMessage) + assert msg.tool_calls == [{"name": "acquire_image", "args": {"detector": "haadf"}, "id": "call_1"}] + + def test_tool_message_becomes_tool_message(self): + [msg] = LLM._openai_messages_to_langchain([{ + "role": "tool", "tool_call_id": "call_1", "content": "stem_image_HAADF_x.h5", + }]) + assert isinstance(msg, ToolMessage) + assert msg.content == "stem_image_HAADF_x.h5" + assert msg.tool_call_id == "call_1" + + def test_unknown_role_falls_back_to_human_message(self): + [msg] = LLM._openai_messages_to_langchain([{"role": "weird", "content": "??"}]) + assert isinstance(msg, HumanMessage) + + def test_missing_content_defaults_to_empty_string(self): + [msg] = LLM._openai_messages_to_langchain([{"role": "user"}]) + assert msg.content == "" + + +class TestLangchainMessageToOpenAI: + def test_plain_text_message(self): + message = AIMessage(content="hello there") + result = LLM._langchain_message_to_openai(message) + assert result == {"role": "assistant", "content": "hello there"} + + def test_message_with_tool_calls_encodes_arguments_as_json_string(self): + message = AIMessage(content="", tool_calls=[{"name": "acquire_image", "args": {"n": 1}, "id": "call_9"}]) + result = LLM._langchain_message_to_openai(message) + assert result["tool_calls"] == [{ + "id": "call_9", + "type": "function", + "function": {"name": "acquire_image", "arguments": '{"n": 1}'}, + }] + + def test_tool_call_missing_id_gets_a_fallback(self): + message = AIMessage(content="", tool_calls=[{"name": "acquire_image", "args": {}, "id": ""}]) + result = LLM._langchain_message_to_openai(message) + assert result["tool_calls"][0]["id"] == "call_0" + + +class TestComplete: + def test_returns_tool_call_decision(self): + device = _make_llm() + response = AIMessage(content="", tool_calls=[{"name": "acquire_image", "args": {}, "id": "call_1"}]) + bound_model = AsyncMock() + bound_model.ainvoke.return_value = response + device._model = MagicMock() + device._model.bind_tools.return_value = bound_model + + request = { + "messages": [{"role": "user", "content": "acquire an image"}], + "tools": [{"type": "function", "function": {"name": "acquire_image"}}], + } + result = json.loads(asyncio.run(device.Complete(json.dumps(request)))) + + assert result["message"]["tool_calls"][0]["function"]["name"] == "acquire_image" + device._model.bind_tools.assert_called_once_with(request["tools"]) + + def test_skips_bind_tools_when_no_tools_given(self): + device = _make_llm() + response = AIMessage(content="hi there") + device._model = AsyncMock() + device._model.ainvoke.return_value = response + + request = {"messages": [{"role": "user", "content": "hi"}]} + result = json.loads(asyncio.run(device.Complete(json.dumps(request)))) + + assert result["message"]["content"] == "hi there" + device._model.ainvoke.assert_called_once() + + def test_invalid_json_returns_error_payload(self): + device = _make_llm() + result = json.loads(asyncio.run(device.Complete("not json"))) + assert "error" in result + assert "message" in result["error"] + + def test_model_exception_returns_error_payload(self): + device = _make_llm() + device._model = AsyncMock() + device._model.ainvoke.side_effect = RuntimeError("model unavailable") + + request = {"messages": [{"role": "user", "content": "hi"}]} + result = json.loads(asyncio.run(device.Complete(json.dumps(request)))) + + assert result["error"]["message"] == "model unavailable" \ No newline at end of file diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 08b71be3..9fa78668 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -23,7 +23,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from asyncroscopy.mcp.mcp_server import MCPServer +from fastmcp.tools import ToolResult + +from asyncroscopy.mcp.mcp_server import COMMAND_TIMEOUT_MILLIS, MCPServer def mcp_kwargs(**overrides): @@ -542,3 +544,126 @@ def record_tool(method): server.setup(print_summary=False) assert set(calls) == {"get_data_from_key", "list_devices"} + + +class TestMCPCommandTimeout: + def test_find_tools_raises_proxy_timeout_above_tango_default(self, monkeypatch) -> None: + class FakeDb: + def get_device_exported(self, pattern): + return type("Result", (), {"value_string": ["asyncroscopy/twin/default"]})() + + timeouts = [] + + class FakeProxy: + def __init__(self, name): + self.name = name + + def set_timeout_millis(self, millis): + timeouts.append(millis) + + def info(self): + return type("Info", (), {"dev_class": "Twin"})() + + def command_list_query(self): + return [] + + monkeypatch.setattr("asyncroscopy.mcp.mcp_server.Database", lambda host, port: FakeDb()) + monkeypatch.setattr("asyncroscopy.mcp.mcp_server.DeviceProxy", FakeProxy) + + server = MCPServer("test", "localhost", 1234, **mcp_kwargs(), verbose=False) + server._find_tools() + + assert timeouts == [COMMAND_TIMEOUT_MILLIS] + assert COMMAND_TIMEOUT_MILLIS > 3000 + + +class TestMCPSpectrumPreview: + png_magic = b"\x89PNG\r\n\x1a\n" + + def test_spectrum_png_labeled_and_unlabeled(self) -> None: + labeled = MCPServer._spectrum_to_png_bytes( + np.array([0.5, 0.3, 0.2]), ["Au", "Pt", "Fe"] + ) + unlabeled = MCPServer._spectrum_to_png_bytes(np.arange(64, dtype=np.float64)) + + assert labeled[: len(self.png_magic)] == self.png_magic + assert unlabeled[: len(self.png_magic)] == self.png_magic + + def test_element_labels_parse_json_and_reject_malformed(self) -> None: + assert MCPServer._element_labels({"elements": '["Au", "Pt"]'}) == ["Au", "Pt"] + assert MCPServer._element_labels({"elements": ["Fe"]}) == ["Fe"] + assert MCPServer._element_labels({"elements": "not json"}) is None + assert MCPServer._element_labels({"elements": [1, 2]}) is None + assert MCPServer._element_labels({}) is None + + def test_acquire_spectrum_result_gains_png_preview(self, monkeypatch) -> None: + monkeypatch.setattr("asyncroscopy.mcp.mcp_server.Database", lambda host, port: None) + + class FakeSpectrum: + metadata = {"elements": json.dumps(["Au", "Pt", "Fe"])} + + def read(self): + return np.array([0.5, 0.3, 0.2]) + + class FakeContainer(dict): + metadata: dict = {} + + node = FakeContainer(spectrum=FakeSpectrum()) + + class FakeDataProxy: + def get_config(self): + return json.dumps({"uri": "http://microscope:9091"}) + + monkeypatch.setattr( + "asyncroscopy.mcp.mcp_server.DeviceProxy", lambda address: FakeDataProxy() + ) + monkeypatch.setattr( + "asyncroscopy.mcp.mcp_server.from_uri", lambda uri: {"spectrum_eds.h5": node} + ) + + server = MCPServer("test", "localhost", 1234, **mcp_kwargs(), verbose=False) + result = server._augment_with_preview("acquire_spectrum", "spectrum_eds.h5") + + assert isinstance(result, ToolResult) + assert result.structured_content == {"result": "spectrum_eds.h5"} + image_blocks = [ + block for block in result.content if getattr(block, "type", "") == "image" + ] + assert len(image_blocks) == 1 + assert base64.b64decode(image_blocks[0].data)[: len(self.png_magic)] == self.png_magic + + def test_spectrum_preview_failure_names_the_reason(self, monkeypatch) -> None: + monkeypatch.setattr("asyncroscopy.mcp.mcp_server.Database", lambda host, port: None) + + class FakeContainer(dict): + metadata: dict = {} + + class FakeDataProxy: + def get_config(self): + return json.dumps({"uri": "http://microscope:9091"}) + + monkeypatch.setattr( + "asyncroscopy.mcp.mcp_server.DeviceProxy", lambda address: FakeDataProxy() + ) + monkeypatch.setattr( + "asyncroscopy.mcp.mcp_server.from_uri", + lambda uri: {"spectrum_eds.h5": FakeContainer()}, + ) + + server = MCPServer("test", "localhost", 1234, **mcp_kwargs(), verbose=False) + result = server._augment_with_preview("acquire_spectrum", "spectrum_eds.h5") + + assert isinstance(result, ToolResult) + assert result.structured_content == {"result": "spectrum_eds.h5"} + texts = [getattr(block, "text", "") for block in result.content] + assert any("image preview unavailable" in text for text in texts) + assert any("no 1D dataset" in text for text in texts) + + def test_unrelated_commands_keep_plain_results(self, monkeypatch) -> None: + monkeypatch.setattr("asyncroscopy.mcp.mcp_server.Database", lambda host, port: None) + + server = MCPServer("test", "localhost", 1234, **mcp_kwargs(), verbose=False) + + assert server._augment_with_preview("get_stage", "[0.0,0.0]") == "[0.0,0.0]" + assert server._augment_with_preview("acquire_spectrum", "") == "" + assert server._augment_with_preview("acquire_spectrum", 42) == 42 diff --git a/tests/test_process_manager.py b/tests/test_process_manager.py index 76ea6c87..b202c06e 100644 --- a/tests/test_process_manager.py +++ b/tests/test_process_manager.py @@ -133,6 +133,9 @@ def mock_kill(pid, sig): killed_pids.append((pid, sig)) monkeypatch.setattr(process_manager.os, "kill", mock_kill) + if os.name != "nt": + monkeypatch.setattr(process_manager.os, "getpgid", lambda pid: pid) + monkeypatch.setattr(process_manager.os, "killpg", mock_kill) time_vals = [100.0, 102.0, 100.0, 102.0] monkeypatch.setattr(process_manager.time, "time", lambda: time_vals.pop(0) if time_vals else 200.0) @@ -205,6 +208,8 @@ def mock_kill(pid, sig): monkeypatch.setattr(subprocess, "run", lambda cmd, **kwargs: FakeCompletedProcess()) monkeypatch.setattr(os, "kill", mock_kill) + monkeypatch.setattr(os, "getpgid", lambda pid: pid) + monkeypatch.setattr(os, "killpg", mock_kill) count = manager.stop_processes_on_port(9094) assert count == 2