GUI/DAQ: added a sample resync to recovery, trying to limit amount we poll Tell for current sample psoiton - work in progress.

This commit is contained in:
appleb_m committed 2026-03-10 11:55:46 +01:00
1 parent cd04619814
commit 50b8dbf054
4 files changed
+228 -26

No files matched your search

+80 -8
View File
@@ -64,6 +64,8 @@ class AareDAQ:
self.__saved_box = None
self._smargon_trace_path = Path("logs") / "smargon_trace.csv"
self._face_detection_progress_cb: Callable[[dict], None] | None = None
self._last_sample_sync_ts = 0.0
self._sample_sync_min_interval_s = 2.0
def set_face_detection_progress_callback(self, cb: Callable[[dict], None] | None) -> None:
self._face_detection_progress_cb = cb
@@ -109,6 +111,80 @@ class AareDAQ:
except Exception as e:
logger.warning(f"Failed to append smargon trace: {e}")
def _sample_matches_mounted_address(
self,
sample: SampleShortInfo | None,
mounted_address,
) -> bool:
if sample is None or sample.location is None or mounted_address is None:
return False
return (
sample.location.segment == mounted_address.puck.segment
and sample.location.pos == mounted_address.puck.pos
and sample.pin == mounted_address.pin
)
def _find_sample_by_mounted_address(self, mounted_address) -> SampleShortInfo | None:
for sample in self.__cfg.spreadsheet.s:
if self._sample_matches_mounted_address(sample, mounted_address):
return sample
for sample in self.__cfg.reference_tools.s:
if self._sample_matches_mounted_address(sample, mounted_address):
return sample
return None
def _placeholder_sample_from_mounted_address(self, mounted_address) -> SampleShortInfo:
return SampleShortInfo(
db_id=-1,
puck_name="",
dewar_name="",
sample_name=f"Mounted sample {mounted_address.puck.segment}{mounted_address.puck.pos}-{mounted_address.pin}",
run_number=0,
user="",
pin=mounted_address.pin,
location=mounted_address.puck,
)
def sync_current_sample_from_tell(self, force: bool = False) -> SampleShortInfo | None:
current_sample = self.__cfg.current_sample
if current_sample is not None and current_sample.location is None:
return current_sample
now = time.monotonic()
if not force and (now - self._last_sample_sync_ts) < self._sample_sync_min_interval_s:
return current_sample
self._last_sample_sync_ts = now
mounted_address = self.__devs.tell.get_mounted_sample()
if mounted_address is None:
if current_sample is not None and current_sample.location is not None:
logger.warning("TELL reports no mounted sample; clearing cached current_sample")
self.__cfg.current_sample = None
return self.__cfg.current_sample
if self._sample_matches_mounted_address(current_sample, mounted_address):
return current_sample
resolved_sample = self._find_sample_by_mounted_address(mounted_address)
if resolved_sample is None:
resolved_sample = self._placeholder_sample_from_mounted_address(mounted_address)
logger.warning(
"Mounted sample from TELL was not found in known sample lists; using placeholder",
extra={"mounted_address": str(mounted_address)},
)
else:
logger.info(
f"Reconciled cached sample from TELL to {resolved_sample.sample_name}",
extra={"db_id": resolved_sample.db_id},
)
self.__cfg.current_sample = resolved_sample
return resolved_sample
@property
def state(self) -> BeamlineStateEnum:
return self.__cfg.state
@@ -272,11 +348,6 @@ class AareDAQ:
@property
def sample(self) -> SampleShortInfo | None:
#TODO figure out why this works and what it should be
if self.__cfg.current_sample is not None and self.__cfg.current_sample.location is None:
return self.__cfg.current_sample
if self.__devs.tell.get_mounted_sample() is None:
return None
return self.__cfg.current_sample
@property
@@ -347,6 +418,7 @@ class AareDAQ:
self.__devs.tell.set_in_mount_position(True)
if target is None:
self.__devs.tell.unmount(wait=True, timeout=60.0)
self.__cfg.current_sample = None
else:
value = self.__devs.tell.mount(address=target.tell_address(), force=True, auto_unmount=True, read_dm=False,
wait=True, timeout=360.0)
@@ -1553,12 +1625,12 @@ class AareDAQ:
Return (sample, tell_connected, tell_error) without raising.
"""
try:
return self.sample, True, None
return self.sync_current_sample_from_tell(), True, None
except TellCommunicationError as e:
return None, False, str(e)
return self.__cfg.current_sample, False, str(e)
except Exception as e:
# Keep status flowing even if Tell code throws something unexpected
return None, False, f"TELL unavailable: {e}"
return self.__cfg.current_sample, False, f"TELL unavailable: {e}"
def _safe_geom(self) -> tuple[SampleGeometryModel, bool, str | None]:
"""
+20 -6
View File
@@ -34,7 +34,7 @@ from aare.common.exception_handler import (
SampleException,
UserRightsException,
)
logger = setup_logger("aareDAQ")
app = FastAPI()
register_exception_handlers(app)
@@ -45,10 +45,14 @@ bl = mx_beamline()
cfg = BeamlineConfig(bl)
daq = AareDAQ(cfg, bl)
try:
daq.sync_current_sample_from_tell(force=True)
except Exception as e:
logger.warning(f"Initial sample sync from TELL failed: {e}")
_all_pgroups_cache: dict[str, tuple[list[str], float]] = {}
_ALL_PGROUPS_TTL_S = 60.0 # adjust TTL as needed
logger = setup_logger("aareDAQ")
_face_detection_state: dict = {
"seq": 0,
"running": False,
@@ -355,6 +359,16 @@ async def manual(s: SampleShortInfo, token: str = Depends(oauth2_scheme)):
daq.create_sample(s)
print(f"DB ID after creating {s.db_id}")
@app.post("/sample/resync")
async def sample_resync(token: str = Depends(oauth2_scheme)) -> dict:
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.sync_current_sample_from_tell(force=True)
logger.info("TELL sample cache resynced via API request.")
return {
"ok": True,
"message": "TELL sample cache resynced.",
}
def get_spreadsheet(data: TokenData) -> SampleShortInfoList:
if data.staff:
@@ -454,7 +468,7 @@ async def maintenance(token: str = Depends(oauth2_scheme)) -> str:
@app.post("/access/take_over_beamline")
async def take_over_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> str:
data = auth.parse_token(token)
auth.check_jwt_staff(cfg, data)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
auth.force_current_sesion(cfg, data)
logger.warning(
@@ -466,7 +480,7 @@ async def take_over_beamline(payload: RecoveryActionRequest, token: str = Depend
@app.post("/state/free_beamline")
async def free_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> str:
data = auth.parse_token(token)
auth.check_jwt_staff(cfg, data)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
cfg.state_busy = False
logger.warning(
@@ -478,7 +492,7 @@ async def free_beamline(payload: RecoveryActionRequest, token: str = Depends(oau
@app.post("/recovery/recover_beamline")
async def recover_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff(cfg, data)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
sample_mounted = _sample_is_mounted()
@@ -509,7 +523,7 @@ async def recover_beamline(payload: RecoveryActionRequest, token: str = Depends(
@app.post("/recovery/unmount_sample")
async def recovery_unmount_sample(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> dict:
data = auth.parse_token(token)
auth.check_jwt_staff(cfg, data)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
auth.force_current_sesion(cfg, data)
@@ -72,6 +72,20 @@ class RecoveryPanel(QWidget):
)
layout.addWidget(self._status)
self._last_action = QLabel("Last action: -", self)
self._last_action.setWordWrap(True)
self._last_action.setStyleSheet(
"QLabel {"
" background: #eef6ff;"
" color: #12406a;"
" border: 1px solid #a8c7e6;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
layout.addWidget(self._last_action)
self._take_over_btn = QPushButton("Take over beamline", self)
self._take_over_btn.setStyleSheet(
"QPushButton {"
@@ -126,9 +140,24 @@ class RecoveryPanel(QWidget):
self._recovery_unmount_btn.clicked.connect(self._recovery_unmount_sample)
layout.addWidget(self._recovery_unmount_btn)
self._resync_sample_btn = QPushButton("Resync sample from TELL", self)
self._resync_sample_btn.setStyleSheet(
"QPushButton {"
" background: #eef6ff;"
" color: #12406a;"
" border: 1px solid #a8c7e6;"
" border-radius: 6px;"
" padding: 8px;"
" font-weight: 600;"
"}"
)
self._resync_sample_btn.clicked.connect(self._resync_sample)
layout.addWidget(self._resync_sample_btn)
layout.addStretch(1)
self._daq.update.connect(self._set_daq_status)
self._daq.sample_resync_completed.connect(self._set_last_action)
self._refresh_buttons()
def _prompt_recovery_code(self, action_name: str) -> str | None:
@@ -185,6 +214,9 @@ class RecoveryPanel(QWidget):
"" if beamline_busy else "Disabled because beamline does not currently appear busy."
)
self._resync_sample_btn.setEnabled(True)
self._resync_sample_btn.setToolTip("Force a one-shot sample reconciliation against TELL.")
def _confirm(self, title: str, msg: str) -> bool:
reply = QMessageBox.warning(
self,
@@ -211,6 +243,7 @@ class RecoveryPanel(QWidget):
code = self._prompt_recovery_code("Take over beamline")
if not code:
return
self._last_action.setText("Last action: Taking over beamline session...")
self._daq.take_over_beamline(code)
@Slot()
@@ -223,6 +256,7 @@ class RecoveryPanel(QWidget):
code = self._prompt_recovery_code("Free beamline")
if not code:
return
self._last_action.setText("Last action: Clearing beamline busy flag...")
self._daq.free_beamline(code)
@Slot()
@@ -246,6 +280,7 @@ class RecoveryPanel(QWidget):
code = self._prompt_recovery_code("Recover beamline")
if not code:
return
self._last_action.setText("Last action: Recovering beamline to Maintenance...")
self._daq.recover_beamline(code)
@Slot()
@@ -260,8 +295,18 @@ class RecoveryPanel(QWidget):
code = self._prompt_recovery_code("Unmount sample (recovery)")
if not code:
return
self._last_action.setText("Last action: Performing recovery sample unmount...")
self._daq.recovery_unmount_sample(code)
@Slot()
def _resync_sample(self) -> None:
self._last_action.setText("Last action: Resyncing sample cache from TELL...")
self._daq.resync_sample()
@Slot(str)
def _set_last_action(self, message: str) -> None:
self._last_action.setText(f"Last action: {message}")
class BeamlineRecoveryDialog(QDialog):
def __init__(self, *, daq: DAQWorker, parent=None):
+83 -12
View File
@@ -39,6 +39,7 @@ class DAQWorker(QObject):
staff_pgroups_loaded = Signal(list)
fluorimeter_update = Signal(list, list, int)
fluorimeter_spectrum_update = Signal(FluorescenceSpectrumOutputModel)
sample_resync_completed = Signal(str)
error_codes_loaded = Signal(dict)
last_error_payload_changed = Signal(dict)
@@ -361,6 +362,32 @@ class DAQWorker(QObject):
reply.deleteLater()
def _handle_sample_resync_response(self, reply: QNetworkReply):
try:
response_data = self.handle_response(reply)
payload = json.loads(response_data) if response_data else {}
message = str(payload.get("message") or "TELL sample cache resynced.")
logger.info(message)
self.status_message.emit(message, False)
self.sample_resync_completed.emit(message)
self.send_status_request()
except Exception as e:
logger.error(f"Sample resync failed: {e}")
self.http_error.emit(str(e))
def _handle_recovery_action_response(self, reply: QNetworkReply, default_message: str):
try:
response_data = self.handle_response(reply)
payload = json.loads(response_data) if response_data else {}
message = str(payload.get("message") or default_message)
logger.info(message)
self.status_message.emit(message, False)
self.recovery_action_completed.emit(message)
self.send_status_request()
except Exception as e:
logger.error(f"Recovery action failed: {e}")
self.http_error.emit(str(e))
def generic_post(self, url: str, body: str = ""):
if self.__base_url is None:
logger.info(f"POST /{url}: {body}")
@@ -456,30 +483,62 @@ class DAQWorker(QObject):
@Slot(str)
def free_beamline(self, confirmation_code: str):
self.generic_post(
"state/free_beamline",
json.dumps({"confirmation_code": confirmation_code}),
if self.__base_url is None:
logger.info("POST /state/free_beamline")
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/state/free_beamline"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
body = json.dumps({"confirmation_code": confirmation_code})
reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(
lambda: self._handle_recovery_action_response(reply, "Beamline busy flag cleared.")
)
@Slot(str)
def take_over_beamline(self, confirmation_code: str):
self.generic_post(
"access/take_over_beamline",
json.dumps({"confirmation_code": confirmation_code}),
if self.__base_url is None:
logger.info("POST /access/take_over_beamline")
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/access/take_over_beamline"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
body = json.dumps({"confirmation_code": confirmation_code})
reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(
lambda: self._handle_recovery_action_response(reply, "Beamline session taken over.")
)
@Slot(str)
def recover_beamline(self, confirmation_code: str):
self.generic_post(
"recovery/recover_beamline",
json.dumps({"confirmation_code": confirmation_code}),
if self.__base_url is None:
logger.info("POST /recovery/recover_beamline")
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/recovery/recover_beamline"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
body = json.dumps({"confirmation_code": confirmation_code})
reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(
lambda: self._handle_recovery_action_response(reply, "Beamline recovered to Maintenance.")
)
@Slot(str)
def recovery_unmount_sample(self, confirmation_code: str):
self.generic_post(
"recovery/unmount_sample",
json.dumps({"confirmation_code": confirmation_code}),
if self.__base_url is None:
logger.info("POST /recovery/unmount_sample")
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/recovery/unmount_sample"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
body = json.dumps({"confirmation_code": confirmation_code})
reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(
lambda: self._handle_recovery_action_response(reply, "Recovery unmount completed.")
)
@Slot(str)
@@ -732,6 +791,18 @@ class DAQWorker(QObject):
def beam_size_mm(self, x: float, y: float):
self.generic_post(f"beamline/beam_size_mm?x={x}&y={y}")
@Slot()
def resync_sample(self):
if self.__base_url is None:
logger.info("POST /sample/resync")
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/sample/resync"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
reply = self.__net_manager.post(request, QByteArray(b""))
reply.finished.connect(lambda: self._handle_sample_resync_response(reply))
@Slot()
def unmount(self):
self.generic_post("sample/unmount")