Tell: start of rework - needs testing

This commit is contained in:
2026-03-24 09:41:22 +01:00
parent 95010444fd
commit 3edf9200fd
2 changed files with 503 additions and 361 deletions
+410
View File
@@ -0,0 +1,410 @@
import json
import re
import time
from typing import Any, Callable, Protocol
from urllib.parse import urlparse
import requests
from aare.common.exception_handler import TellCommunicationError
from aare.common.logger_config import setup_logger
from aare.common.beamline import MXBeamline # noqa: F401
from pshell import PShellClient
logger = setup_logger("aareDAQ")
VALID_DEWAR_POSITIONS = [f"{p}{n}" for n in "12345" for p in "ABCDEFX"]
def is_valid_dewar_position(position):
"""check if argument is a valid dewar position"""
return position in VALID_DEWAR_POSITIONS
POSITION_PARK = "pPark"
POSITION_COLD = "pCold"
POSITION_AUX = "pAux"
POSITION_DEWAR = "pDewar"
POSITION_HOME = "pHome"
POSITION_HEATER = "pHeatB"
class ManualMountException(Exception):
"""Custom exception for manual mounting"""
pass
class SmartMagnetFaultException(Exception):
"""Custom exception for smart magnet fault"""
pass
class TellMountFailedException(Exception):
"""Custom exception for mount failure"""
pass
class TellCommandWhileBusyException(Exception):
"""Custom exception for trying to move Tell when it is busy"""
pass
class TellConnectionException(Exception):
"""Custom exception for connection problems"""
pass
class TellBackend(Protocol):
@property
def url(self) -> str | None:
...
def get_state(self) -> str:
...
def get_result(self, command_id: int = -1):
...
def wait_state(self, state: str, timeout: float) -> None:
...
def wait_state_not(self, state: str, timeout: float) -> None:
...
def wait_events(self, events: dict[str, Any], timeout: float):
...
def eval(self, expr: str):
...
def start_eval(self, expr: str) -> int:
...
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None:
...
def abort(self) -> None:
...
class PShellTellBackend:
def __init__(self, bl: MXBeamline):
self._url = self._resolve_url(bl)
print(f"Connecting TELL p-shell service at {self._url} ...", end="")
hostname = urlparse(self._url).hostname
try:
requests.get(f"{self._url}/history/0", timeout=1.0)
except requests.exceptions.RequestException as e:
print(f"...connection to {hostname} failed")
raise TellCommunicationError(
f"TELL connection failed ({hostname})",
base_url=self._url,
endpoint="history/0",
operation="GET",
) from e
except requests.ReadTimeout as e:
print(f"...PShell service {hostname} is down")
raise TellCommunicationError(
f"TELL connection timedout ({hostname})",
base_url=self._url,
endpoint="history/0",
operation="GET",
) from e
self._pshell = PShellClient(self._url)
@staticmethod
def _resolve_url(bl: MXBeamline) -> str:
beamline = bl.value.lower()
if bl == MXBeamline.X06DA:
return f"http://{beamline}-tell.psi.ch:22222"
if bl == MXBeamline.X10SA:
return "http://PC17488:22222"
if bl == MXBeamline.X06SA:
raise NotImplementedError(f"TellClient not implemented for {beamline}")
if bl == MXBeamline.SIMULATED:
raise NotImplementedError("Use SimTellBackend for MXBeamline.SIMULATED")
raise ValueError(f"Unknown beamline {beamline}")
@property
def url(self) -> str | None:
return self._url
def get_state(self) -> str:
return self._pshell.get_state()
def get_result(self, command_id: int = -1):
return self._pshell.get_result(command_id)
def wait_state(self, state: str, timeout: float) -> None:
self._pshell.wait_state(state, timeout=timeout)
def wait_state_not(self, state: str, timeout: float) -> None:
self._pshell.wait_state_not(state, timeout=timeout)
def wait_events(self, events: dict[str, Any], timeout: float):
return self._pshell.wait_events(events, timeout=timeout)
def eval(self, expr: str):
return self._pshell.eval(expr)
def start_eval(self, expr: str) -> int:
return self._pshell.start_eval(expr)
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None:
self._pshell.run(path, pars=pars, background=background)
def abort(self) -> None:
self._pshell.abort()
class SimTellBackend:
def __init__(self):
self._url: str | None = None
self._state = "Ready"
self._last_cmd_id = 1000
self._mounted_sample = ""
self._settings: dict[str, str] = {"mounted_sample_position": ""}
self._results: dict[int, dict[str, Any]] = {}
self._robot_status: dict[str, Any] = {
"powered": True,
"pos": POSITION_PARK,
}
self._current_mA = 30.0
self._pin_offset = 0.0
self._detected_pucks: list[dict[str, Any]] = []
self._system_check_msg = "OK"
self._smart_magnet_state = "Ready"
self._in_mount_position = False
@property
def url(self) -> str | None:
return self._url
def _next_cmd_id(self) -> int:
self._last_cmd_id += 1
return self._last_cmd_id
def _set_ready_soon(self) -> None:
time.sleep(0.01)
self._state = "Ready"
def get_state(self) -> str:
return self._state
def get_result(self, command_id: int = -1):
if command_id == -1:
command_id = self._last_cmd_id
return self._results.get(command_id, {"status": "completed"})
def wait_state(self, state: str, timeout: float) -> None:
if self._state != state:
time.sleep(min(timeout, 0.05))
self._state = state
def wait_state_not(self, state: str, timeout: float) -> None:
if self._state == state:
time.sleep(min(timeout, 0.05))
self._state = "Ready"
def wait_events(self, events: dict[str, Any], timeout: float):
self.wait_state_not("Busy", timeout)
if "Motion Sync" in events:
return "Motion Sync", "Robot Clear after mount"
if "Motion Task" in events:
return "Motion Task", "idle"
return None, self._state
def eval(self, expr: str):
expr = expr.strip()
if expr == "in_mount_position&":
return "true" if self._in_mount_position else "false"
if expr.startswith("in_mount_position = "):
self._in_mount_position = "True" in expr or "true" in expr
return None
if expr.startswith("set_setting("):
match = re.match(r"set_setting\('([^']+)', '([^']*)'\)&", expr)
if match:
key, value = match.groups()
self._settings[key] = value
return None
if expr.startswith("get_setting("):
match = re.match(r"get_setting\('([^']+)'\)&", expr)
if match:
key = match.group(1)
return self._settings.get(key, "")
return ""
if expr == "system_check_msg()&":
return self._system_check_msg
if expr == "robot.state&":
return self._state
if expr == "robot.take()&":
return str(self._robot_status)
if expr == "get_pucks_info()&":
return json.dumps(self._detected_pucks)
if expr == "get_pin_offset()&":
return str(self._pin_offset)
if expr == "smart_magnet.get_current_rb()&":
return str(self._current_mA)
if expr.startswith("smart_magnet.set_current("):
match = re.match(r"smart_magnet\.set_current\(([-+]?\d+(?:\.\d+)?)\)&", expr)
if match:
self._current_mA = float(match.group(1))
return None
if expr == "enable_motion()&":
self._robot_status["powered"] = True
return None
if expr == "smart_magnet.state&":
return self._smart_magnet_state
if expr == "smart_magnet.set_supress(True)&":
return None
if expr == "smart_magnet.set_supress(False)&":
return None
if expr == "smart_magnet.set_resting_current()&":
return None
if expr == "robot.stop_task()&":
self._state = "Ready"
return None
return None
def start_eval(self, expr: str) -> int:
cmd_id = self._next_cmd_id()
self._state = "Busy"
if expr.startswith("mount("):
parts = re.findall(r"'([^']*)'|([^,()]+)", expr)
values = [a if a else b.strip() for a, b in parts]
if len(values) >= 4:
segment = values[0]
puck = values[1]
sample = values[2]
mounted = f"{segment}{puck}{sample}"
self._mounted_sample = mounted
self._settings["mounted_sample_position"] = mounted
self._robot_status["pos"] = POSITION_DEWAR
elif expr.startswith("unmount("):
self._mounted_sample = ""
self._settings["mounted_sample_position"] = ""
self._robot_status["pos"] = POSITION_PARK
elif expr.startswith("move_park("):
self._robot_status["pos"] = POSITION_PARK
elif expr.startswith("move_cold("):
self._robot_status["pos"] = POSITION_COLD
elif expr.startswith("dry("):
self._robot_status["pos"] = POSITION_HEATER
self._results[cmd_id] = {"status": "completed", "command": expr}
self._set_ready_soon()
return cmd_id
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None:
_ = background
if path == "data/set_samples_info" and pars:
try:
data = json.loads(pars[0])
self._detected_pucks = []
for item in data:
puck_address = item.get("puckAddress", "")
if puck_address:
self._detected_pucks.append(
{
"puckState": "Present",
"puckAddress": puck_address,
"puckBarcode": item.get("puckBarcode", ""),
}
)
except Exception:
logger.warning("Failed to load simulated samples info")
def abort(self) -> None:
self._state = "Ready"
class LazyTellBackend:
def __init__(self, factory: Callable[[], TellBackend], *, retry_interval_s: float = 2.0):
self._factory = factory
self._backend: TellBackend | None = None
self._retry_interval_s = float(retry_interval_s)
self._last_attempt_ts = 0.0
self._last_error: Exception | None = None
def _get_backend(self) -> TellBackend:
if self._backend is not None:
return self._backend
now = time.monotonic()
if now - self._last_attempt_ts < self._retry_interval_s and self._last_error is not None:
raise self._last_error
self._last_attempt_ts = now
try:
self._backend = self._factory()
self._last_error = None
return self._backend
except TellCommunicationError as e:
self._last_error = e
raise
except Exception as e:
wrapped = TellCommunicationError(
"TELL connection failed",
operation="CONNECT",
)
self._last_error = wrapped
raise wrapped from e
@property
def url(self) -> str | None:
return self._get_backend().url
def get_state(self) -> str:
return self._get_backend().get_state()
def get_result(self, command_id: int = -1):
return self._get_backend().get_result(command_id)
def wait_state(self, state: str, timeout: float) -> None:
self._get_backend().wait_state(state, timeout)
def wait_state_not(self, state: str, timeout: float) -> None:
self._get_backend().wait_state_not(state, timeout)
def wait_events(self, events: dict[str, Any], timeout: float):
return self._get_backend().wait_events(events, timeout)
def eval(self, expr: str):
return self._get_backend().eval(expr)
def start_eval(self, expr: str) -> int:
return self._get_backend().start_eval(expr)
def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None:
self._get_backend().run(path, pars=pars, background=background)
def abort(self) -> None:
self._get_backend().abort()
+93 -361
View File
@@ -1,14 +1,9 @@
import ast
import json
import random
import re
import time
from typing import List
from urllib.parse import urlparse
import requests
from aare.common.exception_handler import TellCommunicationError
from aare.common.logger_config import setup_logger
from aare.common.beamline import MXBeamline
from aare.common.models import (
PuckLoadedInfo,
DewarAddress,
@@ -16,93 +11,28 @@ from aare.common.models import (
)
from aareDB import PuckWithTellPosition
from aare.common.beamline import MXBeamline # noqa: F401
from pshell import PShellClient
from aare.devices.tell_backend import (
TellBackend,
SimTellBackend,
LazyTellBackend,
PShellTellBackend,
ManualMountException,
POSITION_COLD,
SmartMagnetFaultException,
TellConnectionException,
TellMountFailedException,
TellCommandWhileBusyException,
)
from aare.common.logger_config import setup_logger
logger = setup_logger("aareDAQ")
class ManualMountException(Exception):
"""Custom exception for manual mounting"""
pass
class SmartMagnetFaultException(Exception):
"""Custom exception for smart magnet fault"""
pass
class TellMountFailedException(Exception):
"""Custom exception for mount failure"""
pass
class TellCommandWhileBusyException(Exception):
"""Custom exception for trying to move Tell when it is busy"""
pass
class TellConnectionException(Exception):
"""Custom exception for connection problems"""
pass
VALID_DEWAR_POSITIONS = [f"{p}{n}" for n in "12345" for p in "ABCDEFX"]
def is_valid_dewar_position(position):
"""check if argument is a valid dewar position"""
return position in VALID_DEWAR_POSITIONS
POSITION_PARK = "pPark"
POSITION_COLD = "pCold"
POSITION_AUX = "pAux"
POSITION_DEWAR = "pDewar"
POSITION_HOME = "pHome"
POSITION_HEATER = "pHeatB"
#Nov 26 13:36:00 mx-x06da-queue-01.psi.ch AareDAQ[2944444]: 2025-11-26 13:36:00,388 - aareDAQ - ERROR - Error getting status: ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer'))
class TellClient:
"""High-level Tell robot API using PShellClient"""
def __init__(self, bl: MXBeamline):
self.__url = None
beamline = bl.value.lower()
"""High-level Tell robot API using a pluggable backend"""
def __init__(self, bl: MXBeamline, backend: TellBackend | None = None):
self.__beamline = bl
if bl == MXBeamline.X06DA:
self.__url = f"http://{beamline}-tell.psi.ch:22222"
elif bl == MXBeamline.X10SA:
self.__url = f"http://PC17488:22222"
elif bl == MXBeamline.X06SA:
self.__url = f""
raise NotImplemented(f"TellClient not implemente for {beamline}")
elif bl == MXBeamline.SIMULATED:
raise NotImplemented(f"Use SimClient, generate tell client using"
f"make_tell_client(beamline)")
else:
raise ValueError(f"Unknown beamline {beamline}")
print(f"Connecting TELL p-shell service at {self.__url} ...", end="")
hostname = urlparse(self.__url).hostname
try:
requests.get(f"{self.__url}/history/0", timeout=1.0)
except requests.exceptions.RequestException as e:
print(f"...connection to {hostname} failed")
raise TellCommunicationError(
f"TELL connection failed ({hostname})",
base_url=self.__url,
endpoint="history/0",
operation="GET",
) from e
except requests.ReadTimeout as e:
print(f"...PShell service {hostname} is down")
raise TellCommunicationError(
f"TELL connection timedout ({hostname})",
base_url=self.__url,
endpoint="history/0",
operation="GET",
) from e
self.pshell = PShellClient(self.__url)
self.backend = backend or PShellTellBackend(bl)
self._aborted = False
self.state = self.get_state()
@@ -112,26 +42,26 @@ class TellClient:
@property
def url(self):
"""returns the configured base url for the Tell robot"""
return self.__url
return self.backend.url
def get_state(self):
"""returns the current state of the robot"""
self.state = self.pshell.get_state()
self.state = self.backend.get_state()
return self.state
def get_result(self, command_id=-1):
"""returns the result of the last command issued to the robot"""
return self.pshell.get_result(command_id)
return self.backend.get_result(command_id)
def wait_ready(self, timeout: float = 360.0):
"""waits until the robot is ready to accept commands returns None if simulation
and raises an exception if the robot is not ready"""
self.pshell.wait_state("Ready", timeout=timeout)
self.backend.wait_state("Ready", timeout=timeout)
def wait_not_busy(self, timeout: float = 360.0):
"""waits until the robot is not busy and returns None if simulation
and raises an exception if the robot is busy"""
self.pshell.wait_state_not("Busy", timeout=timeout)
self.backend.wait_state_not("Busy", timeout=timeout)
state = self.get_state()
if state != "Ready":
if state == "Initializing":
@@ -143,11 +73,11 @@ class TellClient:
def set_in_mount_position(self, value):
"""tells the robot that the beamlien is safe and to set the in mount position flag allowing mounting
:param value """
self.pshell.eval("in_mount_position = " + str(value) + "&")
self.backend.eval("in_mount_position = " + str(value) + "&")
def is_in_mount_position(self) -> bool:
"""checks to see if the robot is in the mount position and returns a boolean"""
return self.pshell.eval("in_mount_position&").lower() == "true"
return self.backend.eval("in_mount_position&").lower() == "true"
def set_samples_info(self, info: List[PuckWithTellPosition]):
"""sets the samples in the robot dewar based on the given list of PuckWithTellPosition objects
@@ -160,7 +90,7 @@ class TellClient:
"userName": x.pgroup,
"dewarName": x.dewar_name or "",
"puckName": x.puck_name,
"puckType": "Unipuck", # could use x.puck_type
"puckType": "Unipuck",
"puckAddress": x.tell_position or "",
"puckBarcode": x.puck_name,
"sampleBarcode": "",
@@ -171,8 +101,7 @@ class TellClient:
}
)
self.pshell.run("data/set_samples_info", pars=[json.dumps(j)], background=True)
# self.pshell.eval("set_samples_info(" + json.dumps(info) + ")&")
self.backend.run("data/set_samples_info", pars=[json.dumps(j)], background=True)
def start_cmd(self, cmd, *argv):
"""starts a command on the robot and returns the command id"""
@@ -180,7 +109,7 @@ class TellClient:
for a in argv:
cmd = cmd + (("'" + a + "'") if type(a) is str else str(a)) + ", "
cmd = cmd + ")"
ret = self.pshell.start_eval(cmd)
ret = self.backend.start_eval(cmd)
self.get_state()
return ret
@@ -191,11 +120,10 @@ class TellClient:
result = self.get_result(self._last_cmd_id)
logger.debug(f"{msg} {result}")
status = result["status"]
if "completed" != status: #FIXME this is very limiting and depends on tell reporting statuses
if "completed" != status:
if "removed" != status:
raise TellMountFailedException(f"{msg} {result}")
else:
return f"{msg} {result}"
return f"{msg} {result}"
def estimate_mounting_time(self, segment) -> int:
"""Adds additional time if cooling/drying is expected based on requested segment,
@@ -206,7 +134,7 @@ class TellClient:
gripper_in_cold = self.is_in_cold()
if current_mounted is None:
unmount_needs_drying = 0 # might not have anything
unmount_needs_drying = 0
unmount_needs_cooling = 0
else:
segment_in_cold = current_mounted.puck.segment in "ABCDEF"
@@ -219,27 +147,18 @@ class TellClient:
needs_cooling = mount_needs_cooling + unmount_needs_cooling
needs_drying = mount_needs_drying + unmount_needs_drying
return needs_cooling * 30 + needs_drying * 120
except:
except Exception:
return 0
def mount(
self,
address: SampleDewarAddress,
force: bool = False, # kept for future
read_dm: bool = False, # read data matrix
auto_unmount: bool = False, # single command, if False it will raise exception
wait: bool = False, # blocking operation
force: bool = False,
read_dm: bool = False,
auto_unmount: bool = False,
wait: bool = False,
timeout: float = 600.0,
):
"""send api request to mount sample from dewer after validating dewer address returns None or repsonse.
If the robot is busy, mount will raise an exception.
:param address: SampleDewarAddress
:param force: bool
:param read_dm: bool
:param auto_unmount: bool
:param wait: bool
:param timeout: float
"""
SampleDewarAddress.model_validate(address)
segment = address.puck.segment
@@ -258,9 +177,16 @@ class TellClient:
wait_timeout = timeout + self.estimate_mounting_time(segment)
logger.info("waiting for mount to complete")
if wait and segment in "ABCDEF":
event, value = self.pshell.wait_events({"state": None, "Motion Task": "dry",
"Gripper detection" : None,
"Motion Sync": "Robot Clear after mount"}, timeout=wait_timeout)
event, value = self.backend.wait_events(
{
"state": None,
"Motion Task": "dry",
"Gripper detection": None,
"Motion Sync": "Robot Clear after mount",
},
timeout=wait_timeout,
)
logger.info(f"event: {event} occurred with value: {value}")
if event is None or event == "state":
logger.info(f"event: {event} occurred with value: {value}, checking command completed okay")
self.check_command_ok(
@@ -299,11 +225,6 @@ class TellClient:
return None
def unmount(self, force=False, wait=False, timeout=360.0):
"""send api request to unmount sample from dewer returns None or repsonse.
:param force: bool Force has a meaning, will unmount even if smart magnet is not detecting sample
:param wait: bool If true will wait until unmount is completed
:timeout: float"""
if self.is_busy():
raise TellCommandWhileBusyException("mount received while robot is busy")
@@ -315,82 +236,60 @@ class TellClient:
return self._last_cmd_id
def dry(self, heat_time=None, speed=None, wait_cold=None, wait=False):
"""send api request to dry tell gripper.
:param: heat_time float if None Tell will use default for drying time
:param: speed float if None Tell will use default for drying speed
:param: wait_cold bool if -1 to go to park after dry. if None Tell will use default time to wait_cold.
:param wait: bool If true will wait until drying is completed
"""
self.pshell.wait_state("Ready", timeout=30.0)
self.backend.wait_state("Ready", timeout=30.0)
self._last_cmd_id = self.start_cmd("dry", heat_time, speed, wait_cold)
if wait:
self.check_command_ok(timeout=360.0, msg=f"Dry failed")
self.check_command_ok(timeout=360.0, msg="Dry failed")
def move_park(self, wait=False):
"""send api request to move robot to park position"""
self._last_cmd_id = self.start_cmd("move_park")
if wait:
self.check_command_ok(timeout=360.0, msg=f"Move to park failed")
self.check_command_ok(timeout=360.0, msg="Move to park failed")
def move_cold(self, reset_timestamp=False, wait=False):
"""send api request to move robot to cold position"""
self._last_cmd_id = self.start_cmd("move_cold", reset_timestamp)
if wait:
self.check_command_ok(timeout=360.0, msg=f"Move to cold failed")
self.check_command_ok(timeout=360.0, msg="Move to cold failed")
def abort_cmd(self):
"""sends an abort pshell requesst and a robot stop task command"""
self.pshell.abort()
self.pshell.eval("robot.stop_task()&")
self.backend.abort()
self.backend.eval("robot.stop_task()&")
def set_setting(self, key: str, value: str):
"""wrapper for pshell eval set_setting command
:param key str, name of a setting in tell
:param value str, the new value of the setting as a string"""
self.pshell.eval(f"set_setting('{key}', '{value}')&")
self.backend.eval(f"set_setting('{key}', '{value}')&")
def get_setting(self, key: str) -> str:
"""wrapper for pshell eval get_setting command, returns the current value for key as a string
:param key str, name of a setting in tell"""
return self.pshell.eval(f"get_setting('{key}')&")
return self.backend.eval(f"get_setting('{key}')&")
def get_mounted_sample(self) -> SampleDewarAddress | None:
"""get the current mounted sample and return a SampleDewarAddress object or None if no sample is mounted"""
ret = self.get_setting('mounted_sample_position').strip()
if not ret or len(ret) == 0:
ret = self.get_setting("mounted_sample_position").strip()
if not ret:
return None
match = re.match(r"([A-Z])(\d)(\d{1,2})", ret)
if match:
segment, puck, sample = match.groups()
dewar_location = DewarAddress(segment=segment, pos=int(puck))
return SampleDewarAddress(puck=dewar_location, pin=int(sample))
else:
logger.warning(f"Failed to decode mounted sample position: {ret}")
return None
logger.warning(f"Failed to decode mounted sample position: {ret}")
return None
def get_system_check(self):
"""returns the current system check status"""
return self.pshell.eval("system_check_msg()&")
return self.backend.eval("system_check_msg()&")
def get_robot_state(self):
"""returns the current robot state"""
return self.pshell.eval("robot.state&")
return self.backend.eval("robot.state&")
def get_robot_status(self):
"""returns the current robot status"""
status = self.pshell.eval("robot.take()&")
return eval(status) # FIXME ALL functions must return a valid JSON object
status = self.backend.eval("robot.take()&")
#return eval(status)
return ast.literal_eval(status)
def get_detected_pucks(self) -> List[PuckLoadedInfo]:
"""returns a list of detected pucks as PuckLoadedInfo objects"""
j = json.loads(self.pshell.eval("get_pucks_info()&"))
j = json.loads(self.backend.eval("get_pucks_info()&"))
output = []
for i in j:
if i["puckState"] == "Present":
puck_address = i["puckAddress"]
@@ -399,90 +298,77 @@ class TellClient:
PuckLoadedInfo(
puck_name=i["puckBarcode"],
location=DewarAddress(
segment=puck_address[0], pos=int(puck_address[1])
segment=puck_address[0],
pos=int(puck_address[1]),
),
),
)
return output
def get_pin_offset(self):
"""get the pin offset for the smart magnet, returns offset as a float"""
try:
offset = float(self.pshell.eval("get_pin_offset()&"))
offset = float(self.backend.eval("get_pin_offset()&"))
except Exception:
offset = 0.0
return offset
def get_current(self):
"""get the current drawn by the smart magnet, returns current as a float in mA"""
current = self.pshell.eval("smart_magnet.get_current_rb()&")
current = self.backend.eval("smart_magnet.get_current_rb()&")
return float(current)
def set_current(self, current: float) -> float:
"""set the current drawn by the smart magnet, returns current as a float in mA"""
self.pshell.eval("smart_magnet.set_current({:.1f})&".format(current))
current = self.pshell.eval("smart_magnet.get_current_rb()&")
self.backend.eval("smart_magnet.set_current({:.1f})&".format(current))
current = self.backend.eval("smart_magnet.get_current_rb()&")
return float(current)
def is_powered(self):
"""returns True if the robot is powered on"""
return self.get_robot_status()["powered"]
def check_enable_motion(self):
"""check if the robot is powered on and enable motion if not"""
if not self.is_powered():
self.pshell.eval("enable_motion()&")
self.backend.eval("enable_motion()&")
def is_in_cold(self):
"""Compare current robot position to the set cold position. Returns True if in cold position, False otherwise."""
return self.is_position(POSITION_COLD)
def is_position(self, position: str) -> bool:
"""Compare current robot position to a given position. Returns True if in position, False otherwise."""
return position == self.get_robot_status()["pos"]
def is_ready(self):
"""returns True if the robot is ready to receive commands"""
return "ready" == self.get_state().lower()
def is_busy(self):
"""returns True if the robot is busy"""
return "busy" == self.get_state().lower()
def check_smart_magnet_mounted(self, timeout: float = 10.0, idle_time: float = 1.0, interval: float = 0.1):
"""Reads smart_magent state and tries to infer if a sample is present
Handles: PAUSED, Fault, Busy and Ready states.
Raises a ManualMountException is the amgnet indicates a sample is present but get_mounted_sample is None.
Raises a SmartMagnetFaultException if the magnet detects no sample but the robot thinks a sample is mounted"""
#TODO tidy up
initial_state = self.pshell.eval("smart_magnet.state&")
#Not sure why unused, potentially can remove them
_ = (timeout, idle_time, interval)
initial_state = self.backend.eval("smart_magnet.state&")
logger.debug(f"checking smart magnet_initial state: {initial_state}")
if initial_state == "Paused":
self.pshell.eval("smart_magnet.set_supress(False)&")
self.pshell.eval("smart_magnet.set_resting_current()&")
self.backend.eval("smart_magnet.set_supress(False)&")
self.backend.eval("smart_magnet.set_resting_current()&")
elif initial_state == "Fault":
logger.error(f"tell smart magnet is in unknown state {initial_state}")
raise SmartMagnetFaultException
state = self.pshell.eval("smart_magnet.state&")
state = self.backend.eval("smart_magnet.state&")
try:
if state == "Busy":
logger.debug('state busy')
self.pshell.eval("smart_magnet.set_supress(True)&")
self.pshell.eval("smart_magnet.state&")
sample_present = True
logger.debug("state busy")
self.backend.eval("smart_magnet.set_supress(True)&")
self.backend.eval("smart_magnet.state&")
if self.get_mounted_sample() is None:
logger.warning("Check mount: A manually mounted sample is detected.")
logger.warning("Remove before mounting with the robot.")
raise ManualMountException
return True
elif state == "Ready":
logger.debug('No sample detected, ready to mount')
sample_present = False
logger.debug("No sample detected, ready to mount")
if self.get_mounted_sample():
logger.error("Check mount: No sample detected, but robot thinks is mounted")
raise SmartMagnetFaultException
@@ -491,174 +377,20 @@ class TellClient:
logger.debug("Smart magnet detection is paused")
return None
else:
self.pshell.eval("smart_magnet.set_supress(True)&")
self.backend.eval("smart_magnet.set_supress(True)&")
logger.error(f"Tell smart magnet is in unknown state {state}")
raise SmartMagnetFaultException
except Exception as e:
logger.error(f"check_smart_magnet_mounted failed: {e}")
raise e
class SimTellClient:
"""
Simulation-only Tell client.
Keeps behavior deterministic-ish and stateful without needing PShellClient.
Implement more methods as your callers need them.
"""
def __init__(self):
self._state = "Ready"
self._last_cmd_id = 1000
self._mounted_sample: str = ""
self._simulated_samples_info = {}
self._simulated_detected_pucks = []
self._simulated_current = 30.0
self._simulated_suppress = True
self._simulated_offset = 0.0
@property
def url(self):
return None
def _next_cmd_id(self) -> int:
self._last_cmd_id += 1
return self._last_cmd_id
def get_state(self) -> str:
return self._state
def is_ready(self) -> bool:
return self._state.lower() == "ready"
def is_busy(self) -> bool:
return self._state.lower() == "busy"
def wait_ready(self, timeout: float = 360.0):
# Keep it simple: flip to Ready quickly.
time.sleep(0.05)
self._state = "Ready"
def mount(
self,
address: SampleDewarAddress,
force: bool = False,
read_dm: bool = False,
auto_unmount: bool = False,
wait: bool = False,
timeout: float = 600.0,
):
SampleDewarAddress.model_validate(address)
if self.is_busy():
raise TellCommandWhileBusyException("mount received while robot is busy")
cmd_id = self._next_cmd_id()
self._state = "Busy"
segment = address.puck.segment
puck = address.puck.pos
sample = address.pin
self._mounted_sample = f"{segment}{puck}{sample}"
if wait:
self.wait_ready(timeout=timeout)
else:
# quickly become ready anyway, but asynchronously-ish
time.sleep(0.01)
self._state = "Ready"
return cmd_id
def unmount(self, force: bool = False, wait: bool = False, timeout: float = 360.0):
if self.is_busy():
raise TellCommandWhileBusyException("unmount received while robot is busy")
cmd_id = self._next_cmd_id()
self._state = "Busy"
self._mounted_sample = ""
if wait:
self.wait_ready(timeout=timeout)
else:
time.sleep(0.01)
self._state = "Ready"
return cmd_id
def get_mounted_sample(self) -> SampleDewarAddress | None:
ret = self._mounted_sample
if not ret:
return None
match = re.match(r"([A-Z])(\d)(\d{1,2})", ret)
if not match:
return None
segment, puck, sample = match.groups()
return SampleDewarAddress(puck=DewarAddress(segment=segment, pos=int(puck)), pin=int(sample))
class TellClientProxy:
"""
Lazy-connecting Tell client proxy that retries periodically.
- Server can start even if TELL is down.
- First use triggers connect; failures raise TellCommunicationError.
"""
def __init__(self, bl: MXBeamline, *, retry_interval_s: float = 2.0):
self._bl = bl
self._client: TellClient | None = None
self._retry_interval_s = float(retry_interval_s)
self._last_attempt_ts = 0.0
self._last_error: Exception | None = None
def _get_client(self) -> TellClient:
if self._client is not None:
return self._client
now = time.monotonic()
if now - self._last_attempt_ts < self._retry_interval_s and self._last_error is not None:
raise self._last_error
self._last_attempt_ts = now
try:
self._client = TellClient(self._bl)
self._last_error = None
return self._client
except TellCommunicationError as e:
self._last_error = e
raise
except Exception as e:
wrapped = TellCommunicationError(
"TELL connection failed",
operation="CONNECT",
)
self._last_error = wrapped
raise wrapped from e
@property
def url(self):
return self._get_client().url
# Delegate methods used by DAQ; add more as needed
def get_mounted_sample(self) -> SampleDewarAddress | None:
return self._get_client().get_mounted_sample()
def get_state(self):
return self._get_client().get_state()
def wait_not_busy(self, timeout: float = 360.0):
return self._get_client().wait_not_busy(timeout=timeout)
def check_enable_motion(self):
return self._get_client().check_enable_motion()
def set_in_mount_position(self, value):
return self._get_client().set_in_mount_position(value)
def mount(self, *args, **kwargs):
return self._get_client().mount(*args, **kwargs)
def unmount(self, *args, **kwargs):
return self._get_client().unmount(*args, **kwargs)
def abort_cmd(self):
return self._get_client().abort_cmd()
def make_tell_client(bl: MXBeamline) -> TellClient | SimTellClient | TellClientProxy:
def make_tell_client(bl: MXBeamline) -> TellClient:
if bl == MXBeamline.SIMULATED:
return SimTellClient()
return TellClientProxy(bl, retry_interval_s=2.0)
backend = SimTellBackend()
else:
backend = LazyTellBackend(
factory=lambda: PShellTellBackend(bl),
retry_interval_s=2.0,
)
return TellClient(bl, backend=backend)