diff --git a/asyncroscopy/instruments/instrument.py b/asyncroscopy/instruments/instrument.py index 1827a630..568d8de2 100644 --- a/asyncroscopy/instruments/instrument.py +++ b/asyncroscopy/instruments/instrument.py @@ -5,6 +5,7 @@ from abc import abstractmethod, ABCMeta import tango +import tango.server class CombinedMeta(tango.server.DeviceMeta, ABCMeta): """Combines Tango DeviceMeta and ABCMeta to allow abstract methods in Devices.""" diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_approach.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_approach.py new file mode 100644 index 00000000..5fdd6003 --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_approach.py @@ -0,0 +1,114 @@ +""" +SPM APPROACH Tango device. + +Owns the tip approach/retract sequence. Vendor-specific behaviour is +isolated in the ``_hw_*`` hooks implemented by concrete subclasses +(APPROACH_Jupiter, etc.). +""" + +from abc import abstractmethod + +import tango +from asyncroscopy.instruments.instrument import CombinedMeta + +class SPM_APPROACH(tango.server.Device, metaclass=CombinedMeta): + """Abstract SPM approach device: tip engage / retract.""" + + # ------------------------------------------------------------------ + # Attributes + # ------------------------------------------------------------------ + + approached = tango.server.attribute( + label="Approached", + dtype=bool, + access=tango.AttrWriteType.READ, + doc="True when the tip is engaged on the surface. Read live from hardware.", + ) + + + # ------------------------------------------------------------------ + # Initialization + # ------------------------------------------------------------------ + + def init_device(self) -> None: + tango.server.Device.init_device(self) + self.set_state(tango.DevState.ON) + self.info_stream("SPM APPROACH device initialised") + + # ------------------------------------------------------------------ + # Attribute read + # ------------------------------------------------------------------ + + def read_approached(self) -> bool: + return self._hw_is_approached() + + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + + @tango.server.command + def approach(self) -> None: + """Engage the tip on the surface. Blocks until engaged.""" + if self.get_state() == tango.DevState.MOVING: + tango.Except.throw_exception( + "ApproachInProgress", + "An approach or retract is already running; call stop first.", + "approach()", + ) + self.set_state(tango.DevState.MOVING) + try: + self._hw_approach() + finally: + self.set_state(tango.DevState.ON) + + @tango.server.command + def retract(self) -> None: + """Retract the tip from the surface. Blocks until retracted.""" + if self.get_state() == tango.DevState.MOVING: + tango.Except.throw_exception( + "ApproachInProgress", + "An approach or retract is already running; call stop first.", + "retract()", + ) + self.set_state(tango.DevState.MOVING) + try: + self._hw_retract() + finally: + self.set_state(tango.DevState.ON) + + @tango.server.command + def stop(self) -> None: + """Abort a running approach or retract immediately.""" + self._hw_stop() + self.set_state(tango.DevState.ON) + + # ------------------------------------------------------------------ + # Abstract methods — vendor-specific + # ------------------------------------------------------------------ + + @abstractmethod + def _hw_approach(self) -> None: + """Run the approach sequence; return when the tip is engaged.""" + pass + + @abstractmethod + def _hw_retract(self) -> None: + """Retract the tip; return when clear of the surface.""" + pass + + @abstractmethod + def _hw_stop(self) -> None: + """Abort any running approach/retract motion.""" + pass + + @abstractmethod + def _hw_is_approached(self) -> bool: + """Return True if the tip is currently engaged.""" + pass + +# ---------------------------------------------------------------------- +# Server entry point +# ---------------------------------------------------------------------- +if __name__ == "__main__": + SPM_APPROACH.run_server() + \ No newline at end of file diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_feedback.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_feedback.py new file mode 100644 index 00000000..11edef9a --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_feedback.py @@ -0,0 +1,140 @@ +""" +SPM FEEDBACK Tango device. + +Owns the Z feedback loop: setpoint, gains, and engage/withdraw. + +Note: the physical meaning of ``setpoint`` depends on the active SPM +mode (deflection volts in contact, amplitude volts in AC, etc.); this +device passes the value through without interpreting it. +""" + +from abc import abstractmethod +import tango # type: ignore + +from asyncroscopy.instruments.instrument import CombinedMeta + +class SPM_FEEDBACK(tango.server.Device, metaclass=CombinedMeta): + """Abstract SPM feedback device: Z loop setpoint, gains, engage state.""" + + # ------------------------------------------------------------------ + # Attributes + # ------------------------------------------------------------------ + + setpoint = tango.server.attribute( + label="Setpoint", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + doc="Z feedback setpoint. Physical meaning depends on the active SPM mode " + "(deflection in contact, amplitude in AC).", + ) + + i_gain = tango.server.attribute( + label="Integral Gain", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + doc="Integral gain of the Z feedback loop.", + ) + + feedback_on_bool = tango.server.attribute( + label="Feedback On", + dtype=bool, + access=tango.AttrWriteType.READ, + doc="True when the Z feedback loop is engaged. Read live from hardware.", + ) + + # ------------------------------------------------------------------ + # Initialization + # ------------------------------------------------------------------ + def init_device(self) -> None: + tango.server.Device.init_device(self) + self._refresh_params() + self.set_state(tango.DevState.ON) + self.info_stream("SPM FEEDBACK device initialised") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _refresh_params(self) -> None: + """Re-read all feedback parameters from hardware into local fields.""" + params = self._hw_read_feedback_params() + self._setpoint: float = params["setpoint"] + self._i_gain: float = params["i_gain"] + + def _write_param(self, name: str, value) -> None: + """Push one parameter to hardware, then re-read all (hardware may coerce values).""" + self._hw_write_feedback_param(name, value) + self._refresh_params() + + # ------------------------------------------------------------------ + # Attribute read / write — writes pushed to hardware + # ------------------------------------------------------------------ + + def read_setpoint(self) -> float: + return self._setpoint + + def write_setpoint(self, value: float) -> None: + self._write_param("setpoint", value) + + def read_i_gain(self) -> float: + return self._i_gain + + def write_i_gain(self, value: float) -> None: + self._write_param("i_gain", value) + + def read_feedback_on_bool(self) -> bool: + return self._hw_is_feedback_on() + + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + + @tango.server.command + def feedback_on(self) -> None: + """Engage the Z feedback loop.""" + self._hw_feedback_on() + + @tango.server.command + def feedback_off(self) -> None: + """Disengage the Z feedback loop.""" + self._hw_feedback_off() + + @tango.server.command + def refresh_params(self) -> None: + """Re-read all feedback parameters from hardware, e.g. after changes in the vendor GUI.""" + self._refresh_params() + + # ------------------------------------------------------------------ + # Abstract methods — vendor-specific + # ------------------------------------------------------------------ + + @abstractmethod + def _hw_read_feedback_params(self) -> dict: + """Read all feedback parameters from hardware. Keys must match attribute names.""" + pass + + @abstractmethod + def _hw_write_feedback_param(self, name: str, value) -> None: + """Push one feedback parameter to hardware.""" + pass + + @abstractmethod + def _hw_feedback_on(self) -> None: + """Engage the Z feedback loop.""" + pass + + @abstractmethod + def _hw_feedback_off(self) -> None: + """Disengage the Z feedback loop.""" + pass + + @abstractmethod + def _hw_is_feedback_on(self) -> bool: + """Return True if the Z feedback loop is currently engaged.""" + pass + +# ---------------------------------------------------------------------- +# Server entry point +# ---------------------------------------------------------------------- +if __name__ == "__main__": + SPM_FEEDBACK.run_server() \ No newline at end of file diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py new file mode 100644 index 00000000..d09e5ab7 --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py @@ -0,0 +1,356 @@ +""" +SPM SCAN Tango device. + +Owns the scan frame parameters AND scan execution. Vendor-specific +behaviour is isolated in the ``_hw_*`` hooks implemented by concrete +subclasses (SCAN_Jupiter, etc.). + +Writes are pushed to hardware and read back. + +``acquire_scan`` returns a DATA/Tiled unique id. +""" + +from abc import abstractmethod + +import tango #type: ignore + +#from asyncroscopy.data.data_writer import save_acquisition +from asyncroscopy.instruments.instrument import CombinedMeta + +_PARAM_DEFAULTS = { + "x_scan_center_m": float("nan"), + "y_scan_center_m": float("nan"), + "scan_size_m": float("nan"), + "scan_size_px": 0, + "scan_angle_deg": float("nan"), + "scan_rate_hz": float("nan"), + } + +class SPM_SCAN(tango.server.Device, metaclass=CombinedMeta): + """Abstract SPM scan device: frame settings + scan execution.""" + + # ------------------------------------------------------------------ + # Device properties + # ------------------------------------------------------------------ + + data_device_address = tango.server.device_property( + dtype=str, + default_value="", + doc="Optional Tango device address for the DATA device, " + "e.g. 'asyncroscopy/data/default'.", + ) + + # ------------------------------------------------------------------ + # Attributes + # ------------------------------------------------------------------ + + x_scan_center_m = tango.server.attribute( + label="Scan Center X", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + unit="m", + format="%e", + doc="X center of the scan frame in meters.", + ) + + y_scan_center_m = tango.server.attribute( + label="Scan Center Y", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + unit="m", + format="%e", + doc="Y center of the scan frame in meters.", + ) + + scan_size_m = tango.server.attribute( + label="Scan Size (m)", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + unit="m", + format="%e", + doc="Side length of the scan frame in meters.", + ) + + scan_size_px = tango.server.attribute( + label="Scan Size (px)", + dtype=int, + access=tango.AttrWriteType.READ_WRITE, + unit="px", + doc="Number of pixels per side of the scan frame.", + ) + + scan_angle_deg = tango.server.attribute( + label="Scan Angle", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + unit="deg", + format="%6.2f", + doc="Rotation angle of the scan frame in degrees.", + ) + + scan_rate_hz = tango.server.attribute( + label="Scan Rate", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + unit="Hz", + format="%6.2f", + doc="Line rate in Hz.", + ) + + probe_x_m = tango.server.attribute( + label="Probe X Position", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + unit="m", + format="%e", + doc="Current probe X position in meters.", + ) + + probe_y_m = tango.server.attribute( + label="Probe Y Position", + dtype=float, + access=tango.AttrWriteType.READ_WRITE, + unit="m", + format="%e", + doc="Current probe Y position in meters.", + ) + + # ------------------------------------------------------------------ + # Initialization + # ------------------------------------------------------------------ + + def init_device(self) -> None: + tango.server.Device.init_device(self) + self._data_proxy = ( + tango.DeviceProxy(self.data_device_address) + if self.data_device_address else None + ) + + for name, value in _PARAM_DEFAULTS.items(): + setattr(self, f"_{name}", value) + + try: + self._refresh_params() + except Exception: + return + self._set_state_and_status(tango.DevState.ON, "Idle.") + self.info_stream("SPM SCAN device initialised") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _set_state_and_status(self, state, status: str) -> None: + """Set State and Status together. + """ + self.set_state(state) + self.set_status(status) + + def _refresh_params(self) -> None: + """Re-read all scan parameters from hardware into local fields.""" + try: + params = self._hw_read_scan_params() + except Exception as exc: + reason = exc.args[0].desc if isinstance(exc, tango.DevFailed) and exc.args else str(exc) + self._set_state_and_status( + tango.DevState.FAULT, + "Could not read scan parameters from hardware.\n" + str(reason), + ) + raise + + self._x_scan_center_m: float = params["x_scan_center_m"] + self._y_scan_center_m: float = params["y_scan_center_m"] + self._scan_size_m: float = params["scan_size_m"] + self._scan_size_px: int = params["scan_size_px"] + self._scan_angle_deg: float = params["scan_angle_deg"] + self._scan_rate_hz: float = params["scan_rate_hz"] + + if self.get_state() == tango.DevState.FAULT: + self._set_state_and_status(tango.DevState.ON, "Idle.") + + def _write_param(self, name: str, value) -> None: + """Push one parameter to hardware, then re-read all (hardware may coerce/couple values).""" + self._hw_write_scan_param(name, value) + self._refresh_params() + + def _scan_metadata(self) -> dict: + """Current scan parameters, saved as dataset attributes.""" + return { + "x_scan_center_m": self._x_scan_center_m, + "y_scan_center_m": self._y_scan_center_m, + "scan_size_m": self._scan_size_m, + "scan_size_px": self._scan_size_px, + "scan_angle_deg": self._scan_angle_deg, + "scan_rate_hz": self._scan_rate_hz, + } + + def _move_probe(self, x: float, y: float) -> None: + """Run one probe move with state bookkeeping; refuses during a scan.""" + state = self.get_state() + if state == tango.DevState.RUNNING: + tango.Except.throw_exception( + "ScanInProgress", + "Cannot move probe while a scan is running; call stop_scan first.", + "move_probe()", + ) + if state == tango.DevState.MOVING: + tango.Except.throw_exception( + "MoveInProgress", + "A probe move is already running.", + "move_probe()", + ) + self._set_state_and_status(tango.DevState.MOVING, "Moving the probe.") + try: + self._hw_move_probe(x, y) + finally: + self._set_state_and_status(tango.DevState.ON, "Idle.") + + #NB placeholder for now; once DATA is wired up, this will register the scan file and return a unique key. + def _save_scan(self, path: str) -> str: + """Hand a finished scan file to DATA/Tiled and return its key. + + Placeholder for now: returns the path unchanged. Once DATA is wired up + this becomes the same registration the electron microscope side does: + + if self._data_proxy is not None: + return self._data_proxy.register_path(path) + + Note for whoever writes that: _hw_acquire_scan returns as soon as the + filename appears, so the vendor software may still have the file open. + Wait for its size to settle before registering it. + """ + return path + + # ------------------------------------------------------------------ + # Attribute read / write, writes pushed to hardware + # ------------------------------------------------------------------ + + def read_x_scan_center_m(self) -> float: + return self._x_scan_center_m + + def write_x_scan_center_m(self, value: float) -> None: + self._write_param("x_scan_center_m", value) + + def read_y_scan_center_m(self) -> float: + return self._y_scan_center_m + + def write_y_scan_center_m(self, value: float) -> None: + self._write_param("y_scan_center_m", value) + + def read_scan_size_m(self) -> float: + return self._scan_size_m + + def write_scan_size_m(self, value: float) -> None: + self._write_param("scan_size_m", value) + + def read_scan_size_px(self) -> int: + return self._scan_size_px + + def write_scan_size_px(self, value: int) -> None: + self._write_param("scan_size_px", value) + + def read_scan_angle_deg(self) -> float: + return self._scan_angle_deg + + def write_scan_angle_deg(self, value: float) -> None: + self._write_param("scan_angle_deg", value) + + def read_scan_rate_hz(self) -> float: + return self._scan_rate_hz + + def write_scan_rate_hz(self, value: float) -> None: + self._write_param("scan_rate_hz", value) + + def read_probe_x_m(self) -> float: + return self._hw_read_probe_position()[0] + + def write_probe_x_m(self, value: float) -> None: + y = self._hw_read_probe_position()[1] + self._move_probe(value, y) + + def read_probe_y_m(self) -> float: + return self._hw_read_probe_position()[1] + + def write_probe_y_m(self, value: float) -> None: + x = self._hw_read_probe_position()[0] + self._move_probe(x, value) + + + + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + + @tango.server.command(dtype_out=str) + def acquire_scan(self) -> str: + """Acquire one frame with current settings; returns a DATA/Tiled key.""" + if self.get_state() == tango.DevState.RUNNING: + tango.Except.throw_exception( + "ScanInProgress", + "A scan is already running", + "acquire_scan()", + ) + self._set_state_and_status(tango.DevState.RUNNING, "Acquiring a scan frame.") + try: + path = self._hw_acquire_scan() + finally: + self._set_state_and_status(tango.DevState.ON, "Idle.") + return self._save_scan(path) + + @tango.server.command + def stop_scan(self) -> None: + """Stop a running scan.""" + self._hw_stop_scan() + self._set_state_and_status(tango.DevState.ON, "Idle.") + + @tango.server.command + def refresh_params(self) -> None: + """Re-read all scan parameters from hardware.""" + self._refresh_params() + + @tango.server.command(dtype_in=tango.DevVarDoubleArray) + def move_probe(self, position) -> None: + """Move the probe to an absolute position [x, y] in meters.""" + if len(position) != 2: + raise ValueError("position must contain exactly two values: [x, y]") + self._move_probe(float(position[0]), float(position[1])) + + # ------------------------------------------------------------------ + # Abstract methods — vendor-specific + # ------------------------------------------------------------------ + + @abstractmethod + def _hw_read_scan_params(self) -> dict: + """Read all scan parameters from hardware. Keys must match attribute names.""" + pass + + @abstractmethod + def _hw_write_scan_param(self, name: str, value) -> None: + """Push one scan parameter to hardware.""" + pass + + @abstractmethod + def _hw_acquire_scan(self) -> str: + """Run one scan; return the path of the file the vendor software wrote.""" + pass + + @abstractmethod + def _hw_stop_scan(self) -> None: + """Abort the running scan.""" + pass + + @abstractmethod + def _hw_read_probe_position(self) -> list[float]: + """Return the current probe position [x, y] in meters.""" + pass + + @abstractmethod + def _hw_move_probe(self, x: float, y: float) -> None: + """Move the probe to (x, y) in meters; return when done.""" + pass + +# ---------------------------------------------------------------------- +# Server entry point +# ---------------------------------------------------------------------- +if __name__ == "__main__": + SPM_SCAN.run_server() \ No newline at end of file diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py new file mode 100644 index 00000000..c5fe343f --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py @@ -0,0 +1,125 @@ +""" +SPM STAGE Tango device. + +Owns the coarse XY sample stage. + +""" + +from abc import abstractmethod +import tango # type: ignore + +from asyncroscopy.instruments.instrument import CombinedMeta + +class SPM_STAGE(tango.server.Device, metaclass=CombinedMeta): + """Abstract SPM coarse stage device: XY position and moves.""" + + # ------------------------------------------------------------------ + # Attributes + # ------------------------------------------------------------------ + + stage_x_m = tango.server.attribute( + label="Stage X Position", + dtype=float, + access=tango.AttrWriteType.READ, + unit="m", + + doc="Current stage X position in meters. Read live from hardware.", + ) + + stage_y_m = tango.server.attribute( + label="Stage Y Position", + dtype=float, + access=tango.AttrWriteType.READ, + unit="m", + + doc="Current stage Y position in meters. Read live from hardware.", + ) + + # ------------------------------------------------------------------ + # Initialization + # ------------------------------------------------------------------ + + def init_device(self) -> None: + tango.server.Device.init_device(self) + self.set_state(tango.DevState.ON) + self.info_stream("SPM STAGE device initialised") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _do_move(self, dx: float, dy: float) -> None: + """Run one relative move with MOVING-state bookkeeping.""" + if self.get_state() == tango.DevState.MOVING: + tango.Except.throw_exception( + "MoveInProgress", + "A stage move is already running; call stop first.", + "move_stage()", + ) + self.set_state(tango.DevState.MOVING) + try: + self._hw_move_stage_relative(dx, dy) + finally: + self.set_state(tango.DevState.ON) + + # ------------------------------------------------------------------ + # Attribute read — live from hardware + # ------------------------------------------------------------------ + + def read_stage_x_m(self) -> float: + return self._hw_read_stage_position()[0] + + def read_stage_y_m(self) -> float: + return self._hw_read_stage_position()[1] + + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + + @tango.server.command(dtype_in=tango.DevVarDoubleArray) + def move_stage(self, position) -> None: + """Move the stage to an absolute position [x, y] in meters. Blocks until done.""" + if len(position) != 2: + raise ValueError("position must contain exactly two values: [x, y]") + current = self._hw_read_stage_position() + self._do_move(float(position[0]) - current[0], float(position[1]) - current[1]) + + @tango.server.command(dtype_in=tango.DevVarDoubleArray) + def move_stage_relative(self, delta) -> None: + """Move the stage by [dx, dy] in meters. Blocks until done.""" + if len(delta) != 2: + raise ValueError("delta must contain exactly two values: [dx, dy]") + self._do_move(float(delta[0]), float(delta[1])) + + @tango.server.command + def stop(self) -> None: + """Abort a running stage move immediately.""" + self._hw_stop() + self.set_state(tango.DevState.ON) + + + # ------------------------------------------------------------------ + # Abstract methods — vendor-specific + # ------------------------------------------------------------------ + + @abstractmethod + def _hw_read_stage_position(self) -> list[float]: + """Return the current stage position [x, y] in meters.""" + pass + + @abstractmethod + def _hw_move_stage_relative(self, dx: float, dy: float) -> None: + """Move the stage by (dx, dy) in meters; return when the move is complete.""" + pass + + @abstractmethod + def _hw_stop(self) -> None: + """Abort any running stage motion.""" + pass + +# ---------------------------------------------------------------------- +# Server entry point +# ---------------------------------------------------------------------- +if __name__ == "__main__": + SPM_STAGE.run_server() + diff --git a/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py new file mode 100644 index 00000000..bdfb24db --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py @@ -0,0 +1,594 @@ +""" +Asylum Research Jupiter AFM implementations. + +Fills in the ``_hw_*`` hooks of the abstract SPM devices +(SPMMicroscope, SPM_SCAN, SPM_FEEDBACK, SPM_APPROACH, SPM_STAGE) +with calls to the gor Pro control software. + +Only one command is added here, calibrate_probe_frame, +because the scan-frame to scanner offset is a Jupiter-specific measurement. +""" + +from asyncroscopy.instruments.scanning_probe_microscope.scanning_probe_microscope import ( + SPMMicroscope, SPMMode, +) +from asyncroscopy.instruments.scanning_probe_microscope.hardware.spm_scan import SPM_SCAN +from asyncroscopy.instruments.scanning_probe_microscope.hardware.spm_feedback import SPM_FEEDBACK +from asyncroscopy.instruments.scanning_probe_microscope.hardware.spm_approach import SPM_APPROACH +from asyncroscopy.instruments.scanning_probe_microscope.hardware.spm_stage import SPM_STAGE + +import math +import subprocess +import threading +import time +from pathlib import Path + +import numpy as np +import tango +import tango.server + +# AR variable names for the scan parameters, keyed by attribute name. +_SCAN_PARAM_KEYS: dict[str, str] = { + "x_scan_center_m": "XOffset", + "y_scan_center_m": "YOffset", + "scan_size_m": "ScanSize", + "scan_size_px": "ScanLines", + "scan_angle_deg": "ScanAngle", + "scan_rate_hz": "ScanRate", +} + +_SCAN_WRITE_COMMANDS: dict[str, str] = { + "x_scan_center_m": 'PV("XOffset", {value})', + "y_scan_center_m": 'PV("YOffset", {value})', + "scan_size_m": 'ARExecuteControl("ScanSizeSetVar_0","MasterPanel",{value},"")', + "scan_size_px": 'ARExecuteControl("PointsLinesSetVar_0","MasterPanel",{value},"")', + "scan_angle_deg": 'PV("ScanAngle", {value})', + "scan_rate_hz": 'ARExecuteControl("ScanRateSetVar_0","MasterPanel",{value},"")', +} + +# Park the tip at the centre of the current scan frame. Clearing the force-spot +# list first makes "go there" fall back to the frame centre. +_CLEAR_FORCE_COMMAND = 'ARExecuteControl("ClearForce_1","MasterPanel",0,"")' +_GO_TO_CENTER_COMMAND = 'ARExecuteControl("GoForce_1","MasterPanel",0,"")' +_START_SCAN_COMMAND = 'ARExecuteControl("DownScan_0","MasterPanel",0,"")' +_STOP_SCAN_COMMAND = 'ARExecuteControl("StopScan_0","MasterPanel",0,"")' + + +# Scan centre, LVDT sensitivities (meters per volt) and the frame rotation. +# All plain AR globals, so one round trip covers them. +_PROBE_KEYS = [ + "PIDSLoop.0.Setpoint", "PIDSLoop.1.Setpoint", # closed-loop X/Y, volts + "XLVDTSens", "YLVDTSens", # meters per volt + "XOffset", "YOffset", # scan centre, meters + "ScanAngle", # degrees +] + + +try: + from aespm import read_spm, write_spm + from aespm.experiment import _read_out_buffer as _READ_OUT_BUFFER + + _AESPM_AVAILABLE = True + _AESPM_IMPORT_ERROR = "" +except Exception as exc: + _AESPM_AVAILABLE = False + _AESPM_IMPORT_ERROR = f"{type(exc).__name__}: {exc}" + _READ_OUT_BUFFER = "" + +_IGOR_LOCK = threading.RLock() +_READ_OUT_BUFFER_IGOR = _READ_OUT_BUFFER.replace("\\", "\\\\") + +# write_spm sleeps this long after handing the command to Igor, which gives the +# AR panels time to update before we read the value back. +_WRITE_SETTLE_S = 0.35 +_MOVE_SETTLE_S = 1. +# How often to look in the save folder while a scan is running. Only touches the +# filesystem, never Igor, so it is cheap. +_POLL_INTERVAL_S = 1.0 +# The AR software runs Igor Pro; the executable name has varied between +# versions, so we accept any of these. +_IGOR_PROCESS_NAMES = ("Igor.exe", "Igor64.exe", "IgorPro.exe") + +#---------------------------------------------------------------------- +#-------------------Auxilary methods-------------------------------- +#---------------------------------------------------------------------- + +def _require_aespm(origin: str) -> None: + """Raise a clear error if aespm could not be imported.""" + if not _AESPM_AVAILABLE: + tango.Except.throw_exception( + "AespmNotAvailable", + "aespm could not be imported, so the Jupiter is unreachable: " + f"{_AESPM_IMPORT_ERROR}. This device server must run on the Jupiter " + "control PC with the Asylum Research software installed.", + origin, + ) + +def _require_igor(origin: str) -> None: + """Raise a clear error if the Asylum Research software is not running. + + aespm talks to Igor by writing a command file and running SendToIgor.bat, + which ends in Popen(...).wait(). If Igor is already up, the command is handed + to the live instance and returns at once. If Igor is NOT up, that batch file + starts the application and the wait never returns, so the device server hangs + forever with no error message. Checking the process list first turns that + hang into something readable. + + If the check itself cannot run we let the call through: a broken check must + never stand in the way of a working instrument. + """ + try: + result = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], + capture_output=True, + text=True, + timeout=10, + ) + except Exception: + return + if result.returncode != 0: + return + + running = result.stdout.lower() + if any(name.lower() in running for name in _IGOR_PROCESS_NAMES): + return + + tango.Except.throw_exception( + "IgorNotRunning", + "Igor Pro (the Asylum Research software) does not appear to be running. " + "Start it before using this device: aespm would otherwise block forever " + "waiting for Igor to launch. Looked for " + + ", ".join(_IGOR_PROCESS_NAMES) + + " in the process list.", + origin, + ) + +def _read(keys: list[str]) -> list[float]: + """Read AR global variables by name in a single Igor round trip. + + read_spm builds one Igor wave for the whole list, so batching is much cheaper + than reading keys individually. np.atleast_1d guards the single-key case, + where np.loadtxt returns a 0-d array. + """ + + _require_aespm("jupiter_api._read()") + _require_igor("jupiter_api._read()") + + with _IGOR_LOCK: + values = read_spm(key=list(keys), connection=None) + values = [float(value) for value in np.atleast_1d(values)] + + unreadable = [key for key, value in zip(keys, values) if not math.isfinite(value)] + if unreadable: + tango.Except.throw_exception( + "IgorValueUnreadable", + "AR returned no finite value for: " + ", ".join(unreadable), + "jupiter_api._read()", + ) + return values + +def _write(commands: str, settle_s: float = _WRITE_SETTLE_S) -> None: + """Send one or more Igor commands (newline separated) to the AR software. + + aespm has no error channel: write_spm returns None whether Igor ran the + command or rejected it. Callers must therefore read the value back and check + it themselves - see SCAN_Jupiter._write_param. + """ + _require_aespm("jupiter_api._write()") + _require_igor("jupiter_api._write()") + + with _IGOR_LOCK: + write_spm(commands=commands, connection=None, wait=settle_s) + +def _first_text_wave_value(raw: str) -> str: + """Pull the first string out of an Igor text-wave file (what Save/T writes). + + The file looks like: IGOR / WAVES/T ReadOutText / BEGIN / "the value" / END + so the value we want is simply the first quoted line. + """ + for line in raw.splitlines(): + line = line.strip() + if len(line) >= 2 and line.startswith('"') and line.endswith('"'): + return line[1:-1] + return "" + + +def _igor_path_to_windows(path: str) -> str: + """Convert an Igor path to a Windows one. + + Igor separates folders with colons and adds a trailing one, so + 'C:Users:Asylum User:Data:' means 'C:\\Users\\Asylum User\\Data'. + Stripping stray slashes first also copes with versions that already + return a normal Windows path. + """ + parts = [part.strip("\\/") for part in path.strip().split(":")] + parts = [part for part in parts if part] + if len(parts) < 2: + return "" + return parts[0] + ":\\" + "\\".join(parts[1:]) + + +def _read_save_folder() -> Path: + """Ask Igor where the AR software is currently saving images. + + 'SaveImage' is the named path AR keeps for its save folder, and PathInfo puts + it into the Igor variable S_path. We write that into a one-element text wave + and save it to the same buffer file _read() uses for numbers. + + The buffer is cleared first for the same reason _read() checks for NaN: when + an Igor command fails the old file contents stay put, so a stale answer would + otherwise look like a fresh one. + """ + _require_aespm("jupiter_api._read_save_folder()") + _require_igor("jupiter_api._read_save_folder()") + + commands = ( + 'PathInfo $"SaveImage"\n' + "Make/T/O/N=1 ReadOutText\n" + "ReadOutText[0] = S_path\n" + f'Save/T/O ReadOutText as "{_READ_OUT_BUFFER_IGOR}"\n' + ) + + buffer = Path(_READ_OUT_BUFFER) + with _IGOR_LOCK: + buffer.write_text("", encoding="utf-8") + write_spm(commands=commands, connection=None, wait=_MOVE_SETTLE_S) + raw = buffer.read_text(encoding="utf-8", errors="replace") + + folder = _igor_path_to_windows(_first_text_wave_value(raw)) + if not folder: + tango.Except.throw_exception( + "SaveFolderUnknown", + "Igor did not report a save folder. The named path 'SaveImage' is " + "empty or undefined, which usually means image saving has never been " + "switched on in the AR GUI.", + "_read_save_folder()", + ) + return Path(folder) + +def _num(value: float) -> str: + """Format a number for an Igor command, e.g. '1e-05' or '0.4992'.""" + return f"{float(value):.10g}" + + +def _is_same_value(requested, current) -> bool: + """True if the requested value is numerically what we already have.""" + return math.isclose(float(requested), float(current), rel_tol=1e-9, abs_tol=0.0) + + + +#---------------------------------------------------------------------- +#------------------Jupiter Class-------------------------------- +#---------------------------------------------------------------------- + +class JupiterMicroscope(SPMMicroscope): + """Top-level Jupiter AFM device: vendor connection and instrument-global state.""" + + def _connect_hardware(self) -> None: + """Open the connection to the AR control software; raise on failure.""" + ... + + def _hw_get_spm_mode(self) -> SPMMode: + """Map the active AR imaging mode to the SPMMode enum.""" + ... + + def _hw_get_meter_values(self) -> dict: + """Return live photodetector signals as {'sum', 'deflection', 'lateral', 'z'}, in volts.""" + ... + + +class SCAN_Jupiter(SPM_SCAN): + """Jupiter scan device: XY piezo frame parameters, scan execution, probe positioning.""" + + def init_device(self) -> None: + # Set before the base class runs, because it calls into hardware hooks. + # Init() re-runs this, so Init() is also how you forget a stale + # calibration after an LVDT sensitivity change or an AR restart. + self._center_write_pending = False + self._scanner_offset: tuple[float, float] | None = None + super().init_device() + + #---------------------------------------------------------------------- + #-------------------Scan aquiring methods-------------------------------- + #---------------------------------------------------------------------- + def _scan_timeout_s(self) -> float: + """How long to allow for one frame before giving up. + + A frame takes roughly lines / rate seconds. Double that and add a minute, + so a slow start or a trace-and-retrace pass cannot trip the timeout. + """ + rate, lines = self._scan_rate_hz, self._scan_size_px + if not (math.isfinite(rate) and rate > 0 and lines > 0): + return 600.0 + return 2.0 * lines / rate + 60.0 + + def _wait_for_new_ibw(self, folder: Path, before: set[str], timeout_s: float) -> Path: + """Wait for the AR software to write a new .ibw into the save folder. + + 'before' is the set of filenames that were already there, so anything + else that turns up is our frame. If more than one appears, take the + newest by modification time. + """ + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + new = {path.name for path in folder.glob("*.ibw")} - before + if new: + return max((folder / name for name in new), key=lambda p: p.stat().st_mtime) + time.sleep(_POLL_INTERVAL_S) + + tango.Except.throw_exception( + "ScanTimedOut", + f"No new .ibw appeared in {str(folder)!r} within {timeout_s:.0f} s. " + "Check that image saving is switched on in the AR GUI.", + "_hw_acquire_scan()", + ) + + # auxilary + def _write_param(self, name: str, value) -> None: + """Write one scan parameter and confirm the instrument actually took it. + + aespm cannot report a rejected command, so a wrong control name looks + exactly like a successful write. The base class already re-reads every + parameter after the write, so comparing before with after costs nothing: + if we asked for a different value and nothing moved at all, Igor ignored + us. A value the hardware merely rounds (257 -> 256 pixels) does move, so + legitimate coercion does not trigger this. + """ + before = getattr(self, f"_{name}") + super()._write_param(name, value) + after = getattr(self, f"_{name}") + + if after == before and not _is_same_value(value, before): + tango.Except.throw_exception( + "ScanParameterWriteIgnored", + f"Asked to set {name} to {value}, but it is still {after}. " + "The AR software probably rejected the command " + f"'{_SCAN_WRITE_COMMANDS[name]}'.", + "_write_param()", + ) + + def _hw_read_scan_params(self) -> dict: + """Read all scan parameters from AR; keys match the attribute names.""" + names = list(_SCAN_PARAM_KEYS) + values = _read([_SCAN_PARAM_KEYS[name] for name in names]) + params = dict(zip(names, values)) + params["scan_size_px"] = int(round(params["scan_size_px"])) + return params + + def _hw_write_scan_param(self, name: str, value) -> None: + """Push one scan parameter to AR. The caller re-reads and checks it.""" + command = _SCAN_WRITE_COMMANDS.get(name) + if command is None: + tango.Except.throw_exception( + "UnknownScanParameter", + f"No Igor command is defined for '{name}'.", + "_hw_write_scan_param()", + ) + + # Pixels go into an integer control; everything else is a float. + if name == "scan_size_px": + text = str(int(round(float(value)))) + else: + text = _num(value) + + if name in ("x_scan_center_m", "y_scan_center_m"): + # AR takes the new offset immediately, but the scanner only adopts it + # on the next scan, so "go there" would still park at the old centre. + self._center_write_pending = True + + _write(command.format(value=text)) + + def _hw_acquire_scan(self) -> str: + """Run one frame with current settings; return the file AR wrote. + + The save folder is read from Igor every time rather than configured, so + it always matches what the AR GUI is actually doing. + """ + folder = _read_save_folder() + before = {path.name for path in folder.glob("*.ibw")} + + _write(_START_SCAN_COMMAND) + path = self._wait_for_new_ibw(folder, before, self._scan_timeout_s()) + + # Running a scan is what makes a pending scan centre real, so the + # calibration guard can be cleared now. + self._center_write_pending = False + self.info_stream(f"Scan saved as {path}") + return str(path) + + def _hw_stop_scan(self) -> None: + """Abort the running scan immediately.""" + _write(_STOP_SCAN_COMMAND) + + # ------------------------------------------------------------------ + # Probe positioning + # + # The probe is positioned by the closed-loop X/Y setpoints, which are + # voltages in the scanner's own frame: + # + # x_scanner = setpoint_volts * XLVDTSens [meters] + # + # That frame is shifted from the scan frame by a constant the instrument + # does not report: + # + # x_scanner = x_scan + scanner_offset_x + # ------------------------------------------------------------------ + def _read_probe(self) -> tuple[float, float, float, float, float, float]: + """One round trip: setpoints (V), sensitivities (m/V), scan centre (m). + + Refuses a rotated frame - the closed loops drive the scanner axes, not + the rotated scan frame. + """ + vx, vy, sx, sy, xo, yo, angle = _read(_PROBE_KEYS) + if sx == 0.0 or sy == 0.0: + tango.Except.throw_exception( + "SensitivityUnavailable", + f"AR reported an LVDT sensitivity of zero (x={sx}, y={sy}).", + "_read_probe()", + ) + if abs(angle) > 1e-6: + tango.Except.throw_exception( + "ScanFrameRotated", + f"ScanAngle is {angle:g} deg; probe moves require ScanAngle = 0.", + "_read_probe()", + ) + return vx, vy, sx, sy, xo, yo + + def _measure_scanner_offset( + self, sx: float, sy: float, xo: float, yo: float + ) -> tuple[float, float]: + """Park the tip at the scan centre and measure the frame offset. + + This physically moves the tip. The centre is the one point whose + scan-frame coordinate we already know, so it is the only place the + offset can be measured. + """ + + if self._center_write_pending: + tango.Except.throw_exception( + "ScanCenterNotApplied", + "The scan centre was changed and no scan has run since, so the " + "scanner is still using the old centre and the measured offset " + "would be wrong by the difference. Run a scan first, or calibrate " + "before moving the frame. Init() clears this.", + "_measure_scanner_offset()", + ) + + _write(_CLEAR_FORCE_COMMAND) + _write(_GO_TO_CENTER_COMMAND, settle_s=_MOVE_SETTLE_S) + time.sleep(_MOVE_SETTLE_S) # let the tip arrive before reading + + vx, vy = _read(_PROBE_KEYS)[:2] + offset = (vx * sx - xo, vy * sy - yo) + self.info_stream(f"Scanner offset measured: {offset[0]:e}, {offset[1]:e} m") + return offset + + @tango.server.command(dtype_out=tango.DevVarDoubleArray) + def calibrate_probe_frame(self) -> list[float]: + """Measure the scan-frame to scanner offset. Parks the tip at the centre. + + Returns [scanner_offset_x_m, scanner_offset_y_m]. + """ + _, _, sx, sy, xo, yo = self._read_probe() + self._scanner_offset = self._measure_scanner_offset(sx, sy, xo, yo) + return list(self._scanner_offset) + + def _hw_read_probe_position(self) -> list[float]: + """Return the probe position [x, y] in scan-frame meters.""" + if self._scanner_offset is None: + tango.Except.throw_exception( + "ProbeFrameNotMeasured", + "The scan-frame to scanner offset is not known yet. Call " + "calibrate_probe_frame(), or move the probe once - the first move " + "measures it by parking the tip at the frame centre.", + "_hw_read_probe_position()", + ) + vx, vy, sx, sy, _, _ = self._read_probe() + offset_x, offset_y = self._scanner_offset + return [vx * sx - offset_x, vy * sy - offset_y] + + def _hw_move_probe(self, x: float, y: float) -> None: + """Move the probe to (x, y) in scan-frame meters. + + The first move parks at the frame centre to measure the offset, then + continues to the target. Needs the X/Y PID loops engaged. + """ + _, _, sx, sy, xo, yo = self._read_probe() + if self._scanner_offset is None: + self._scanner_offset = self._measure_scanner_offset(sx, sy, xo, yo) + offset_x, offset_y = self._scanner_offset + + _write( + f'td_WriteValue("PIDSLoop.0.Setpoint",{_num((x + offset_x) / sx)})\n' + f'td_WriteValue("PIDSLoop.1.Setpoint",{_num((y + offset_y) / sy)})\n', + settle_s=_MOVE_SETTLE_S, + ) + + +class FEEDBACK_Jupiter(SPM_FEEDBACK): + """Jupiter Z-feedback device: setpoint, gain, engage/disengage.""" + + def _hw_read_feedback_params(self) -> dict: + """Read all feedback parameters from AR; keys must match the attribute + names (setpoint, i_gain).""" + ... + + def _hw_write_feedback_param(self, name: str, value) -> None: + """Push one feedback parameter to AR (name as in _hw_read_feedback_params).""" + ... + + def _hw_feedback_on(self) -> None: + """Engage the Z feedback loop.""" + ... + + def _hw_feedback_off(self) -> None: + """Disengage the Z feedback loop.""" + ... + + def _hw_is_feedback_on(self) -> bool: + """Return True if the Z feedback loop is currently engaged (read live).""" + ... + + +class APPROACH_Jupiter(SPM_APPROACH): + """Jupiter approach device: tip engage / retract sequence.""" + + def _hw_approach(self) -> None: + """Run the AR approach sequence; block until the tip is engaged.""" + ... + + def _hw_retract(self) -> None: + """Retract the tip; block until clear of the surface.""" + ... + + def _hw_stop(self) -> None: + """Abort any running approach/retract motion immediately.""" + ... + + def _hw_is_approached(self) -> bool: + """Return True if the tip is currently engaged (read live).""" + ... + + +class STAGE_Jupiter(SPM_STAGE): + """Jupiter coarse XY stage device.""" + + def _hw_read_stage_position(self) -> list[float]: + """Return the current stage position [x, y] in meters.""" + ... + + def _hw_move_stage_relative(self, dx: float, dy: float) -> None: + """Move the stage by (dx, dy) in meters; block until the move completes.""" + ... + + def _hw_stop(self) -> None: + """Abort any running stage motion immediately.""" + ... + +# ---------------------------------------------------------------------- +# Server entry point +# ---------------------------------------------------------------------- +# run_servers.py starts one process per device with +# `python -m ...jupiter_api _instance`, and Device.run_server() uses the +# class name as the Tango server name, so the class is selected here from the +# instance name the process was launched with. +_DEVICE_CLASSES = { + "instrument": JupiterMicroscope, + "scan": SCAN_Jupiter, + "feedback": FEEDBACK_Jupiter, + "approach": APPROACH_Jupiter, + "stage": STAGE_Jupiter, +} + +if __name__ == "__main__": + import sys + + instance = sys.argv[1] if len(sys.argv) > 1 else "" + key = instance.rsplit("_instance", 1)[0] + device_class = _DEVICE_CLASSES.get(key) + if device_class is None: + raise SystemExit( + f"Cannot pick a device class from instance name {instance!r}. " + f"Expected one of: {', '.join(f'{k}_instance' for k in _DEVICE_CLASSES)}" + ) + device_class.run_server() \ No newline at end of file diff --git a/asyncroscopy/instruments/scanning_probe_microscope/jupyter_api.py b/asyncroscopy/instruments/scanning_probe_microscope/jupyter_api.py deleted file mode 100644 index 8b137891..00000000 --- a/asyncroscopy/instruments/scanning_probe_microscope/jupyter_api.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/asyncroscopy/instruments/scanning_probe_microscope/notebooks/0_jupiter_scan_test.ipynb b/asyncroscopy/instruments/scanning_probe_microscope/notebooks/0_jupiter_scan_test.ipynb new file mode 100644 index 00000000..a5392099 --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/notebooks/0_jupiter_scan_test.ipynb @@ -0,0 +1,357 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "id": "e911a33c", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "os.environ[\"TANGO_HOST\"] = \"localhost:9094\" # must be set before importing tango\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8176c376", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "State : ON\n", + "Status: Idle.\n" + ] + } + ], + "source": [ + "import tango\n", + "scan = tango.DeviceProxy(\"asyncroscopy/scan/default\")\n", + "print(\"State :\", scan.State())\n", + "print(\"Status:\", scan.status())" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "53096da9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['Init',\n", + " 'State',\n", + " 'Status',\n", + " '__attr_cache',\n", + " '__class__',\n", + " '__cmd_cache',\n", + " '__command_inout',\n", + " '__command_inout_asynch_cb',\n", + " '__command_inout_asynch_id',\n", + " '__contains__',\n", + " '__delattr__',\n", + " '__dict__',\n", + " '__dir__',\n", + " '__doc__',\n", + " '__eq__',\n", + " '__format__',\n", + " '__ge__',\n", + " '__get_attr_cache',\n", + " '__get_attr_conf_events',\n", + " '__get_callback_events',\n", + " '__get_cmd_cache',\n", + " '__get_data_events',\n", + " '__get_data_ready_events',\n", + " '__get_devintr_change_events',\n", + " '__get_event_map',\n", + " '__get_event_map_lock',\n", + " '__getattr__',\n", + " '__getattribute__',\n", + " '__getitem__',\n", + " '__getstate__',\n", + " '__gt__',\n", + " '__hash__',\n", + " '__init__',\n", + " '__init_orig__',\n", + " '__init_subclass__',\n", + " '__le__',\n", + " '__lt__',\n", + " '__module__',\n", + " '__ne__',\n", + " '__new__',\n", + " '__read_attributes_asynch',\n", + " '__read_attributes_reply',\n", + " '__reduce__',\n", + " '__reduce_ex__',\n", + " '__refresh_attr_cache',\n", + " '__refresh_cmd_cache',\n", + " '__repr__',\n", + " '__setattr__',\n", + " '__setitem__',\n", + " '__setstate__',\n", + " '__sizeof__',\n", + " '__str__',\n", + " '__subclasshook__',\n", + " '__subscribe_event_attrib_with_stateless_flag',\n", + " '__subscribe_event_attrib_with_sub_mode',\n", + " '__subscribe_event_global_with_stateless_flag',\n", + " '__subscribe_event_global_with_sub_mode',\n", + " '__unsubscribe_event',\n", + " '__unsubscribe_event_all',\n", + " '__write_attributes_asynch',\n", + " '__write_attributes_reply',\n", + " '_delete_property',\n", + " '_dev_info',\n", + " '_dynamic_interface_frozen',\n", + " '_executors',\n", + " '_get_attribute_config',\n", + " '_get_attribute_config_ex',\n", + " '_get_command_config',\n", + " '_get_info_',\n", + " '_get_property',\n", + " '_get_property_list',\n", + " '_green_mode',\n", + " '_initialized',\n", + " '_pending_unsubscribe',\n", + " '_ping',\n", + " '_put_property',\n", + " '_pybind11_conduit_v1_',\n", + " '_read_attribute',\n", + " '_read_attributes',\n", + " '_set_attribute_config',\n", + " '_state',\n", + " '_status',\n", + " '_write_attribute',\n", + " '_write_attributes',\n", + " '_write_read_attribute',\n", + " '_write_read_attributes',\n", + " 'acquire_scan',\n", + " 'add_logging_target',\n", + " 'adm_name',\n", + " 'alias',\n", + " 'attribute_history',\n", + " 'attribute_list_query',\n", + " 'attribute_list_query_ex',\n", + " 'attribute_query',\n", + " 'black_box',\n", + " 'cancel_all_polling_asynch_request',\n", + " 'cancel_asynch_request',\n", + " 'command_history',\n", + " 'command_inout',\n", + " 'command_inout_asynch',\n", + " 'command_inout_raw',\n", + " 'command_inout_reply',\n", + " 'command_inout_reply_raw',\n", + " 'command_list_query',\n", + " 'command_query',\n", + " 'connect',\n", + " 'defaultCommandExtractAs',\n", + " 'delete_property',\n", + " 'description',\n", + " 'dev_name',\n", + " 'event_queue_size',\n", + " 'freeze_dynamic_interface',\n", + " 'get_access_control',\n", + " 'get_access_right',\n", + " 'get_asynch_replies',\n", + " 'get_attribute_config',\n", + " 'get_attribute_config_ex',\n", + " 'get_attribute_list',\n", + " 'get_attribute_poll_period',\n", + " 'get_command_config',\n", + " 'get_command_list',\n", + " 'get_command_poll_period',\n", + " 'get_db_host',\n", + " 'get_db_port',\n", + " 'get_db_port_num',\n", + " 'get_dev_host',\n", + " 'get_dev_port',\n", + " 'get_device_db',\n", + " 'get_events',\n", + " 'get_fqdn',\n", + " 'get_from_env_var',\n", + " 'get_green_mode',\n", + " 'get_idl_version',\n", + " 'get_last_event_date',\n", + " 'get_locker',\n", + " 'get_logging_level',\n", + " 'get_logging_target',\n", + " 'get_property',\n", + " 'get_property_list',\n", + " 'get_source',\n", + " 'get_tango_lib_version',\n", + " 'get_timeout_millis',\n", + " 'get_transparency_reconnection',\n", + " 'import_info',\n", + " 'info',\n", + " 'init',\n", + " 'is_attribute_polled',\n", + " 'is_command_polled',\n", + " 'is_dbase_used',\n", + " 'is_dynamic_interface_frozen',\n", + " 'is_event_queue_empty',\n", + " 'is_locked',\n", + " 'is_locked_by_me',\n", + " 'lock',\n", + " 'locking_status',\n", + " 'move_probe',\n", + " 'name',\n", + " 'pending_asynch_call',\n", + " 'ping',\n", + " 'poll_attribute',\n", + " 'poll_command',\n", + " 'polling_status',\n", + " 'probe_x_m',\n", + " 'probe_y_m',\n", + " 'put_property',\n", + " 'read_attribute',\n", + " 'read_attribute_asynch',\n", + " 'read_attribute_reply',\n", + " 'read_attributes',\n", + " 'read_attributes_asynch',\n", + " 'read_attributes_reply',\n", + " 'reconnect',\n", + " 'refresh_params',\n", + " 'remove_logging_target',\n", + " 'scan_angle_deg',\n", + " 'scan_rate_hz',\n", + " 'scan_size_m',\n", + " 'scan_size_px',\n", + " 'set_access_control',\n", + " 'set_attribute_config',\n", + " 'set_green_mode',\n", + " 'set_logging_level',\n", + " 'set_source',\n", + " 'set_timeout_millis',\n", + " 'set_transparency_reconnection',\n", + " 'state',\n", + " 'status',\n", + " 'stop_poll_attribute',\n", + " 'stop_poll_command',\n", + " 'stop_scan',\n", + " 'subscribe_event',\n", + " 'unfreeze_dynamic_interface',\n", + " 'unlock',\n", + " 'unsubscribe_event',\n", + " 'write_attribute',\n", + " 'write_attribute_asynch',\n", + " 'write_attribute_reply',\n", + " 'write_attributes',\n", + " 'write_attributes_asynch',\n", + " 'write_attributes_reply',\n", + " 'write_read_attribute',\n", + " 'write_read_attributes',\n", + " 'x_scan_center_m',\n", + " 'y_scan_center_m']" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dir(scan)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "33f1253c", + "metadata": {}, + "outputs": [], + "source": [ + "def scan_attributes(device):\n", + " \"\"\"Every attribute the device advertises, minus the built-in State/Status.\"\"\"\n", + " return [a for a in device.attribute_list_query() if a.name not in (\"State\", \"Status\")]\n", + "\n", + "def read_all(device, show=True):\n", + " \"\"\"Read every advertised attribute in one Tango call.\n", + "\n", + " read_attributes does not raise when individual attributes fail — each result\n", + " carries its own has_failed — so one unimplemented _hw_ hook does not hide\n", + " the values that did come back.\n", + " \"\"\"\n", + " infos = scan_attributes(device)\n", + " names = [a.name for a in infos]\n", + " results = device.read_attributes(names)\n", + " width = max(len(n) for n in names)\n", + "\n", + " values = {}\n", + " for a, r in zip(infos, results):\n", + " if r.has_failed:\n", + " values[a.name] = None\n", + " if show:\n", + " print(f\" {a.name:<{width}} FAILED {r.get_err_stack()[0].desc.splitlines()[0]}\")\n", + " else:\n", + " values[a.name] = r.value\n", + " if show:\n", + " unit = \"\" if a.unit in (\"\", \"No unit\") else a.unit\n", + " print(f\" {a.name:<{width}} {r.value!r:>14} {unit}\")\n", + " return values" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "2ae577b2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "State: ON\n", + " x_scan_center_m 0.0 m\n", + " y_scan_center_m 0.0 m\n", + " scan_size_m 9.9999997e-06 m\n", + " scan_size_px 256 px\n", + " scan_angle_deg 0.0 deg\n", + " scan_rate_hz 0.49920127 Hz\n", + " probe_x_m FAILED TypeError: 'NoneType' object is not subscriptable\n", + " probe_y_m FAILED TypeError: 'NoneType' object is not subscriptable\n" + ] + } + ], + "source": [ + "print(\"State:\", scan.State())\n", + "values = read_all(scan)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "82f34deb", + "metadata": {}, + "outputs": [], + "source": [ + "scan.refresh_params() " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "asyncroscopy (3.12.x)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py index 8b137891..503fa0e5 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py @@ -1 +1,260 @@ +""" +Scanning probe microscope Tango device. +Thin orchestrator over the SPM sub-devices (SCAN, FEEDBACK, APPROACH, +STAGE, SPECTROSCOPY). Each sub-device owns its own parameters and its +vendor-specific behaviour; this device only wires them together and +exposes cross-device workflows and instrument-global state. + +Return convention for acquisition commands +------------------------------------------ +Acquisition commands return a string supplied by the sub-device, +typically a DATA/Tiled unique id. +""" + +import enum +import json +from abc import abstractmethod + +import tango + +from asyncroscopy.instruments.instrument import Instrument + +class SPMMode(enum.IntEnum): + CONTACT_AFM = 0 + NON_CONTACT_AFM = 1 + KPFM = 2 + EFM = 3 + CONDUCTIVE_AFM = 4 + SF_PFM = 5 + DART = 6 + ESM = 7 + MFM = 8 + THERMAL = 9 + AFM_IR = 10 + TERS = 11 + SNOM = 12 + +class SPMMicroscope(Instrument): + """ + Top-level scanning probe microscope device. + + Single-subsystem actions are delegated to the sub-devices via + DeviceProxy; only instrument-global state (spm_mode, meter values) + is implemented by the concrete vendor subclass. + """ + + # ------------------------------------------------------------------ + # Sub-device addresses — configure in Tango DB per deployment + # ------------------------------------------------------------------ + + scan_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the SCAN device. " + "DB mode: 'asyncroscopy/scan/default' " + "No-DB mode: 'tango://127.0.0.1:8888/asyncroscopy/scan/default#dbase=no'", + ) + + feedback_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the FEEDBACK device." + "DB mode: 'asyncroscopy/feedback/default' " + "No-DB mode: 'tango://127.0.0.1:8888/asyncroscopy/feedback/default#dbase=no'", + ) + + approach_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the APPROACH device." + "DB mode: 'asyncroscopy/approach/default' " + "No-DB mode: 'tango://127.0.0.1:8888/asyncroscopy/approach/default#dbase=no'", + ) + + stage_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the STAGE device." + "DB mode: 'asyncroscopy/stage/default' " + "No-DB mode: 'tango://127.0.0.1:8888/asyncroscopy/stage/default#dbase=no'", + ) + + spectroscopy_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the SPECTROSCOPY device." + "DB mode: 'asyncroscopy/spectroscopy/default' " + "No-DB mode: 'tango://127.0.0.1:8888/asyncroscopy/spectroscopy/default#dbase=no'", + ) + + # ------------------------------------------------------------------ + # Attributes + # ------------------------------------------------------------------ + + spm_mode = tango.server.attribute( + label="SPM Mode", + dtype=SPMMode, + access=tango.AttrWriteType.READ, + doc="Active SPM operating mode.", + ) + + # ------------------------------------------------------------------ + # Initialization + # ------------------------------------------------------------------ + + def _init_device_attributes(self) -> None: + self._device_proxies: dict[str, tango.DeviceProxy] = {} + + def read_instrument_type(self) -> str: + return 'SPM' + + def read_spm_mode(self) -> SPMMode: + return self._hw_get_spm_mode() + + def _connect(self): + self._connect_hardware() + self._connect_device_proxies() + self.set_state(tango.DevState.ON) + + def _connect_device_proxies(self) -> None: + addresses = { + 'scan': self.scan_device_address, + 'feedback': self.feedback_device_address, + 'approach': self.approach_device_address, + 'stage': self.stage_device_address, + 'spectroscopy': self.spectroscopy_device_address, + } + for name, address in addresses.items(): + if address: + self._device_proxies[name] = tango.DeviceProxy(address) + self.info_stream(f'Connected proxy {name} -> {address}') + + def _disconnect(self): + self._device_proxies = {} + self.info_stream('Disconnected from sub-devices') + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_proxy(self, name: str) -> tango.DeviceProxy: + """Return the sub-device proxy or raise a clear DevFailed.""" + proxy = self._device_proxies.get(name) + if proxy is None: + tango.Except.throw_exception( + 'DeviceNotConfigured', + f"No '{name}' device is configured. " + f"Set {name}_device_address in the Tango DB / config yaml.", + f'{name}', + ) + try: + proxy.ping() # type: ignore + except tango.DevFailed: + tango.Except.throw_exception( + 'DeviceNotAccessible', + f"The '{name}' device at '{proxy.dev_name()}' is not responding. " # type: ignore + f"Check that its server is running.", + f'{name}', + ) + return proxy + + # ------------------------------------------------------------------ + # Commands — instrument-global + # ------------------------------------------------------------------ + + @tango.server.command(dtype_out=str) + def get_microscope_state(self) -> str: + """Aggregate instrument-global state and sub-device states as JSON.""" + devices = {} + for name in ('scan', 'feedback', 'approach', 'stage', 'spectroscopy'): + proxy = self._device_proxies.get(name) + if proxy is None: + devices[name] = 'NOT_CONFIGURED' + else: + try: + devices[name] = str(proxy.State()) + except tango.DevFailed: + devices[name] = 'UNREACHABLE' + state = {'spm_mode': self.read_spm_mode().name, 'devices': devices} + return json.dumps(state) + + + @tango.server.command(dtype_out=str) #the only direct function + def get_meter_values(self) -> str: + """Read current meter values (Sum, Deflection, Lateral, Z) as JSON.""" + return json.dumps(self._hw_get_meter_values()) + + # ------------------------------------------------------------------ + # Commands — delegators to sub-devices + # ------------------------------------------------------------------ + @tango.server.command(dtype_out=str) + def acquire_scan(self) -> str: + """Acquire a scan using SCAN device settings; returns a DATA/Tiled uid.""" + return self._get_proxy('scan').acquire_scan() + + @tango.server.command(dtype_out=str) + def acquire_spectrum(self) -> str: + """Acquire a spectrum using SPECTROSCOPY device settings; returns a DATA/Tiled uid.""" + return self._get_proxy('spectroscopy').acquire_spectrum() + + @tango.server.command(dtype_out=tango.DevBoolean) + def approach(self) -> bool: + """Approach the tip to the surface; returns True if approached.""" + proxy = self._get_proxy('approach') + proxy.approach() + return proxy.approached + + @tango.server.command(dtype_out=tango.DevBoolean) + def retract(self) -> bool: + """Retract the tip from the surface; returns True if still approached.""" + proxy = self._get_proxy('approach') + proxy.retract() + return proxy.approached + + @tango.server.command(dtype_out=tango.DevBoolean) + def feedback_on(self) -> bool: + """Engage the Z feedback loop; returns True if feedback loop is active.""" + proxy = self._get_proxy('feedback') + proxy.feedback_on() + return proxy.feedback_on_bool + + @tango.server.command(dtype_out=tango.DevBoolean) + def feedback_off(self) -> bool: + """Disengage the Z feedback loop; returns True if feedback loop is still active.""" + proxy = self._get_proxy('feedback') + proxy.feedback_off() + return proxy.feedback_on_bool + + @tango.server.command(dtype_in=tango.DevDouble, dtype_out=tango.DevDouble) + def set_setpoint(self, setpoint: float) -> float: + """Set the feedback setpoint; returns the value read back as float.""" + proxy = self._get_proxy('feedback') + proxy.setpoint = setpoint + return proxy.setpoint + + @tango.server.command(dtype_in=tango.DevVarDoubleArray, dtype_out=tango.DevVarDoubleArray) + def move_stage(self, position) -> list[float]: + """Move the stage to an absolute position. + + :param position: [stage_x_m, stage_y_m] — absolute target in meters. + Returns the final position [stage_x_m, stage_y_m] in meters. + """ + proxy = self._get_proxy('stage') + proxy.move_stage(position) + return [proxy.stage_x_m, proxy.stage_y_m] + + @tango.server.command(dtype_in=tango.DevVarDoubleArray, dtype_out=tango.DevVarDoubleArray) + def move_probe(self, position) -> list[float]: + """Move the probe to an absolute position; returns the final position [x, y] in meters.""" + proxy = self._get_proxy('scan') + proxy.move_probe(position) + return [proxy.probe_x_m, proxy.probe_y_m] + + # ------------------------------------------------------------------ + # Abstract methods — vendor-specific + # ------------------------------------------------------------------ + @abstractmethod + def _hw_get_spm_mode(self) -> SPMMode: + """Return the active SPM operating mode.""" + pass + + @abstractmethod + def _hw_get_meter_values(self) -> dict: + """Return current meter values with keys 'sum', 'deflection', 'lateral', 'z'.""" + pass \ No newline at end of file diff --git a/configs/Jupiter-local.yaml b/configs/Jupiter-local.yaml new file mode 100644 index 00000000..0c7b8106 --- /dev/null +++ b/configs/Jupiter-local.yaml @@ -0,0 +1,26 @@ +# Everything on the Jupiter control PC: Tango DB, device servers and notebook. +instrument: + class_name: JupiterMicroscope + file: asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py + description: "Asylum Research Jupiter AFM (local)" + timeout_seconds: 120 + +devices: + scan: + class_name: SCAN_Jupiter + module_name: asyncroscopy.instruments.scanning_probe_microscope.jupiter_api + +tango: + host: localhost + port: 9094 + reset_database_file: true + +# Required section, but autostart:false means DATA and Tiled are never touched. +tiled: + host: localhost + port: 9091 + acquisition_dir: outputs/tiled_acquisitions + autostart: false + register_on_startup: false + +device_timeout_seconds: 120 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7490a285..314cb1d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,10 @@ agent = [ "langchain-mcp-adapters", ] +jupiter = [ + "aespm>=1.1.4", +] + ollama = [ "langchain_ollama" ] @@ -77,4 +81,9 @@ autoscript-core = { path = "stubs/AutoScript_v_1.17/autoscript_core-1.3.0-py3-no autoscript-tem-microscope-client = { path = "stubs/AutoScript_v_1.17/autoscript_tem_microscope_client-1.17.0-py3-none-any.whl" } autoscript-tem-toolkit = { path = "stubs/AutoScript_v_1.17/autoscript_tem_toolkit-1.17.0-py3-none-any.whl" } thermoscientific-logging = { path = "stubs/AutoScript_v_1.17/thermoscientific_logging-1.3.0-py3-none-any.whl" } -pyjem = { path = "stubs/PyJEM_v_1.3.0.3564/PyJEM-1.3.0.3564-py3-none-any.whl" } \ No newline at end of file +pyjem = { path = "stubs/PyJEM_v_1.3.0.3564/PyJEM-1.3.0.3564-py3-none-any.whl" } + +[tool.uv] +override-dependencies = [ + { package = { name = "aespm" }, dependencies = ["numpy>=2.3.5"] }, +] \ No newline at end of file