DAQ/GUI: feature added - fluorimeter panel and device

This commit is contained in:
2025-11-11 11:43:31 +01:00
parent 209b0a279b
commit 0ed47e7d4a
7 changed files with 515 additions and 1 deletions
+15
View File
@@ -1524,3 +1524,18 @@ class AareDAQ:
self.__devs.anneal(time_s)
finally:
self.__cfg.state_busy = False
def fluorimeter_start(self, erase: bool = False):
self.__devs.fluorimeter.start_acquisition(erase=erase)
def fluorimeter_stop(self):
self.__devs.fluorimeter.stop_acquisition()
def fluorimeter_status(self) -> int | None:
return self.__devs.fluorimeter.check_status()
def fluorimeter_data(self):
return self.__devs.fluorimeter.get_current_data()
def fluorimeter_background(self):
return self.__devs.fluorimeter.get_current_background()
+4
View File
@@ -9,6 +9,7 @@ from mxlibs3 import aerotech, smargon
from mxlibs3.area_detector import epicsAD
from mxlibs3.enum_pv import EnumPv
from mxlibs3.filter_transmission import FilterTransmission
from mxlibs3.fluorimeter import Fluorimeter
from mxlibs3.my_motor import MyMotor
from mxlibs3.non_standard import NonStandard
from mxlibs3.tell_client import TellClient
@@ -175,6 +176,9 @@ class BeamlineDevices:
self.sample_cam = epicsAD(f"{BEAMLINE}-SAMCAM:")
self.fluorimeter = Fluorimeter(beamline)
@property
def lamp_light(self) -> float:
return self.__lamp_light.value
+73 -1
View File
@@ -2,7 +2,7 @@ import asyncio
import io
import os, time
from typing import Tuple, AsyncGenerator
import json
import cv2
import urllib3
import uvicorn
@@ -546,6 +546,78 @@ async def get_all_pgroups(token: str = Depends(oauth2_scheme)):
logger.error(f"Failed to list pgroups: {e}")
return []
@app.post("/fluorimeter/start")
async def fluorimeter_start(erase: bool = False, token: str = Depends(oauth2_scheme)) -> str:
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.fluorimeter_start(erase=erase)
return "OK"
@app.post("/fluorimeter/stop")
async def fluorimeter_stop(token: str = Depends(oauth2_scheme)) -> str:
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.fluorimeter_stop()
return "OK"
@app.get("/fluorimeter/status")
async def fluorimeter_status(token: str = Depends(oauth2_scheme)) -> int | None:
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.fluorimeter_status()
@app.get("/fluorimeter/data")
async def fluorimeter_data(token: str = Depends(oauth2_scheme)) -> list[int] | None:
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.fluorimeter_data()
@app.get("/fluorimeter/background")
async def fluorimeter_background(token: str = Depends(oauth2_scheme)) -> list[int] | None:
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.fluorimeter_background()
# Optional: SSE stream for live spectra while acquiring
async def fluorimeter_stream() -> AsyncGenerator[str, None]:
logger.debug("start fluorimeter stream...")
try:
while True:
s = daq.fluorimeter_status()
d = daq.fluorimeter_data()
b = daq.fluorimeter_background()
# Ensure JSON-serializable lists; avoid numpy truthiness
status = "stopped" if s == 0 else "running"
logger.debug(f"fluorimeter status: {status}. got data from fluorimeter: {d[0]} and background: {b[0]}")
data = d.tolist() if hasattr(d, "tolist") else (list(d) if isinstance(d, (tuple, list)) else ([] if d is None else [d]))
bkg = b.tolist() if hasattr(b, "tolist") else (list(b) if isinstance(b, (tuple, list)) else ([] if b is None else [b]))
logger.debug(f"fluorimeter data: {data[0]}. fluorimeter background: {bkg[0]}")
payload = json.dumps({"status": s, "data": data, "background": bkg}, separators=(",", ":"))
yield f"data: {payload}\n\n"
if s == 0:
await asyncio.sleep(0.2)
logger.debug("fluorimeter stream stopped")
break
await asyncio.sleep(0.2)
except asyncio.CancelledError as e:
logger.error(f">>> Fluorimeter stream cancelled: {e}")
return
@app.get("/sse/fluorimeter")
async def sse_fluorimeter(token: str = Depends(oauth2_scheme)):
auth.check_jwt_ro(cfg, auth.parse_token(token))
try:
daq.fluorimeter_start(erase=False)
except Exception:
# Ignore if already running or start not needed
pass
return StreamingResponse(
fluorimeter_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control"
}
)
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
+167
View File
@@ -0,0 +1,167 @@
import time
from typing import Callable, cast
from epics import PV, poll
from epics.ca import pend_io
from aaredaqlib.beamline import MXBeamline
from aaredaqlib.logger_config import setup_logger
logger = setup_logger("aareaDAQ")
class Fluorimeter(object):
def __init__(self, beamline: MXBeamline, **kwargs):
BEAMLINE = beamline.value.upper()
self.__start = PV(f"{BEAMLINE}-ES-SiD:mca1Start") #0 done ,1 start
self.__stop = PV(f"{BEAMLINE}-ES-SiD:mca1Stop") #0 done ,1 stop
self.__erase_and_start = PV(f"{BEAMLINE}-ES-SiD:mca1EraseStart") #0 done,1 start
self.__erase = PV(f"{BEAMLINE}-ES-SiD:mca1Erase") #0 done,1 erase
self.__preset_mode = PV(f"{BEAMLINE}-ES-SiD:dxp1:PresetMode") # set mode
self.__status = PV(f"{BEAMLINE}-ES-SiD:mca1.ACQG") # 0 done, 1 acquire
self.__real_time = PV(f"{BEAMLINE}-ES-SiD:mca1.PRTM") #float
self.__live_time = PV(f"{BEAMLINE}-ES-SiD:mca1.PLTM") #float
self.__elapsed_real_time = PV(f"{BEAMLINE}-ES-SiD:mca1.ERTM") #float
self.__elapsed_live_time = PV(f"{BEAMLINE}-ES-SiD:mca1.ELTM") #float
self.__elapsed_trigger_live_time = PV(f"{BEAMLINE}-ES-SiD:dxp1:ElapsedTriggerLiveTime")
self.__instant_dead_time = PV(f"{BEAMLINE}-ES-SiD:mca1.IDTIM")
self.__average_dead_time = PV(f"{BEAMLINE}-ES-SiD:mca1.DTIM")
self.__poll_time_rbv = PV(f"{BEAMLINE}-ES-SiD:PollTime_RBV")
self.__poll_time = PV(f"{BEAMLINE}-ES-SiD:PollTime")
self.__mca = PV(f"{BEAMLINE}-ES-SiD:mca1")
self.__data = PV(f"{BEAMLINE}-ES-SiD:mca1.VAL")
self.__background = PV(f"{BEAMLINE}-ES-SiD:mca1.BG")
self.__saveFile = PV(f"{BEAMLINE}-ES-SiD:SaveSystemFile")
self.__saveFile_name = PV(f"{BEAMLINE}-ES-SiD:SaveSystem")
self.__saveFile_rbv = PV(f"{BEAMLINE}-ES-SiD:SaveSystem_RBV") #1 ssave, 0 done
self.__roi_1 = self.get_roi(BEAMLINE, 1) #currently SiEsc
self.__roi_2 = self.get_roi(BEAMLINE, 2) #currently MnKa
def get_roi(self, bl, roi_num:int):
if roi_num not in [1, 2]:
raise ValueError(f"Invalid ROI number: {roi_num}")
roi_name = f"{bl}-ES-SiD:mca1.R{roi_num}NM"
return PV(roi_name)
def start_acquisition(self, erase: bool = False):
if erase:
self.__erase_and_start.put(1)
else:
self.__start.put(1)
def stop_acquisition(self):
self.__stop.put(1)
def erase(self):
self.__erase.put(1)
def poll_data(self, timeout_s: float = 30.0):
timeout = time.time() + timeout_s
while not self.check_status_done():
if time.time() > timeout:
self.stop_acquisition()
raise TimeoutError("timeout waiting for data to be acquired.")
self.__mca.get()
poll(0.1)
def get_current_data(self):
return self.__data.get()
def get_current_background(self):
return self.__background.get()
def check_status(self):
return self.__status.get()
def check_status_done(self):
if self.check_status() == 0:
return True
def check_status_acquiring(self):
if self.check_status() == 1:
return True
def wait_till_done(self, timeout_s: float = 30.0):
timeout = time.time() + timeout_s
while not self.check_status_done():
if time.time() > timeout:
raise TimeoutError("timeout waiting for done.")
poll(0.1)
def wait_till_acquiring(self, timeout_s: float = 30.0):
timeout = time.time() + timeout_s
while not self.check_status_acquiring():
if time.time() > timeout:
raise TimeoutError("timeout waiting for acquiring.")
poll(0.1)
def set_preset_mode(self, mode: int):
""" 0 No preset, 1 Real time, 2 Live time, 3 Events, 4 Triggers"""
if mode not in [0, 1, 2, 3, 4]:
raise ValueError(f"Invalid preset mode: {mode}")
try:
self.__preset_mode.put(mode)
except Exception as e:
logger.error(f"Error setting preset mode to {mode}: {e}")
def save_file(self, filename:str|None = None, timeout_s: float = 30.0):
if not filename:
filename = f"KETEK_{time.strftime('%Y%m%d_%H%M%S')}.txt"
self.__saveFile_name.put(filename)
try:
self.__saveFile.put(1)
timeout = time.time() + timeout_s
while self.__saveFile_rbv.get() == 1:
if time.time() > timeout:
raise TimeoutError("Timed out waiting for save to complete")
poll(0.1)
except Exception as e:
logger.error(f"Error saving file {filename}: {e}")
@property
def real_time(self):
return self.__real_time.get()
@real_time.setter
def real_time(self, value):
self.__real_time.put(value)
@property
def live_time(self):
return self.__live_time.get()
@live_time.setter
def live_time(self, value):
self.__live_time.put(value)
def elapsed_real_time(self):
return self.__elapsed_real_time.get()
def elapsed_live_time(self):
return self.__elapsed_live_time.get()
def elapsed_trigger_live_time(self):
return self.__elapsed_trigger_live_time.get()
def instant_dead_time(self):
return self.__instant_dead_time.get()
def average_dead_time(self):
return self.__average_dead_time.get()
@property
def poll_time(self):
return self.__poll_time_rbv.get()
@poll_time.setter
def poll_time(self, value):
self.__poll_time.put(value)
+23
View File
@@ -36,6 +36,7 @@ from aaregui.widgets.camera_image import SampleCameraImageLabel
from aaregui.widgets.no_wheel_scroll_area import NoWheelScrollArea
from aaregui.widgets.status_bar import StatusBar
from aaregui.widgets.video_image import VideoGraphicsView
from aaregui.panels.fluoresence_panel import FluorescencePanel
logger = setup_logger("aareGUI")
@@ -186,6 +187,13 @@ class MainWindow(QMainWindow):
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.face_panel_dock)
self.face_panel_dock.hide()
self.fluor_panel = FluorescencePanel()
self.fluor_panel_dock = QDockWidget("Fluorescence", self)
self.fluor_panel_dock.setWidget(self.fluor_panel)
self.fluor_panel_dock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea | Qt.DockWidgetArea.LeftDockWidgetArea)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.fluor_panel_dock)
self.fluor_panel_dock.hide()
# Create and add the dock to your main window
self.log_dock = LogDock("Console Log", self)
self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock)
@@ -347,6 +355,14 @@ class MainWindow(QMainWindow):
self.status_bar.get_all_pgroups.connect(self.daq.get_all_pgroups)
self.daq.staff_pgroups_loaded.connect(self.status_bar.staff_pgroups_loaded)
self.fluor_panel.start.connect(self.daq.fluorimeter_start)
self.fluor_panel.start_erase.connect(self.daq.fluorimeter_start_erase)
self.fluor_panel.stop.connect(self.daq.fluorimeter_stop)
self.fluor_panel.snapshot.connect(self.daq.fluorimeter_request_snapshot)
self.fluor_panel.live.connect(self.daq.fluorimeter_start_erase)
self.fluor_panel.live.connect(self.daq.start_fluorimeter_stream)
self.daq.fluorimeter_update.connect(self.fluor_panel.update_plot)
def create_menu_bar(self):
"""Create a menu bar with File->Quit and Help->About."""
# Main menu bar
@@ -392,6 +408,13 @@ class MainWindow(QMainWindow):
self.face_panel_dock.visibilityChanged.connect(show_face_panel_action.setChecked)
view_menu.addAction(show_face_panel_action)
show_fluor_panel_action = QAction("Show fluorescence", self)
show_fluor_panel_action.setCheckable(True)
show_fluor_panel_action.setChecked(False)
show_fluor_panel_action.triggered.connect(lambda checked: self.fluor_panel_dock.setVisible(checked))
self.fluor_panel_dock.visibilityChanged.connect(show_fluor_panel_action.setChecked)
view_menu.addAction(show_fluor_panel_action)
show_log_action = QAction("Show Log", self)
show_log_action.setCheckable(True)
show_log_action.setChecked(False)
+148
View File
@@ -0,0 +1,148 @@
from PySide6.QtCore import Slot, QTimer, Signal
from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import numpy as np
from aaregui.widgets.number_line_edit import NumberLineEdit
from aaregui.widgets.title_label import TitleLabel
from aaredaqlib.logger_config import setup_logger
logger = setup_logger(__name__)
class FluorescencePanel(QWidget):
start = Signal()
start_erase = Signal()
stop = Signal()
snapshot = Signal()
live = Signal()
def __init__(self, parent=None):
super().__init__(parent)
grid = QGridLayout(self)
grid.addWidget(TitleLabel("Fluorescence", self), 0, 0, 1, 4)
self.btn_start = QPushButton("Start")
self.btn_start_erase = QPushButton("Erase+Start")
self.btn_stop = QPushButton("Stop")
self.btn_live = QPushButton("Start Live")
self.btn_snapshot = QPushButton("Snapshot")
for i, b in enumerate((self.btn_start, self.btn_start_erase, self.btn_stop, self.btn_live, self.btn_snapshot), start=1):
grid.addWidget(b, 1, i - 1)
self.fig = Figure(figsize=(5, 3), tight_layout=True)
self.ax = self.fig.add_subplot(111)
self.canvas = FigureCanvas(self.fig)
grid.addWidget(self.canvas, 2, 0, 1, 4)
# Wire buttons
self.btn_start.clicked.connect(self.start.emit)
self.btn_start_erase.clicked.connect(self.start_erase.emit)
self.btn_stop.clicked.connect(self.stop.emit)
self.btn_snapshot.clicked.connect(self.snapshot.emit)
self.btn_live.clicked.connect(self.live.emit)
@Slot(list, list, int)
def update_plot(self, data, background, status):
try:
self.ax.clear()
if data:
y = np.array(data, dtype=float)
x = np.arange(len(y), dtype=int)
self.ax.plot(x, y, lw=1.0, label="Data")
if background:
b = np.array(background, dtype=float)
xb = np.arange(len(b), dtype=int)
self.ax.plot(xb, b, lw=1.0, label="Background")
self.ax.set_xlabel("Channel")
self.ax.set_ylabel("Counts")
if self.ax.has_data():
self.ax.legend(loc="best")
self.ax.grid(True, alpha=0.3)
self.canvas.draw_idle()
except Exception as e:
logger.error(f"Update plot error: {e}")
# class FluorescencePanel(QWidget):
# def __init__(self, daq_worker, parent=None):
# super().__init__(parent)
# self._w = daq_worker
#
# lay = QGridLayout(self)
# self.btn_start = QPushButton("Start")
# self.btn_start_erase = QPushButton("Erase+Start")
# self.btn_stop = QPushButton("Stop")
# self.btn_live = QPushButton("Start Live")
# self.btn_snapshot = QPushButton("Snapshot")
# lay.addWidget(self.btn_start, 0, 0)
# lay.addWidget(self.btn_start_erase, 0, 1)
# lay.addWidget(self.btn_stop, 0, 2)
# lay.addWidget(self.btn_live, 0, 3)
# lay.addWidget(self.btn_snapshot, 0, 4)
#
# self.fig = Figure(figsize=(5, 3), tight_layout=True)
# self.ax = self.fig.add_subplot(111)
# self.canvas = FigureCanvas(self.fig)
# lay.addWidget(self.canvas, 1, 0, 1, 5)
#
# # Polling timer
# self._timer = QTimer(self)
# self._timer.setInterval(200)
# self._timer.timeout.connect(self._request_snapshot)
#
# # Wire buttons
# self.btn_start.clicked.connect(self._w.fluorimeter_start)
# self.btn_start_erase.clicked.connect(self._w.fluorimeter_start_erase)
# self.btn_stop.clicked.connect(self._on_stop_clicked)
# self.btn_snapshot.clicked.connect(self._request_snapshot)
# self.btn_live.clicked.connect(self._on_start_live)
#
# # Subscribe to data updates
# # Expected signal: fluorimeter_update(list data, list background, int status)
# self._w.fluorimeter_update.connect(self.update_plot)
#
# @Slot()
# def _on_start_live(self):
# # Start acquisition if not already running, then begin polling
# self._w.fluorimeter_start()
# self._timer.start()
#
# @Slot()
# def _on_stop_clicked(self):
# self._timer.stop()
# self._w.fluorimeter_stop()
#
# @Slot()
# def _request_snapshot(self):
# # One-shot HTTP GET to fetch data+status+background (worker emits fluorimeter_update)
# self._w.fluorimeter_request_snapshot()
#
# @Slot(list, list, int)
# def update_plot(self, data, background, status):
# # Plot and stop timer automatically when acquisition ends
# self.ax.clear()
# if data:
# y = np.array(data, dtype=float)
# x = np.arange(len(y))
# self.ax.plot(x, y, lw=1.0, label="Data")
# if background:
# b = np.array(background, dtype=float)
# xb = np.arange(len(b))
# self.ax.plot(xb, b, lw=1.0, label="Background")
# if self.ax.has_data():
# self.ax.legend(loc="best")
# self.ax.set_xlabel("Channel")
# self.ax.set_ylabel("Counts")
# self.ax.grid(True, alpha=0.3)
# self.canvas.draw_idle()
#
# # status: 1 acquiring, 0 done, other -> unknown
# if status == 0:
# self._timer.stop()
+85
View File
@@ -1,6 +1,7 @@
import copy
import random
import time
import json
from PySide6.QtCore import Signal, QUrl, Slot, QTimer, QObject, QByteArray
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -32,6 +33,7 @@ class DAQWorker(QObject):
raster_generated_by_ml = Signal(RasterGridRequest)
face_detection_result = Signal(dict)
staff_pgroups_loaded = Signal(list)
fluorimeter_update = Signal(list, list, int)
def __init__(self, base_url: str | None, token: str, parent=None):
super().__init__(parent)
@@ -507,4 +509,87 @@ class DAQWorker(QObject):
reply = self.__net_manager.post(request, QByteArray(b""))
reply.finished.connect(lambda: self._handle_face_detection_response(reply))
@Slot()
def fluorimeter_start(self):
self.generic_post("fluorimeter/start")
@Slot()
def fluorimeter_start_erase(self):
self.generic_post("fluorimeter/start?erase=true")
@Slot()
def fluorimeter_stop(self):
self.generic_post("fluorimeter/stop")
def _handle_fluorimeter_data(self, reply: QNetworkReply, emit_status: bool = False):
try:
response_data = self.handle_response(reply)
import json
data = json.loads(response_data) if response_data else []
if emit_status:
# fetch status and bkg in parallel (simple sequential here)
status_req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/status"))
status_req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
status_reply = self.__net_manager.get(status_req)
status_reply.finished.connect(lambda: self._handle_fluorimeter_status_and_emit(data, status_reply))
else:
self.fluorimeter_update.emit(data, [], -1)
except Exception as e:
logger.error(f"Fluorimeter data error: {e}")
self.http_error.emit(str(e))
def _handle_fluorimeter_status_and_emit(self, data, status_reply: QNetworkReply):
try:
s_payload = self.handle_response(status_reply)
s = int(s_payload) if s_payload not in ("", "null") else -1
b_req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/background"))
b_req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
b_reply = self.__net_manager.get(b_req)
b_reply.finished.connect(lambda: self._emit_fluorimeter_with_bkg(data, s, b_reply))
except Exception as e:
logger.error(f"Fluorimeter status error: {e}")
self.http_error.emit(str(e))
def _emit_fluorimeter_with_bkg(self, data, s, b_reply: QNetworkReply):
try:
bkg_json = self.handle_response(b_reply)
import json
bkg = json.loads(bkg_json) if bkg_json else []
self.fluorimeter_update.emit(data, bkg, s)
except Exception as e:
logger.error(f"Fluorimeter background error: {e}")
self.http_error.emit(str(e))
@Slot()
def fluorimeter_request_snapshot(self):
if self.__base_url is None:
return
req = QNetworkRequest(QUrl(f"{self.__base_url}/fluorimeter/data"))
req.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.get(req)
reply.finished.connect(lambda: self._handle_fluorimeter_data(reply, emit_status=True))
# Optional: SSE listener for live updates
def start_fluorimeter_stream(self):
if self.__base_url is None:
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/sse/fluorimeter"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.get(request)
reply.readyRead.connect(lambda: self._read_fluorimeter_stream(reply))
reply.finished.connect(lambda: reply.deleteLater())
def _read_fluorimeter_stream(self, reply: QNetworkReply):
try:
chunk = reply.readAll().data().decode("utf-8")
for line in chunk.splitlines():
if line.startswith("data:"):
payload = line[5:].strip()
if payload:
obj = json.loads(payload)
data = obj.get("data") or []
bkg = obj.get("background") or []
status = obj.get("status", -1)
self.fluorimeter_update.emit(data, bkg, status)
except Exception as e:
logger.error(f"SSE parse error: {e}")