fix: read the aareDB transmission as the percentage it is
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
DataCollectionParameters.transmission is a StrictInt documented as "positive, between 0 and 100" (aareDB's own API description of the spreadsheet payload), while every model that consumes it wants a 0-to-1 fraction. The old "divide by 100 only when above 1.0" guess therefore turned a spreadsheet asking for 1% into a scan at 100% transmission - a hundredfold dose on the sample. A fraction cannot be stored in that column at all: the model rejects 0.2. The mapping test now builds a real DataCollectionParameters instead of a stand-in namespace, so a renamed column fails the test rather than the beamline. That is what let 'totalrange' through. daq.py's spreadsheet_params and get_auto_raster_params still carry both the old column name and the same transmission guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHoUkj66jxByS2ypY5h9Mn
This commit is contained in:
@@ -86,9 +86,11 @@ class SampleParameters:
|
||||
params = None if sample is None else sample.aaredb_params
|
||||
if params is None:
|
||||
return cls()
|
||||
# aareDB holds transmission as a percentage (DataCollectionParameters
|
||||
# .transmission is an int, 0 to 100); everything here works in the
|
||||
# 0-to-1 fraction the scan requests are built from.
|
||||
transmission = _spreadsheet_float(params, "transmission")
|
||||
if transmission is not None and transmission > 1.0:
|
||||
# aareDB holds transmission either as a fraction or as a percentage
|
||||
if transmission is not None:
|
||||
transmission = transmission / 100.0
|
||||
return cls(
|
||||
resolution_a=_spreadsheet_float(params, "targetresolution"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -14,6 +15,17 @@ from aare.gui.constants import LOGGER_NAME
|
||||
|
||||
logger = setup_logger(LOGGER_NAME)
|
||||
|
||||
# Frames handed to the GUI thread but not painted yet. The image signal is a
|
||||
# queued connection, so without a cap the subscriber hands over frames faster
|
||||
# than they can be painted and the Qt event queue grows without bound - lag
|
||||
# that no amount of draining on the socket can fix.
|
||||
MAX_FRAMES_IN_FLIGHT = 1
|
||||
|
||||
# The socket's own queue. Only a memory bound: _recv_latest() drops whatever
|
||||
# piled up behind the newest frame anyway. Has to stay above the number of
|
||||
# parts in a frame, or the pipe cannot assemble a multipart message.
|
||||
RCV_QUEUE_MESSAGES = 10
|
||||
|
||||
|
||||
class PredictionSubscriber(QThread):
|
||||
prediction = Signal(dict)
|
||||
@@ -30,6 +42,14 @@ class PredictionSubscriber(QThread):
|
||||
self._sock = self._ctx.socket(zmq.SUB)
|
||||
self._sock.setsockopt(zmq.RCVTIMEO, 500)
|
||||
self._sock.setsockopt(zmq.LINGER, 0)
|
||||
self._sock.setsockopt(zmq.RCVHWM, RCV_QUEUE_MESSAGES)
|
||||
|
||||
# Delivery of an image frees the slot for the next one. Queued back to
|
||||
# the thread this object lives in (the GUI thread), like the painting
|
||||
# slots, so it runs once the GUI has worked through the frame.
|
||||
self._frames_in_flight = 0
|
||||
self._frames_in_flight_lock = Lock()
|
||||
self.image.connect(self._on_image_delivered)
|
||||
|
||||
self._emit_images = True
|
||||
self.running = True
|
||||
@@ -62,6 +82,37 @@ class PredictionSubscriber(QThread):
|
||||
def set_emit_images(self, enabled: bool) -> None:
|
||||
self._emit_images = enabled
|
||||
|
||||
def _recv_latest(self) -> tuple[list[bytes], int]:
|
||||
"""The newest frame on the socket, plus how many older ones were
|
||||
dropped to get to it. This is what CONFLATE would do, except that
|
||||
CONFLATE keeps a single message *part* and every frame here is
|
||||
multipart (header, image, detections), so it cannot be used."""
|
||||
sock = self._sock
|
||||
if sock is None: # run() has already torn the socket down
|
||||
raise zmq.Again
|
||||
parts = sock.recv_multipart()
|
||||
dropped = 0
|
||||
while True:
|
||||
try:
|
||||
parts = sock.recv_multipart(zmq.NOBLOCK)
|
||||
except zmq.Again:
|
||||
return parts, dropped
|
||||
dropped += 1
|
||||
|
||||
@Slot(QPixmap)
|
||||
def _on_image_delivered(self, _pixmap: QPixmap) -> None:
|
||||
with self._frames_in_flight_lock:
|
||||
self._frames_in_flight = max(0, self._frames_in_flight - 1)
|
||||
|
||||
def _gui_ready_for_frame(self) -> bool:
|
||||
with self._frames_in_flight_lock:
|
||||
return self._frames_in_flight < MAX_FRAMES_IN_FLIGHT
|
||||
|
||||
def _emit_image(self, pixmap: QPixmap) -> None:
|
||||
with self._frames_in_flight_lock:
|
||||
self._frames_in_flight += 1
|
||||
self.image.emit(pixmap)
|
||||
|
||||
def _set_camera_available(self, available: bool, error: str | None = None) -> None:
|
||||
if available != self._camera_available:
|
||||
self._camera_available = available
|
||||
@@ -152,12 +203,10 @@ class PredictionSubscriber(QThread):
|
||||
self.focus_measure.emit(sharpness)
|
||||
|
||||
def run(self):
|
||||
self._debug_last_log_ts = time.perf_counter()
|
||||
self._debug_msg_count = 0
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
parts = self._sock.recv_multipart()
|
||||
parts, dropped = self._recv_latest()
|
||||
except zmq.Again:
|
||||
now = time.perf_counter()
|
||||
elapsed = now - self._fps_window_start
|
||||
@@ -179,7 +228,8 @@ class PredictionSubscriber(QThread):
|
||||
|
||||
now = time.perf_counter()
|
||||
self._last_frame_time = now
|
||||
self._fps_frame_count += 1
|
||||
# dropped frames count too: this is the camera's rate, not ours
|
||||
self._fps_frame_count += 1 + dropped
|
||||
|
||||
elapsed = now - self._fps_window_start
|
||||
if elapsed >= self._fps_emit_period_s:
|
||||
@@ -211,7 +261,12 @@ class PredictionSubscriber(QThread):
|
||||
target = next((d for d in json_dicts if "target_point" in d), None)
|
||||
image_bytes = max(non_json_parts, key=len) if non_json_parts else None
|
||||
|
||||
if self._emit_images and header and image_bytes:
|
||||
# The decode is skipped along with the emit: a frame the GUI
|
||||
# is too busy to paint is not worth decoding. The sharpness
|
||||
# read-out is the exception, it works on the decoded image.
|
||||
emit_image = self._emit_images and self._gui_ready_for_frame()
|
||||
|
||||
if header and image_bytes and (emit_image or self._measure_focus):
|
||||
rgb = self._decode_rgb_image(header, image_bytes)
|
||||
if rgb is None:
|
||||
self._set_camera_available(
|
||||
@@ -220,9 +275,9 @@ class PredictionSubscriber(QThread):
|
||||
else:
|
||||
self._set_camera_available(True)
|
||||
self._emit_focus_measure_if_enabled(rgb)
|
||||
if self.running:
|
||||
self.image.emit(self._rgb_to_pixmap(rgb))
|
||||
elif self._emit_images:
|
||||
if emit_image and self.running:
|
||||
self._emit_image(self._rgb_to_pixmap(rgb))
|
||||
elif self._emit_images and not (header and image_bytes):
|
||||
self._set_camera_available(
|
||||
False, "Sample camera feed unavailable: no frame header in zmq stream"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any, cast
|
||||
import pytest
|
||||
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
|
||||
from aarecommon.math.diffraction_geometry import DiffractionGeometry
|
||||
from aarecommon.models.models import BeamlineStateEnum, SampleGeometryModel
|
||||
from aarecommon.models.models import (
|
||||
BeamlineStateEnum,
|
||||
DataCollectionParameters,
|
||||
SampleGeometryModel,
|
||||
SampleShortInfo,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from aare.gui.panels.data_collection_settings import DataCollectionSettings
|
||||
@@ -43,14 +48,23 @@ def _mount(panel, **params):
|
||||
|
||||
|
||||
def test_sample_parameters_translate_the_spreadsheet_row():
|
||||
params = types.SimpleNamespace(
|
||||
targetresolution=1.5,
|
||||
transmission=20.0, # percent
|
||||
totalangle="180", # spreadsheet cells can arrive as text
|
||||
oscillation=0.1,
|
||||
exposure=0.02,
|
||||
# The real model, so the column names are checked against aareDB and not
|
||||
# against a stand-in that agrees with whatever this file happens to say.
|
||||
sample = SampleShortInfo(
|
||||
db_id=1,
|
||||
puck_name="puck1",
|
||||
dewar_name="dewar1",
|
||||
sample_name="sample1",
|
||||
run_number=1,
|
||||
pin=1,
|
||||
aaredb_params=DataCollectionParameters(
|
||||
targetresolution=1.5,
|
||||
transmission=20, # aareDB stores a percentage
|
||||
totalangle=180,
|
||||
oscillation=0.1,
|
||||
exposure=0.02,
|
||||
),
|
||||
)
|
||||
sample = cast(Any, types.SimpleNamespace(aaredb_params=params))
|
||||
assert SampleParameters.from_sample(sample) == SampleParameters(
|
||||
resolution_a=1.5,
|
||||
transmission=0.2,
|
||||
|
||||
Reference in New Issue
Block a user