From 047c225526cecd3fe46166814cc07817cb730af2 Mon Sep 17 00:00:00 2001 From: Slautin Date: Mon, 29 Jun 2026 14:51:32 -0400 Subject: [PATCH 1/9] SPMMicroscope_class_basic_setup0 --- .../{jupyter_api.py => jupiter_api.py} | 0 .../scanning_probe_microscope.py | 212 ++++++++++++++++++ 2 files changed, 212 insertions(+) rename asyncroscopy/instruments/scanning_probe_microscope/{jupyter_api.py => jupiter_api.py} (100%) diff --git a/asyncroscopy/instruments/scanning_probe_microscope/jupyter_api.py b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py similarity index 100% rename from asyncroscopy/instruments/scanning_probe_microscope/jupyter_api.py rename to asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py diff --git a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py index 8b137891..2c8843e7 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py @@ -1 +1,213 @@ +""" +Scanning Probe Microscope Tango device. + +Sub-Devices settings are read from the corresponding DeviceProxy +so that each device is the single source of truth for its own params. + +Image commands return a string supplied by the concrete microscope +implementation, typically a DATA/Tiled unique id. +""" + +import json +from abc import abstractmethod +from typing import Optional + +import tango + +# from tango import AttrWriteType, DevEncoded, DevFloat, DevState, DevVarFloatArray, DevVarStringArray +# from tango.server import attribute, command, device_property + +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. + """ + + # ------------------------------------------------------------------ + # SPM subDevices — configure in Tango DB per deployment + # ------------------------------------------------------------------ + + scan_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the SCAN settings device. " + "DB mode: 'test/scan' " + "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/scan#dbase=no'", + ) + + approach_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the APPROACH settings device. " + "DB mode: 'test/approach' " + "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/approach#dbase=no'", + ) + + feedback_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the FEEDBACK settings device. " + "DB mode: 'test/feedback' " + "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/feedback#dbase=no'", + ) + + piezostage_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the PIEZOSTAGE settings device. " + "DB mode: 'test/piezostage' " + "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/piezostage#dbase=no'", + ) + + stage_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the STAGE settings device. " + "DB mode: 'test/stage' " + "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/stage#dbase=no'", + ) + + spectroscopy_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the SPECTROSCOPY settings device. " + "DB mode: 'test/spectroscopy' " + "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/spectroscopy#dbase=no'", + ) + + camera_device_address = tango.server.device_property( + dtype=str, + doc="Tango device address for the CAMERA settings device. " + "DB mode: 'test/camera' " + "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/camera#dbase=no'", + ) + + # ------------------------------------------------------------------ + # SPM Attributes + # ------------------------------------------------------------------ + + spm_mode = tango.server.attribute( + label="SPM Mode", + dtype=SPMMode, + access=tango.AttrWriteType.READ, + doc="Returns active SPM mode", + ) + + # ------------------------------------------------------------------ + # SPM-level Methods + # ------------------------------------------------------------------ + + def _init_device_attributes(self) -> None: + pass + + def read_instrument_type(self) -> str: + return 'SPM' + + def read_spm_mode(self) -> SPMMode: + return self._get_spm_mode() + + def _disconnect(self): + self._microscope = None + self.info_stream('Disconnected from microscope hardware') + + + # ------------------------------------------------------------------ + # SPM Commands + # ------------------------------------------------------------------ + + #spm level + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def get_microscope_state(self,) -> str: + #TODO + #get active spm mode + spm_mode = self.read_spm_mode() + + state = { + "spm_mode": spm_mode.name + } + + return json.dumps(state) + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def get_meter_values(self,) -> str: + """Read current meter values: Sum, Defl, Lat, etc.""" + #TODO + pass + return "" + + #high_level + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def acquire_scan(self,) -> str: + pass #TODO + return "" + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def acquire_spectrum(self,) -> str: + pass #TODO + return "" + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def move_probe(self,) -> str: + pass #TODO + return "" + + #stage + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def move_stage(self,) -> str: + pass #TODO + return "" + + #feedback_control + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def feedback_on(self,) -> str: + pass #TODO + return "" + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def feedback_off(self,) -> str: + pass #TODO + return "" + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def set_set_point(self,) -> str: + pass #TODO + return "" + + #approach + + @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) + def approach(self,) -> str: + pass #TODO + return "" + + + # ------------------------------------------------------------------ + # SPM Abstract Methods + # ------------------------------------------------------------------ + + @abstractmethod + def _connect(self): + pass + + @abstractmethod + def _get_spm_mode(self)-> SPMMode: + """Get active spm mode""" + pass + +if __name__ == '__main__': + SPMMicroscope.run_server() From e6122358a4862c6a4cbd8ef6a6a6393b0f25aa7a Mon Sep 17 00:00:00 2001 From: Slautin Date: Wed, 8 Jul 2026 16:55:56 -0400 Subject: [PATCH 2/9] SPMMicroscope&Scandevice --- .../hardware/scan.py | 239 ++++++++++++++ .../scanning_probe_microscope.py | 299 ++++++++++-------- 2 files changed, 401 insertions(+), 137 deletions(-) create mode 100644 asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py new file mode 100644 index 00000000..14b1d9f3 --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py @@ -0,0 +1,239 @@ +""" +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 +# from tango import AttrWriteType, DevState, DevVarFloatArray +# from tango.server import attribute, command, device_property + +from asyncroscopy.data.data_writer import save_acquisition +from asyncroscopy.instruments.instrument import CombinedMeta + +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.", + ) + + # ------------------------------------------------------------------ + # 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 + ) + self._refresh_params() + self.set_state(tango.DevState.ON) + self.info_stream("SPM SCAN device initialised") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _refresh_params(self) -> None: + """Re-read all scan parameters from hardware into local fields.""" + params = self._hw_read_scan_params() + 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"] + + 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, + } + + # ------------------------------------------------------------------ + # 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) + + # ------------------------------------------------------------------ + # 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(tango.DevState.RUNNING) + try: + channel_names, data = self._hw_acquire_scan() + finally: + self.set_state(tango.DevState.ON) + return save_acquisition( + self, self._data_proxy, "spm_scan", channel_names, data, + dataset_attrs=[self._scan_metadata()] * len(channel_names), + ) + + @tango.server.command + def stop_scan(self) -> None: + """Stop a running scan.""" + self._hw_stop_scan() + self.set_state(tango.DevState.ON) + + @tango.server.command + def refresh_params(self) -> None: + """Re-read all scan parameters from hardware, e.g. after changes in the vendor GUI.""" + self._refresh_params() + + # ------------------------------------------------------------------ + # 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) -> tuple[list[str], list]: + """Run one scan; return (channel_names, list of 2D numpy arrays).""" + pass + + @abstractmethod + def _hw_stop_scan(self) -> None: + """Abort the running scan.""" + 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/scanning_probe_microscope.py b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py index 2c8843e7..6347efcf 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py @@ -1,114 +1,99 @@ """ -Scanning Probe Microscope Tango device. +Scanning probe microscope Tango device. -Sub-Devices settings are read from the corresponding DeviceProxy -so that each device is the single source of truth for its own params. +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. -Image commands return a string supplied by the concrete microscope -implementation, typically a DATA/Tiled unique id. +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 -from typing import Optional import tango - -# from tango import AttrWriteType, DevEncoded, DevFloat, DevState, DevVarFloatArray, DevVarStringArray +# from tango import AttrWriteType, DevFloat, DevVarFloatArray # from tango.server import attribute, command, device_property 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 + 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. """ # ------------------------------------------------------------------ - # SPM subDevices — configure in Tango DB per deployment + # 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 settings device. " - "DB mode: 'test/scan' " - "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/scan#dbase=no'", + 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'", ) - - approach_device_address = tango.server.device_property( - dtype=str, - doc="Tango device address for the APPROACH settings device. " - "DB mode: 'test/approach' " - "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/approach#dbase=no'", - ) - + feedback_device_address = tango.server.device_property( dtype=str, - doc="Tango device address for the FEEDBACK settings device. " - "DB mode: 'test/feedback' " - "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/feedback#dbase=no'", + doc="Tango device address for the FEEDBACK device.", ) - piezostage_device_address = tango.server.device_property( + approach_device_address = tango.server.device_property( dtype=str, - doc="Tango device address for the PIEZOSTAGE settings device. " - "DB mode: 'test/piezostage' " - "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/piezostage#dbase=no'", + doc="Tango device address for the APPROACH device.", ) stage_device_address = tango.server.device_property( dtype=str, - doc="Tango device address for the STAGE settings device. " - "DB mode: 'test/stage' " - "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/stage#dbase=no'", + doc="Tango device address for the STAGE device.", ) spectroscopy_device_address = tango.server.device_property( dtype=str, - doc="Tango device address for the SPECTROSCOPY settings device. " - "DB mode: 'test/spectroscopy' " - "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/spectroscopy#dbase=no'", - ) - - camera_device_address = tango.server.device_property( - dtype=str, - doc="Tango device address for the CAMERA settings device. " - "DB mode: 'test/camera' " - "No-DB mode: 'tango://127.0.0.1:8888/test/nodb/camera#dbase=no'", + doc="Tango device address for the SPECTROSCOPY device.", ) # ------------------------------------------------------------------ - # SPM Attributes + # Attributes # ------------------------------------------------------------------ spm_mode = tango.server.attribute( label="SPM Mode", dtype=SPMMode, access=tango.AttrWriteType.READ, - doc="Returns active SPM mode", + doc="Active SPM operating mode.", ) # ------------------------------------------------------------------ - # SPM-level Methods + # Initialization # ------------------------------------------------------------------ def _init_device_attributes(self) -> None: - pass + self._device_proxies: dict[str, tango.DeviceProxy] = {} def read_instrument_type(self) -> str: return 'SPM' @@ -116,98 +101,138 @@ def read_instrument_type(self) -> str: def read_spm_mode(self) -> SPMMode: return self._get_spm_mode() - def _disconnect(self): - self._microscope = None - self.info_stream('Disconnected from microscope hardware') + 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') # ------------------------------------------------------------------ - # SPM Commands + # Internal helpers # ------------------------------------------------------------------ - #spm level - - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def get_microscope_state(self,) -> str: - #TODO - #get active spm mode - spm_mode = self.read_spm_mode() + 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 + # ------------------------------------------------------------------ - state = { - "spm_mode": spm_mode.name - } - + @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.command(dtype_in=str, dtype_out=tango.DevEncoded) - def get_meter_values(self,) -> str: - """Read current meter values: Sum, Defl, Lat, etc.""" - #TODO - pass - return "" - - #high_level - - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def acquire_scan(self,) -> str: - pass #TODO - return "" + + @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._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.command(dtype_in=str, dtype_out=tango.DevEncoded) - def acquire_spectrum(self,) -> str: - pass #TODO - return "" - - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def move_probe(self,) -> str: - pass #TODO - return "" - - #stage - - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def move_stage(self,) -> str: - pass #TODO - return "" + @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() - #feedback_control - - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def feedback_on(self,) -> str: - pass #TODO - return "" + @tango.server.command(dtype_out=str) + def approach(self) -> str: + """Approach the tip to the surface; returns resulting approach state as JSON.""" + proxy = self._get_proxy('approach') + proxy.approach() + return json.dumps({'approached': bool(proxy.approached)}) - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def feedback_off(self,) -> str: - pass #TODO - return "" + @tango.server.command(dtype_out=str) + def retract(self) -> str: + """Retract the tip from the surface; returns resulting approach state as JSON.""" + proxy = self._get_proxy('approach') + proxy.retract() + return json.dumps({'approached': bool(proxy.approached)}) + + @tango.server.command(dtype_out=str) + def feedback_on(self) -> str: + """Engage the Z feedback loop; returns resulting feedback state as JSON.""" + proxy = self._get_proxy('feedback') + proxy.feedback_on() + return json.dumps({'feedback_on': bool(proxy.feedback_on_bool), + 'setpoint': float(proxy.setpoint)}) - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def set_set_point(self,) -> str: - pass #TODO - return "" + @tango.server.command(dtype_out=str) + def feedback_off(self) -> str: + """Disengage the Z feedback loop; returns resulting feedback state as JSON.""" + proxy = self._get_proxy('feedback') + proxy.feedback_off() + return json.dumps({'feedback_on': bool(proxy.feedback_on_bool)}) - #approach - - @tango.command(dtype_in=str, dtype_out=tango.DevEncoded) - def approach(self,) -> str: - pass #TODO - return "" + @tango.server.command(dtype_in=tango.DevFloat, dtype_out=str) + def set_setpoint(self, setpoint: float) -> str: + """Set the feedback setpoint; returns the value read back as JSON.""" + proxy = self._get_proxy('feedback') + proxy.setpoint = setpoint + return json.dumps({'setpoint': float(proxy.setpoint)}) - - # ------------------------------------------------------------------ - # SPM Abstract Methods - # ------------------------------------------------------------------ + @tango.server.command(dtype_in=tango.DevVarFloatArray, dtype_out=str) + def move_stage(self, position) -> str: + """Move the coarse stage; returns the final position as JSON.""" + proxy = self._get_proxy('stage') + proxy.move_stage(position) + return json.dumps({'position': list(proxy.position)}) - @abstractmethod - def _connect(self): - pass - - @abstractmethod - def _get_spm_mode(self)-> SPMMode: - """Get active spm mode""" - pass - -if __name__ == '__main__': - SPMMicroscope.run_server() - + @tango.server.command(dtype_in=tango.DevVarFloatArray, dtype_out=str) + def move_probe(self, position) -> str: + """Move the probe within the scan frame; returns the final position as JSON.""" + proxy = self._get_proxy('scan') + proxy.move_probe(position) + return json.dumps({'probe_position': list(proxy.probe_position)}) \ No newline at end of file From 9db46b4c9dd9af4e28bf57731207c2a64aab5bd0 Mon Sep 17 00:00:00 2001 From: Slautin Date: Mon, 13 Jul 2026 11:50:04 -0400 Subject: [PATCH 3/9] scan_and_approach --- .../hardware/spm_approach.py | 114 +++++++++ .../hardware/spm_scan.py | 239 ++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_approach.py create mode 100644 asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py 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_scan.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py new file mode 100644 index 00000000..14b1d9f3 --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py @@ -0,0 +1,239 @@ +""" +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 +# from tango import AttrWriteType, DevState, DevVarFloatArray +# from tango.server import attribute, command, device_property + +from asyncroscopy.data.data_writer import save_acquisition +from asyncroscopy.instruments.instrument import CombinedMeta + +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.", + ) + + # ------------------------------------------------------------------ + # 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 + ) + self._refresh_params() + self.set_state(tango.DevState.ON) + self.info_stream("SPM SCAN device initialised") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _refresh_params(self) -> None: + """Re-read all scan parameters from hardware into local fields.""" + params = self._hw_read_scan_params() + 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"] + + 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, + } + + # ------------------------------------------------------------------ + # 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) + + # ------------------------------------------------------------------ + # 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(tango.DevState.RUNNING) + try: + channel_names, data = self._hw_acquire_scan() + finally: + self.set_state(tango.DevState.ON) + return save_acquisition( + self, self._data_proxy, "spm_scan", channel_names, data, + dataset_attrs=[self._scan_metadata()] * len(channel_names), + ) + + @tango.server.command + def stop_scan(self) -> None: + """Stop a running scan.""" + self._hw_stop_scan() + self.set_state(tango.DevState.ON) + + @tango.server.command + def refresh_params(self) -> None: + """Re-read all scan parameters from hardware, e.g. after changes in the vendor GUI.""" + self._refresh_params() + + # ------------------------------------------------------------------ + # 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) -> tuple[list[str], list]: + """Run one scan; return (channel_names, list of 2D numpy arrays).""" + pass + + @abstractmethod + def _hw_stop_scan(self) -> None: + """Abort the running scan.""" + pass + +# ---------------------------------------------------------------------- +# Server entry point +# ---------------------------------------------------------------------- +if __name__ == "__main__": + SPM_SCAN.run_server() \ No newline at end of file From 3f83731fa09e8d70f0697e18396e6138523ee4dc Mon Sep 17 00:00:00 2001 From: Slautin Date: Mon, 13 Jul 2026 11:51:33 -0400 Subject: [PATCH 4/9] del_old_scan --- .../hardware/scan.py | 239 ------------------ 1 file changed, 239 deletions(-) delete mode 100644 asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py deleted file mode 100644 index 14b1d9f3..00000000 --- a/asyncroscopy/instruments/scanning_probe_microscope/hardware/scan.py +++ /dev/null @@ -1,239 +0,0 @@ -""" -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 -# from tango import AttrWriteType, DevState, DevVarFloatArray -# from tango.server import attribute, command, device_property - -from asyncroscopy.data.data_writer import save_acquisition -from asyncroscopy.instruments.instrument import CombinedMeta - -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.", - ) - - # ------------------------------------------------------------------ - # 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 - ) - self._refresh_params() - self.set_state(tango.DevState.ON) - self.info_stream("SPM SCAN device initialised") - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _refresh_params(self) -> None: - """Re-read all scan parameters from hardware into local fields.""" - params = self._hw_read_scan_params() - 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"] - - 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, - } - - # ------------------------------------------------------------------ - # 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) - - # ------------------------------------------------------------------ - # 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(tango.DevState.RUNNING) - try: - channel_names, data = self._hw_acquire_scan() - finally: - self.set_state(tango.DevState.ON) - return save_acquisition( - self, self._data_proxy, "spm_scan", channel_names, data, - dataset_attrs=[self._scan_metadata()] * len(channel_names), - ) - - @tango.server.command - def stop_scan(self) -> None: - """Stop a running scan.""" - self._hw_stop_scan() - self.set_state(tango.DevState.ON) - - @tango.server.command - def refresh_params(self) -> None: - """Re-read all scan parameters from hardware, e.g. after changes in the vendor GUI.""" - self._refresh_params() - - # ------------------------------------------------------------------ - # 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) -> tuple[list[str], list]: - """Run one scan; return (channel_names, list of 2D numpy arrays).""" - pass - - @abstractmethod - def _hw_stop_scan(self) -> None: - """Abort the running scan.""" - pass - -# ---------------------------------------------------------------------- -# Server entry point -# ---------------------------------------------------------------------- -if __name__ == "__main__": - SPM_SCAN.run_server() \ No newline at end of file From 03e9053a78dfec2d516365068b0187f9d3a8f027 Mon Sep 17 00:00:00 2001 From: Slautin Date: Sun, 19 Jul 2026 19:24:57 -0400 Subject: [PATCH 5/9] abstract_devices_feedback_scan_stage --- .../hardware/spm_feedback.py | 140 ++++++++++++++++++ .../hardware/spm_scan.py | 4 +- .../hardware/spm_stage.py | 115 ++++++++++++++ .../scanning_probe_microscope.py | 18 ++- 4 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_feedback.py create mode 100644 asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py 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 index 14b1d9f3..c40db3b1 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py @@ -12,9 +12,7 @@ from abc import abstractmethod -import tango -# from tango import AttrWriteType, DevState, DevVarFloatArray -# from tango.server import attribute, command, device_property +import tango #type: ignore from asyncroscopy.data.data_writer import save_acquisition from asyncroscopy.instruments.instrument import CombinedMeta 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..f0e6a381 --- /dev/null +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py @@ -0,0 +1,115 @@ +""" +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 = 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 = 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) + + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + + @tango.server.command(dtype_in=tango.DevVarFloatArray) + 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.DevVarFloatArray) + 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/scanning_probe_microscope.py b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py index 6347efcf..c7dd3695 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py @@ -17,8 +17,6 @@ from abc import abstractmethod import tango -# from tango import AttrWriteType, DevFloat, DevVarFloatArray -# from tango.server import attribute, command, device_property from asyncroscopy.instruments.instrument import Instrument @@ -59,22 +57,30 @@ class SPMMicroscope(Instrument): feedback_device_address = tango.server.device_property( dtype=str, - doc="Tango device address for the FEEDBACK device.", + 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.", + 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.", + 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.", + 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'", ) # ------------------------------------------------------------------ From 7f576a2775d9c1a84771971e0c10c5594f4c5a79 Mon Sep 17 00:00:00 2001 From: Slautin Date: Mon, 10 Aug 2026 11:54:08 -0400 Subject: [PATCH 6/9] outcome format correction and finalising abstrations --- .../hardware/spm_scan.py | 74 +++++++++++++++++- .../hardware/spm_stage.py | 4 +- .../scanning_probe_microscope.py | 78 +++++++++++-------- 3 files changed, 122 insertions(+), 34 deletions(-) diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py index c40db3b1..e2730052 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py @@ -88,6 +88,24 @@ class SPM_SCAN(tango.server.Device, metaclass=CombinedMeta): 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 # ------------------------------------------------------------------ @@ -131,6 +149,27 @@ def _scan_metadata(self) -> dict: "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(tango.DevState.MOVING) + try: + self._hw_move_probe(x, y) + finally: + self.set_state(tango.DevState.ON) # ------------------------------------------------------------------ # Attribute read / write, writes pushed to hardware @@ -172,6 +211,22 @@ def read_scan_rate_hz(self) -> float: 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 # ------------------------------------------------------------------ @@ -203,9 +258,16 @@ def stop_scan(self) -> None: @tango.server.command def refresh_params(self) -> None: - """Re-read all scan parameters from hardware, e.g. after changes in the vendor GUI.""" + """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 # ------------------------------------------------------------------ @@ -230,6 +292,16 @@ 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 # ---------------------------------------------------------------------- diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py index f0e6a381..33e93f80 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py @@ -66,7 +66,7 @@ def _do_move(self, dx: float, dy: float) -> None: # Commands # ------------------------------------------------------------------ - @tango.server.command(dtype_in=tango.DevVarFloatArray) + @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: @@ -74,7 +74,7 @@ def move_stage(self, position) -> None: 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.DevVarFloatArray) + @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: diff --git a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py index c7dd3695..8986d8a0 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py @@ -105,7 +105,7 @@ def read_instrument_type(self) -> str: return 'SPM' def read_spm_mode(self) -> SPMMode: - return self._get_spm_mode() + return self._hw_get_spm_mode() def _connect(self): self._connect_hardware() @@ -178,7 +178,7 @@ def get_microscope_state(self) -> str: @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._get_meter_values()) + return json.dumps(self._hw_get_meter_values()) # ------------------------------------------------------------------ # Commands — delegators to sub-devices @@ -193,52 +193,68 @@ 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=str) - def approach(self) -> str: - """Approach the tip to the surface; returns resulting approach state as JSON.""" + @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 json.dumps({'approached': bool(proxy.approached)}) + return proxy.approached - @tango.server.command(dtype_out=str) - def retract(self) -> str: - """Retract the tip from the surface; returns resulting approach state as JSON.""" + @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 json.dumps({'approached': bool(proxy.approached)}) + return proxy.approached - @tango.server.command(dtype_out=str) - def feedback_on(self) -> str: - """Engage the Z feedback loop; returns resulting feedback state as JSON.""" + @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 json.dumps({'feedback_on': bool(proxy.feedback_on_bool), - 'setpoint': float(proxy.setpoint)}) + return proxy.feedback_on_bool - @tango.server.command(dtype_out=str) - def feedback_off(self) -> str: - """Disengage the Z feedback loop; returns resulting feedback state as JSON.""" + @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 json.dumps({'feedback_on': bool(proxy.feedback_on_bool)}) + return proxy.feedback_on_bool - @tango.server.command(dtype_in=tango.DevFloat, dtype_out=str) - def set_setpoint(self, setpoint: float) -> str: - """Set the feedback setpoint; returns the value read back as JSON.""" + @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 json.dumps({'setpoint': float(proxy.setpoint)}) + return proxy.setpoint - @tango.server.command(dtype_in=tango.DevVarFloatArray, dtype_out=str) - def move_stage(self, position) -> str: - """Move the coarse stage; returns the final position as JSON.""" + @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 json.dumps({'position': list(proxy.position)}) + return [proxy.stage_x, proxy.stage_y] - @tango.server.command(dtype_in=tango.DevVarFloatArray, dtype_out=str) - def move_probe(self, position) -> str: - """Move the probe within the scan frame; returns the final position as JSON.""" + @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 json.dumps({'probe_position': list(proxy.probe_position)}) \ No newline at end of file + 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 From 3ae1fba350c84acd6484c7e024fce790f76f3e08 Mon Sep 17 00:00:00 2001 From: Slautin Date: Sun, 16 Aug 2026 21:38:06 -0400 Subject: [PATCH 7/9] jupiter_api_blank --- .../scanning_probe_microscope/jupiter_api.py | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py index 8b137891..21784df7 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py @@ -1 +1,127 @@ +""" +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. + +No Tango attributes or commands are (re)defined here — the public +interface lives entirely in the abstract base classes. +""" + +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 + + +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 _hw_read_scan_params(self) -> dict: + """Read all scan parameters from AR; keys must match the attribute names + (x_scan_center_m, y_scan_center_m, scan_size_m, scan_size_px, + scan_angle_deg, scan_rate_hz).""" + ... + + def _hw_write_scan_param(self, name: str, value) -> None: + """Push one scan parameter to AR (name as in _hw_read_scan_params); + hardware may coerce/couple values — caller re-reads afterwards.""" + ... + + def _hw_acquire_scan(self) -> tuple[list[str], list]: + """Run one frame with current settings; block until complete. + Return (channel_names, list of 2D numpy arrays, same length/order).""" + ... + + def _hw_stop_scan(self) -> None: + """Abort the running scan immediately.""" + ... + + def _hw_read_probe_position(self) -> list[float]: + """Return the current probe position [x, y] in meters within the scan frame.""" + ... + + def _hw_move_probe(self, x: float, y: float) -> None: + """Move the probe to (x, y) in meters; return when the move is complete.""" + ... + + +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.""" + ... From 5fb50618c6f26af61e5e78a05de11e396ac892d4 Mon Sep 17 00:00:00 2001 From: RichardLiuCoding Date: Mon, 17 Aug 2026 12:21:09 -0400 Subject: [PATCH 8/9] feat(spm): Jupiter adapter + fixes to run the SPM stack for the first time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jupiter_api: Jupiter adapters for the five SPM devices. _hw_read_scan_params reads the six scan parameters from Igor via aespm (local mode, one batched round trip, lock-guarded since Tango dispatches across worker threads). The remaining _hw_ hooks are still stubs. Server entry point picks the device class from the instance name, as one module hosts five classes. - instrument: explicit `import tango.server`. A bare `import tango` does not bind the submodule, so CombinedMeta raised AttributeError; the STEM chain only worked because electron_microscope imports it first. - spm_stage: add the missing read_stage_x_m/read_stage_y_m — PyTango resolves read_ at class creation, so the module could not be imported at all. Renamed from stage_x/stage_y to match SPM_SCAN and the units the SPMMicroscope.move_stage docstring already documented. - spm_scan: fault instead of failing to export when the first hardware read fails, so the device stays reachable and Status carries the reason; cleared on the next successful refresh. State and Status are now always set together. - pyproject: `jupiter` extra for aespm, with a numpy override scoped to aespm (it pins numpy<2.0 against this project's >=2.3.5). - configs/Jupiter-local.yaml: single-machine config, Tiled off. --- asyncroscopy/instruments/instrument.py | 1 + .../hardware/spm_scan.py | 50 ++++++++-- .../hardware/spm_stage.py | 14 ++- .../scanning_probe_microscope/jupiter_api.py | 92 ++++++++++++++++++- .../scanning_probe_microscope.py | 2 +- configs/Jupiter-local.yaml | 26 ++++++ pyproject.toml | 11 ++- 7 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 configs/Jupiter-local.yaml 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_scan.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py index e2730052..f0c08c46 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py @@ -17,6 +17,15 @@ 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.""" @@ -116,17 +125,39 @@ def init_device(self) -> None: tango.DeviceProxy(self.data_device_address) if self.data_device_address else None ) - self._refresh_params() - self.set_state(tango.DevState.ON) + + 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.""" - params = self._hw_read_scan_params() + 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"] @@ -134,6 +165,9 @@ def _refresh_params(self) -> None: 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) @@ -165,11 +199,11 @@ def _move_probe(self, x: float, y: float) -> None: "A probe move is already running.", "move_probe()", ) - self.set_state(tango.DevState.MOVING) + self._set_state_and_status(tango.DevState.MOVING, "Moving the probe.") try: self._hw_move_probe(x, y) finally: - self.set_state(tango.DevState.ON) + self._set_state_and_status(tango.DevState.ON, "Idle.") # ------------------------------------------------------------------ # Attribute read / write, writes pushed to hardware @@ -240,11 +274,11 @@ def acquire_scan(self) -> str: "A scan is already running", "acquire_scan()", ) - self.set_state(tango.DevState.RUNNING) + self._set_state_and_status(tango.DevState.RUNNING, "Acquiring a scan frame.") try: channel_names, data = self._hw_acquire_scan() finally: - self.set_state(tango.DevState.ON) + self._set_state_and_status(tango.DevState.ON, "Idle.") return save_acquisition( self, self._data_proxy, "spm_scan", channel_names, data, dataset_attrs=[self._scan_metadata()] * len(channel_names), @@ -254,7 +288,7 @@ def acquire_scan(self) -> str: def stop_scan(self) -> None: """Stop a running scan.""" self._hw_stop_scan() - self.set_state(tango.DevState.ON) + self._set_state_and_status(tango.DevState.ON, "Idle.") @tango.server.command def refresh_params(self) -> None: diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py index 33e93f80..c5fe343f 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_stage.py @@ -17,7 +17,7 @@ class SPM_STAGE(tango.server.Device, metaclass=CombinedMeta): # Attributes # ------------------------------------------------------------------ - stage_x = tango.server.attribute( + stage_x_m = tango.server.attribute( label="Stage X Position", dtype=float, access=tango.AttrWriteType.READ, @@ -26,7 +26,7 @@ class SPM_STAGE(tango.server.Device, metaclass=CombinedMeta): doc="Current stage X position in meters. Read live from hardware.", ) - stage_y = tango.server.attribute( + stage_y_m = tango.server.attribute( label="Stage Y Position", dtype=float, access=tango.AttrWriteType.READ, @@ -62,6 +62,16 @@ def _do_move(self, dx: float, dy: float) -> None: 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 # ------------------------------------------------------------------ diff --git a/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py index 21784df7..ce97d2db 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py @@ -17,6 +17,51 @@ 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 threading + +import numpy as np +import tango +import tango.server + + +try: + from aespm import read_spm + + _AESPM_AVAILABLE = True + _AESPM_IMPORT_ERROR = "" +except Exception as exc: + _AESPM_AVAILABLE = False + _AESPM_IMPORT_ERROR = f"{type(exc).__name__}: {exc}" + +_IGOR_LOCK = threading.RLock() + +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. + """ + 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.", + "jupiter_api._read()", + ) + with _IGOR_LOCK: + values = read_spm(key=list(keys), connection=None) + return [float(value) for value in np.atleast_1d(values)] + +_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", +} class JupiterMicroscope(SPMMicroscope): """Top-level Jupiter AFM device: vendor connection and instrument-global state.""" @@ -41,7 +86,24 @@ def _hw_read_scan_params(self) -> dict: """Read all scan parameters from AR; keys must match the attribute names (x_scan_center_m, y_scan_center_m, scan_size_m, scan_size_px, scan_angle_deg, scan_rate_hz).""" - ... + names = list(_SCAN_PARAM_KEYS) + values = _read([_SCAN_PARAM_KEYS[name] for name in names]) + params = dict(zip(names, values)) + + # A wrong key name can leave GV() failing inside Igor while readout.txt + # keeps its previous contents, so bad names surface as NaN or as stale + # values rather than as an error. NaN is the case we can actually catch. + invalid = [name for name, value in params.items() if not np.isfinite(value)] + if invalid: + tango.Except.throw_exception( + "ScanParameterUnreadable", + "AR returned no finite value for: " + + ", ".join(f"{name} (GV '{_SCAN_PARAM_KEYS[name]}')" for name in invalid), + "_hw_read_scan_params()", + ) + + 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 (name as in _hw_read_scan_params); @@ -125,3 +187,31 @@ def _hw_move_stage_relative(self, dx: float, dy: float) -> None: 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/scanning_probe_microscope.py b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py index 8986d8a0..503fa0e5 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/scanning_probe_microscope.py @@ -237,7 +237,7 @@ def move_stage(self, position) -> list[float]: """ proxy = self._get_proxy('stage') proxy.move_stage(position) - return [proxy.stage_x, proxy.stage_y] + 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]: 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 From c9fd725ecd8c1bc33f47dc989c308781ab36a006 Mon Sep 17 00:00:00 2001 From: RichardLiuCoding Date: Wed, 2 Sep 2026 17:50:11 -0400 Subject: [PATCH 9/9] multiple_updates to the jupiter_api scan device --- .../hardware/spm_scan.py | 29 +- .../scanning_probe_microscope/jupiter_api.py | 467 ++++++++++++++++-- .../notebooks/0_jupiter_scan_test.ipynb | 357 +++++++++++++ 3 files changed, 800 insertions(+), 53 deletions(-) create mode 100644 asyncroscopy/instruments/scanning_probe_microscope/notebooks/0_jupiter_scan_test.ipynb diff --git a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py index f0c08c46..d09e5ab7 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/hardware/spm_scan.py @@ -14,7 +14,7 @@ import tango #type: ignore -from asyncroscopy.data.data_writer import save_acquisition +#from asyncroscopy.data.data_writer import save_acquisition from asyncroscopy.instruments.instrument import CombinedMeta _PARAM_DEFAULTS = { @@ -205,6 +205,22 @@ def _move_probe(self, x: float, y: float) -> None: 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 # ------------------------------------------------------------------ @@ -276,13 +292,10 @@ def acquire_scan(self) -> str: ) self._set_state_and_status(tango.DevState.RUNNING, "Acquiring a scan frame.") try: - channel_names, data = self._hw_acquire_scan() + path = self._hw_acquire_scan() finally: self._set_state_and_status(tango.DevState.ON, "Idle.") - return save_acquisition( - self, self._data_proxy, "spm_scan", channel_names, data, - dataset_attrs=[self._scan_metadata()] * len(channel_names), - ) + return self._save_scan(path) @tango.server.command def stop_scan(self) -> None: @@ -317,8 +330,8 @@ def _hw_write_scan_param(self, name: str, value) -> None: pass @abstractmethod - def _hw_acquire_scan(self) -> tuple[list[str], list]: - """Run one scan; return (channel_names, list of 2D numpy arrays).""" + def _hw_acquire_scan(self) -> str: + """Run one scan; return the path of the file the vendor software wrote.""" pass @abstractmethod diff --git a/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py index ce97d2db..bdfb24db 100644 --- a/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py +++ b/asyncroscopy/instruments/scanning_probe_microscope/jupiter_api.py @@ -5,8 +5,8 @@ (SPMMicroscope, SPM_SCAN, SPM_FEEDBACK, SPM_APPROACH, SPM_STAGE) with calls to the gor Pro control software. -No Tango attributes or commands are (re)defined here — the public -interface lives entirely in the abstract base classes. +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 ( @@ -17,23 +17,131 @@ 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 + 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. @@ -42,26 +150,116 @@ def _read(keys: list[str]) -> list[float]: than reading keys individually. np.atleast_1d guards the single-key case, where np.loadtxt returns a 0-d array. """ - if not _AESPM_AVAILABLE: + + _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( - "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.", + "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: - values = read_spm(key=list(keys), connection=None) - return [float(value) for value in np.atleast_1d(values)] + write_spm(commands=commands, connection=None, wait=settle_s) -_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", -} +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.""" @@ -82,50 +280,229 @@ def _hw_get_meter_values(self) -> dict: 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 must match the attribute names - (x_scan_center_m, y_scan_center_m, scan_size_m, scan_size_px, - scan_angle_deg, scan_rate_hz).""" + """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 - # A wrong key name can leave GV() failing inside Igor while readout.txt - # keeps its previous contents, so bad names surface as NaN or as stale - # values rather than as an error. NaN is the case we can actually catch. - invalid = [name for name, value in params.items() if not np.isfinite(value)] - if invalid: + 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( - "ScanParameterUnreadable", - "AR returned no finite value for: " - + ", ".join(f"{name} (GV '{_SCAN_PARAM_KEYS[name]}')" for name in invalid), - "_hw_read_scan_params()", + "UnknownScanParameter", + f"No Igor command is defined for '{name}'.", + "_hw_write_scan_param()", ) - params["scan_size_px"] = int(round(params["scan_size_px"])) - return params + # 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) - def _hw_write_scan_param(self, name: str, value) -> None: - """Push one scan parameter to AR (name as in _hw_read_scan_params); - hardware may coerce/couple values — caller re-reads afterwards.""" - ... + 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 - def _hw_acquire_scan(self) -> tuple[list[str], list]: - """Run one frame with current settings; block until complete. - Return (channel_names, list of 2D numpy arrays, same length/order).""" - ... + _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 current probe position [x, y] in meters within the scan frame.""" - ... + """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 meters; return when the move is complete.""" - ... + """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): 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 +}