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

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:
2026-09-07 16:59:53 +02:00
co-authored by Claude Opus 5
parent 85e0609aa4
commit daabf830d7
3 changed files with 89 additions and 18 deletions
+4 -2
View File
@@ -86,9 +86,11 @@ class SampleParameters:
params = None if sample is None else sample.aaredb_params params = None if sample is None else sample.aaredb_params
if params is None: if params is None:
return cls() 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") transmission = _spreadsheet_float(params, "transmission")
if transmission is not None and transmission > 1.0: if transmission is not None:
# aareDB holds transmission either as a fraction or as a percentage
transmission = transmission / 100.0 transmission = transmission / 100.0
return cls( return cls(
resolution_a=_spreadsheet_float(params, "targetresolution"), resolution_a=_spreadsheet_float(params, "targetresolution"),
+63 -8
View File
@@ -1,5 +1,6 @@
import json import json
import time import time
from threading import Lock
import cv2 import cv2
import numpy as np import numpy as np
@@ -14,6 +15,17 @@ from aare.gui.constants import LOGGER_NAME
logger = setup_logger(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): class PredictionSubscriber(QThread):
prediction = Signal(dict) prediction = Signal(dict)
@@ -30,6 +42,14 @@ class PredictionSubscriber(QThread):
self._sock = self._ctx.socket(zmq.SUB) self._sock = self._ctx.socket(zmq.SUB)
self._sock.setsockopt(zmq.RCVTIMEO, 500) self._sock.setsockopt(zmq.RCVTIMEO, 500)
self._sock.setsockopt(zmq.LINGER, 0) 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._emit_images = True
self.running = True self.running = True
@@ -62,6 +82,37 @@ class PredictionSubscriber(QThread):
def set_emit_images(self, enabled: bool) -> None: def set_emit_images(self, enabled: bool) -> None:
self._emit_images = enabled 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: def _set_camera_available(self, available: bool, error: str | None = None) -> None:
if available != self._camera_available: if available != self._camera_available:
self._camera_available = available self._camera_available = available
@@ -152,12 +203,10 @@ class PredictionSubscriber(QThread):
self.focus_measure.emit(sharpness) self.focus_measure.emit(sharpness)
def run(self): def run(self):
self._debug_last_log_ts = time.perf_counter()
self._debug_msg_count = 0
try: try:
while self.running: while self.running:
try: try:
parts = self._sock.recv_multipart() parts, dropped = self._recv_latest()
except zmq.Again: except zmq.Again:
now = time.perf_counter() now = time.perf_counter()
elapsed = now - self._fps_window_start elapsed = now - self._fps_window_start
@@ -179,7 +228,8 @@ class PredictionSubscriber(QThread):
now = time.perf_counter() now = time.perf_counter()
self._last_frame_time = now 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 elapsed = now - self._fps_window_start
if elapsed >= self._fps_emit_period_s: 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) 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 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) rgb = self._decode_rgb_image(header, image_bytes)
if rgb is None: if rgb is None:
self._set_camera_available( self._set_camera_available(
@@ -220,9 +275,9 @@ class PredictionSubscriber(QThread):
else: else:
self._set_camera_available(True) self._set_camera_available(True)
self._emit_focus_measure_if_enabled(rgb) self._emit_focus_measure_if_enabled(rgb)
if self.running: if emit_image and self.running:
self.image.emit(self._rgb_to_pixmap(rgb)) self._emit_image(self._rgb_to_pixmap(rgb))
elif self._emit_images: elif self._emit_images and not (header and image_bytes):
self._set_camera_available( self._set_camera_available(
False, "Sample camera feed unavailable: no frame header in zmq stream" False, "Sample camera feed unavailable: no frame header in zmq stream"
) )
@@ -13,7 +13,12 @@ from typing import Any, cast
import pytest import pytest
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
from aarecommon.math.diffraction_geometry import DiffractionGeometry 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 PySide6.QtCore import Qt
from aare.gui.panels.data_collection_settings import DataCollectionSettings 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(): def test_sample_parameters_translate_the_spreadsheet_row():
params = types.SimpleNamespace( # The real model, so the column names are checked against aareDB and not
targetresolution=1.5, # against a stand-in that agrees with whatever this file happens to say.
transmission=20.0, # percent sample = SampleShortInfo(
totalangle="180", # spreadsheet cells can arrive as text db_id=1,
oscillation=0.1, puck_name="puck1",
exposure=0.02, 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( assert SampleParameters.from_sample(sample) == SampleParameters(
resolution_a=1.5, resolution_a=1.5,
transmission=0.2, transmission=0.2,