refactor(image): consume the data api for async and preview streams

This commit is contained in:
2026-08-14 12:01:24 +02:00
parent 970c73e767
commit df7c0cb04e
2 changed files with 500 additions and 568 deletions
+170 -344
View File
@@ -5,7 +5,6 @@ from typing import Literal
import numpy as np
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QWidget
@@ -13,6 +12,7 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.qt_data_subscription import QtDataSubscription
from bec_widgets.widgets.plots.image.image_base import ImageBase
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.image.toolbar_components.device_selection import (
@@ -106,6 +106,11 @@ class Image(ImageBase):
SUPPORTED_SIGNALS = ["AsyncSignal", "AsyncMultiSignal", "DynamicSignal"]
#: Retention cap for scan-less 1D preview streams (rows of the waterfall buffer).
PREVIEW_1D_MAX_ROWS = 1000
#: Retention cap for scan-less 2D preview streams (only the newest frame is shown).
PREVIEW_2D_MAX_FRAMES = 2
def __init__(
self,
parent: QWidget | None = None,
@@ -121,6 +126,13 @@ class Image(ImageBase):
self.subscriptions: defaultdict[str, ImageLayerConfig] = defaultdict(ImageLayerConfig)
# Store signal configs separately (not serialized to QSettings)
self._signal_configs: dict[str, dict] = {}
# Data delivery through the DataAPI (one bridge for the main layer).
self._data_bridge: QtDataSubscription | None = None
self._source_key: tuple[str, str] | None = None
self._min_display_ordinal: int | None = None
self.old_scan_id = None
self.scan_id = None
self.async_update = False
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
@@ -129,11 +141,6 @@ class Image(ImageBase):
self._autorange_on_next_update = False
self._init_toolbar_image()
self.layer_removed.connect(self._on_layer_removed)
self.old_scan_id = None
self.scan_id = None
self.async_update = False
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
@property
def _config(self) -> ImageLayerConfig:
@@ -271,15 +278,8 @@ class Image(ImageBase):
old_device = self._config.device
old_signal = self._config.signal
old_config = self.subscriptions["main"]
if old_device and old_signal and old_device != value:
self._disconnect_monitor_subscription(
device=old_device,
signal=old_signal,
source=old_config.source,
async_update=self.async_update,
async_signal_name=old_config.async_signal_name,
)
self._cleanup_data_api_subscription()
self._config.device = value
# If we have a signal, reconnect with the new device
@@ -336,15 +336,8 @@ class Image(ImageBase):
return
old_signal = self._config.signal
old_config = self.subscriptions["main"]
if self._config.device and old_signal and old_signal != value:
self._disconnect_monitor_subscription(
device=self._config.device,
signal=old_signal,
source=old_config.source,
async_update=self.async_update,
async_signal_name=old_config.async_signal_name,
)
self._cleanup_data_api_subscription()
self._config.signal = value
# If we have a device, try to connect
@@ -381,7 +374,8 @@ class Image(ImageBase):
def _setup_connection(self):
"""
Internal method to setup connection based on current device, signal, and signal_config.
Internal method to setup the DataAPI subscription based on current
device, signal, and signal_config.
"""
if not self._config.device or not self._config.signal:
logger.warning("Cannot setup connection without both device and signal")
@@ -396,8 +390,8 @@ class Image(ImageBase):
self._set_connection_status("error", "Missing signal config")
return
# Disconnect any existing monitor first
self._disconnect_current_monitor()
# Close any existing subscription first
self._cleanup_data_api_subscription()
# Determine monitor type and source from signal_config
signal_class = signal_config.get("signal_class", None)
@@ -422,123 +416,81 @@ class Image(ImageBase):
self._set_connection_status("error", "Missing ndim in signal_info")
return
config = self.subscriptions["main"]
self.async_update = False
config.async_signal_name = None
if ndim == 1:
config.source = "device_monitor_1d"
config.monitor_type = "1d"
if signal_class == "PreviewSignal":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d,
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
elif signal_class in self.SUPPORTED_SIGNALS:
self.async_update = True
config.async_signal_name = signal_config.get(
"obj_name", f"{self._config.device}_{self._config.signal}"
)
self._setup_async_image(self.scan_id)
elif ndim == 2:
config.source = "device_monitor_2d"
config.monitor_type = "2d"
if signal_class == "PreviewSignal":
self.bec_dispatcher.connect_slot(
self.on_image_update_2d,
MessageEndpoints.device_preview(self._config.device, self._config.signal),
)
elif signal_class in self.SUPPORTED_SIGNALS:
self.async_update = True
config.async_signal_name = signal_config.get(
"obj_name", f"{self._config.device}_{self._config.signal}"
)
self._setup_async_image(self.scan_id)
else:
if ndim not in (1, 2):
logger.warning(
f"Unsupported ndim '{ndim}' for monitor '{self._config.device}.{self._config.signal}'."
)
self._set_connection_status("error", f"Unsupported ndim '{ndim}'")
return
config = self.subscriptions["main"]
self.async_update = False
config.async_signal_name = None
config.monitor_type = "1d" if ndim == 1 else "2d"
config.source = "device_monitor_1d" if ndim == 1 else "device_monitor_2d"
if signal_class == "PreviewSignal":
# Scan-less device stream served by the DataAPI device plugin.
scan = None
entry = self._config.signal
max_points = self.PREVIEW_1D_MAX_ROWS if ndim == 1 else self.PREVIEW_2D_MAX_FRAMES
else:
# Scan-scoped async stream; the DataAPI rebinds on new scans and
# hands terminal scans over to history automatically.
self.async_update = True
config.async_signal_name = signal_config.get(
"obj_name", f"{self._config.device}_{self._config.signal}"
)
scan = "live"
entry = config.async_signal_name
max_points = None
try:
self._data_bridge = QtDataSubscription(
self.client,
sources=[(self._config.device, entry)],
scan=scan,
parent=self,
min_emit_interval=0.1,
max_points=max_points,
)
self._data_bridge.updated.connect(self._on_data_update)
except Exception as exc: # pylint: disable=broad-except
logger.warning(
f"Failed to configure image data subscription for "
f"{self._config.device}.{self._config.signal}: {exc}"
)
self._cleanup_data_api_subscription()
self._set_connection_status("error", str(exc))
return
self._source_key = (self._config.device, entry)
self._min_display_ordinal = None
self._set_connection_status("connected")
logger.info(
f"Connected to {self._config.device}.{self._config.signal} with type {config.monitor_type}"
)
self._autorange_on_next_update = True
def _disconnect_monitor_subscription(
self,
*,
device: str,
signal: str,
source: Literal["device_monitor_1d", "device_monitor_2d"] | None,
async_update: bool,
async_signal_name: str | None,
) -> None:
if not device or not signal:
def _cleanup_data_api_subscription(self):
"""Close the active DataAPI bridge, if any."""
self._source_key = None
self._min_display_ordinal = None
if self._data_bridge is None:
return
if async_update:
async_signal_name = async_signal_name or signal
ids_to_check = [self.scan_id, self.old_scan_id]
if source == "device_monitor_1d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d,
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
)
logger.info(
f"Disconnecting 1d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
)
elif source == "device_monitor_2d":
for scan_id in ids_to_check:
if scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d,
MessageEndpoints.device_async_signal(scan_id, device, async_signal_name),
)
logger.info(
f"Disconnecting 2d update ScanID:{scan_id}, Device Name:{device},Device Entry:{async_signal_name}"
)
return
if source == "device_monitor_1d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d, MessageEndpoints.device_preview(device, signal)
)
logger.info(
f"Disconnecting preview 1d update Device Name:{device}, Device Entry:{signal}"
)
elif source == "device_monitor_2d":
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d, MessageEndpoints.device_preview(device, signal)
)
logger.info(
f"Disconnecting preview 2d update Device Name:{device}, Device Entry:{signal}"
)
try:
self._data_bridge.close()
finally:
self._data_bridge = None
def _disconnect_current_monitor(self):
"""
Internal method to disconnect the current monitor subscriptions.
Close the current DataAPI subscription and reset the async bookkeeping.
"""
if not self._config.device or not self._config.signal:
return
config = self.subscriptions["main"]
self._disconnect_monitor_subscription(
device=self._config.device,
signal=self._config.signal,
source=config.source,
async_update=self.async_update,
async_signal_name=config.async_signal_name,
)
self._cleanup_data_api_subscription()
# Reset async state
config = self.subscriptions["main"]
self.async_update = False
config.async_signal_name = None
self._set_connection_status("disconnected")
@@ -755,131 +707,9 @@ class Image(ImageBase):
# Image Update Methods
################################################################################
########################################
# Connections
@SafeSlot(dict, dict)
def on_scan_status(self, msg: dict, meta: dict):
"""
Initial scan status message handler, which is triggered at the beginning and end of scan.
Needed for setup of AsyncSignal connections.
Args:
msg(dict): The message content.
meta(dict): The message metadata.
"""
current_scan_id = msg.get("scan_id", None)
if current_scan_id is None:
return
self._handle_scan_change(current_scan_id)
@SafeSlot(dict, dict)
def on_scan_progress(self, msg: dict, meta: dict):
"""
For setting async image readback during scan progress updates if widget is started later than scan.
Args:
msg(dict): The message content.
meta(dict): The message metadata.
"""
current_scan_id = meta.get("scan_id", None)
if current_scan_id is None:
return
self._handle_scan_change(current_scan_id)
def _handle_scan_change(self, current_scan_id: str):
"""
Update internal scan ids and refresh async connections if needed.
Also clears image buffers when scan changes.
Args:
current_scan_id (str): The current scan identifier.
"""
if current_scan_id == self.scan_id:
return
# Scan ID changed - clear buffers and reset image
self.old_scan_id = self.scan_id
self.scan_id = current_scan_id
# Clear image buffer for 1D data accumulation
self.main_image.clear()
if hasattr(self.main_image, "buffer"):
self.main_image.buffer = []
self.main_image.max_len = 0
# Reset crosshair if present
if self.crosshair is not None:
self.crosshair.reset()
# Reconnect async image subscription with new scan_id
if self.async_update:
self._setup_async_image(scan_id=self.scan_id)
def _get_async_signal_name(self) -> tuple[str, str] | None:
"""
Returns device and async signal names used for endpoints/messages.
Returns:
tuple[str, str] | None: (device, async_signal_name) or None if not available.
"""
if not self._config.device or not self._config.signal:
return None
config = self.subscriptions["main"]
async_signal = config.async_signal_name or self._config.signal
return self._config.device, async_signal
def _setup_async_image(self, scan_id: str | None):
"""
(Re)connect async image readback for the current scan.
Args:
scan_id (str | None): The scan identifier to subscribe to.
"""
if not self.async_update:
return
config = self.subscriptions["main"]
async_names = self._get_async_signal_name()
if async_names is None:
logger.info("Async image setup skipped because monitor information is incomplete.")
return
device, async_signal = async_names
if config.monitor_type == "1d":
slot = self.on_image_update_1d
elif config.monitor_type == "2d":
slot = self.on_image_update_2d
else:
logger.warning(
f"Async image setup skipped due to unsupported monitor type '{config.monitor_type}'."
)
return
# Disconnect any previous scan subscriptions to avoid stale updates.
for prev_scan_id in (self.old_scan_id, self.scan_id):
if prev_scan_id is None:
continue
self.bec_dispatcher.disconnect_slot(
slot, MessageEndpoints.device_async_signal(prev_scan_id, device, async_signal)
)
if scan_id is None:
logger.info("Scan ID not available yet; delaying async image subscription.")
return
self.bec_dispatcher.connect_slot(
slot,
MessageEndpoints.device_async_signal(scan_id, device, async_signal),
from_start=True,
cb_info={"scan_id": scan_id},
)
logger.info(f"Setup async image for {device}.{async_signal} and scan {scan_id}.")
def disconnect_monitor(self, device: str | None = None, signal: str | None = None):
"""
Disconnect the monitor from the image update signals, both 1D and 2D.
Disconnect the monitor from the image update stream, both 1D and 2D.
Args:
device(str|None): The name of the device to disconnect. Defaults to current device.
@@ -899,109 +729,122 @@ class Image(ImageBase):
)
return
self._disconnect_monitor_subscription(
device=target_device,
signal=target_entry,
source=config.source,
async_update=self.async_update,
async_signal_name=config.async_signal_name,
)
self.subscriptions["main"].async_signal_name = None
self.async_update = False
self._disconnect_current_monitor()
self._sync_device_selection()
########################################
# 1D updates
@SafeSlot(dict, dict)
def on_image_update_1d(self, msg: dict, metadata: dict):
@SafeSlot(object)
def _on_data_update(self, update) -> None:
"""
Update the image with 1D data.
For preview signals: metadata doesn't contain scan_id.
For async signals: scan_id is managed via on_scan_status/on_scan_progress.
Render one columnar DataAPI update (live, backfill or history).
2-D sources display the latest frame; 1-D sources rebuild the
waterfall buffer from the columnar fragments (newest row last).
Args:
msg(dict): The message containing the data.
metadata(dict): The metadata associated with the message.
update (SubscriptionUpdate): Full-state columnar snapshot.
"""
try:
image = self.main_image
except Exception:
if self._source_key is None:
return
data = self._get_payload_data(msg)
source = update.sources.get(self._source_key)
if source is None or source.values is None or len(source.values) == 0:
return
self._handle_scan_rollover(update, source)
if self.subscriptions["main"].monitor_type == "2d":
data = np.asarray(source.values[-1])
else:
data = self._build_1d_buffer(source)
if data is None:
logger.warning("No data received for image update from 1D.")
return
image_buffer = self.adjust_image_buffer(image, data)
if self._color_bar is not None:
self._color_bar.blockSignals(True)
image.set_data(image_buffer)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
if self._autorange_on_next_update:
self._autorange_on_next_update = False
self.auto_range()
self.image_updated.emit()
self._render_image_data(data)
@staticmethod
def adjust_image_buffer(image: ImageItem, new_data: np.ndarray) -> np.ndarray:
def _effective_scan_id(update, source) -> str | None:
"""
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
The scan id an update belongs to: the bound scan for scan-scoped
subscriptions, the last-seen scan id from the stream metadata for
scan-less preview streams.
Args:
image: The image object (used to store a buffer list and max_len).
new_data (np.ndarray): The new incoming 1D waveform data.
update (SubscriptionUpdate): The update snapshot.
source (SourceData): The rendered source of the update.
Returns:
np.ndarray: The updated image buffer with adjusted shapes.
str | None: The scan id, or None if not known (yet).
"""
# Guard for wrong data shapes
new_data = np.atleast_1d(np.asarray(new_data))
new_len = new_data.shape[0]
if not hasattr(image, "buffer"):
image.buffer = []
image.max_len = 0
if update.scan_id:
return update.scan_id
return source.metadata.get("scan_id")
if new_len > image.max_len:
image.max_len = new_len
for i in range(len(image.buffer)):
wf = image.buffer[i]
pad_width = image.max_len - wf.shape[0]
if pad_width > 0:
image.buffer[i] = np.pad(wf, (0, pad_width), mode="constant", constant_values=0)
image.buffer.append(new_data)
else:
pad_width = image.max_len - new_len
if pad_width > 0:
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
image.buffer.append(new_data)
image_buffer = np.array(image.buffer)
return image_buffer
########################################
# 2D updates
@SafeSlot(dict, dict)
def on_image_update_2d(self, msg: dict, metadata: dict):
def _handle_scan_rollover(self, update, source) -> None:
"""
Update the image with 2D data.
Reset per-scan display state once the data belongs to a new scan.
Scan-scoped subscriptions deliver fresh per-scan series, so only the
bookkeeping and the crosshair need a reset. Scan-less preview streams
retain pre-rollover points; the display window is restricted to the
newest point (the one that carried the new scan id) onward.
Args:
msg(dict): The message containing the data.
metadata(dict): The metadata associated with the message.
update (SubscriptionUpdate): The update snapshot.
source (SourceData): The rendered source of the update.
"""
scan_id = self._effective_scan_id(update, source)
if scan_id is None or scan_id == self.scan_id:
return
previous = self.scan_id
self.old_scan_id = previous
self.scan_id = scan_id
if previous is None:
return
if source.kind == "unindexed" and source.ordinals:
self._min_display_ordinal = source.ordinals[-1]
if self.crosshair is not None:
self.crosshair.reset()
def _build_1d_buffer(self, source) -> np.ndarray | None:
"""
Rebuild the 2-D waterfall buffer from the 1-D columnar fragments of a
source: one row per ordinal, rows zero-padded to the longest row,
newest row last. Covers async 'add' (one fragment per ordinal),
'add_slice' (accumulated row per ordinal), 'replace' (single current
state) and preview streams (one waveform per arrival) alike.
Args:
source (SourceData): The 1-D source snapshot.
Returns:
np.ndarray | None: The (n_rows, max_len) buffer, or None if no
displayable rows remain.
"""
values = source.values
if self._min_display_ordinal is not None:
values = [
value
for ordinal, value in zip(source.ordinals, values)
if ordinal >= self._min_display_ordinal
]
rows = [np.atleast_1d(np.asarray(value)) for value in values]
rows = [row for row in rows if row.ndim == 1]
if not rows:
return None
max_len = max(row.shape[0] for row in rows)
return np.array(
[
np.pad(row, (0, max_len - row.shape[0]), mode="constant", constant_values=0)
for row in rows
]
)
def _render_image_data(self, data: np.ndarray) -> None:
"""
Display the given data on the main image (shared render tail).
Args:
data (np.ndarray): The 2-D buffer or frame to render.
"""
try:
image = self.main_image
except Exception:
return
data = self._get_payload_data(msg)
if data is None:
logger.warning("No data received for image update from 2D.")
except Exception: # pylint: disable=broad-except
return
if self._color_bar is not None:
self._color_bar.blockSignals(True)
@@ -1013,22 +856,6 @@ class Image(ImageBase):
self.auto_range()
self.image_updated.emit()
def _get_payload_data(self, msg: dict) -> np.ndarray | None:
"""
Extract payload from async/preview/monitor1D/2D message structures due to inconsistent formats in backend.
Args:
msg (dict): The incoming message containing data.
"""
if not self.async_update:
return msg.get("data")
async_names = self._get_async_signal_name()
if async_names is None:
logger.warning("Async payload extraction failed; monitor info incomplete.")
return None
_, async_signal = async_names
return msg.get("signals", {}).get(async_signal, {}).get("value", None)
################################################################################
# Clean up
################################################################################
@@ -1056,9 +883,8 @@ class Image(ImageBase):
"""
self.layer_removed.disconnect(self._on_layer_removed)
# Disconnect current monitor
if self._config.device and self._config.signal:
self._disconnect_current_monitor()
# Close the DataAPI subscription
self._cleanup_data_api_subscription()
self.subscriptions.clear()
+330 -224
View File
@@ -1,7 +1,9 @@
from unittest.mock import MagicMock
import numpy as np
import pyqtgraph as pg
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.data_api.models import SourceData, SubscriptionUpdate
from qtpy.QtCore import QPointF, Qt
from bec_widgets.widgets.plots.image.bec_histogram_lut_item import (
@@ -30,6 +32,75 @@ def _set_signal_config(
}
def _fake_bridge_factory(monkeypatch):
"""Replace the widget's QtDataSubscription with a recording fake."""
created = []
class _FakeBridge:
def __init__(
self, client, sources, scan="live", parent=None, min_emit_interval=0.1, max_points=None
):
self.client = client
self.sources = list(sources)
self.scan = scan
self.max_points = max_points
self.healthy = True
self.closed = False
self.updated = MagicMock()
def close(self):
self.closed = True
def factory(client, sources, scan="live", parent=None, min_emit_interval=0.1, max_points=None):
bridge = _FakeBridge(client, sources, scan=scan, max_points=max_points)
created.append(bridge)
return bridge
monkeypatch.setattr("bec_widgets.widgets.plots.image.image.QtDataSubscription", factory)
return created
def _make_source(
device, entry, values, *, kind="async", ordinals=None, metadata=None, as_numpy=False
):
values = np.asarray(values) if as_numpy else tuple(values)
if ordinals is None:
ordinals = tuple(range(len(values)))
wrap = np.asarray if as_numpy else tuple
return SourceData(
device=device,
entry=entry,
kind=kind,
ordinals=wrap(ordinals),
values=values,
timestamps=wrap([float(i) for i in range(len(values))]),
complete=True,
metadata=metadata or {},
)
def _make_update(source, scan_id="scan_1", reason="live"):
return SubscriptionUpdate(
scan_id=scan_id,
reason=reason,
sources={source.key: source},
aligned_ordinals=source.ordinals,
complete=True,
metadata={"group": "standalone"},
)
def _preview_update(device, entry, values, *, ordinals=None, scan_id=None):
"""Build an update as delivered by a scan-less device_preview stream."""
metadata = {"stream": "preview"}
if scan_id is not None:
metadata["scan_id"] = scan_id
source = _make_source(
device, entry, values, kind="unindexed", ordinals=ordinals, metadata=metadata
)
return _make_update(source, scan_id="")
class _FakeClickEvent:
"""Minimal stand-in for a pyqtgraph MouseClickEvent."""
@@ -294,13 +365,12 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
# Device/signal update mechanism
def test_image_setup_preview_signal_1d(qtbot, mocked_client):
def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a 1D PreviewSignal connects using the 1D path
and updates correctly.
Ensure that calling .image() with a 1D PreviewSignal connects through a
scan-less DataAPI bridge and accumulates rows into the waterfall buffer.
"""
import numpy as np
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
@@ -321,20 +391,25 @@ def test_image_setup_preview_signal_1d(qtbot, mocked_client):
assert view.device == "waveform1d"
assert view.signal == "img"
# Simulate a waveform update from the dispatcher
# Preview signals are scan-less device streams with bounded retention
bridge = bridges[-1]
assert bridge.scan is None
assert bridge.sources == [("waveform1d", "img")]
assert bridge.max_points == Image.PREVIEW_1D_MAX_ROWS
# Simulate a waveform update from the DataAPI
waveform = np.arange(25, dtype=float)
view.on_image_update_1d({"data": waveform}, {"scan_id": "scan_test"})
view._on_data_update(_preview_update("waveform1d", "img", [waveform]))
assert view.main_image.raw_data.shape == (1, 25)
np.testing.assert_array_equal(view.main_image.raw_data[0], waveform)
def test_image_setup_preview_signal_2d(qtbot, mocked_client):
def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch):
"""
Ensure that calling .image() with a 2D PreviewSignal connects using the 2D path
and updates correctly.
Ensure that calling .image() with a 2D PreviewSignal connects through a
scan-less DataAPI bridge and displays the newest frame.
"""
import numpy as np
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
@@ -355,46 +430,40 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client):
assert view.device == "eiger"
assert view.signal == "img2d"
# Simulate a 2D image update
bridge = bridges[-1]
assert bridge.scan is None
assert bridge.sources == [("eiger", "img2d")]
assert bridge.max_points == Image.PREVIEW_2D_MAX_FRAMES
# Only the newest frame of the stream is displayed
test_data = np.arange(16, dtype=float).reshape(4, 4)
view.on_image_update_2d({"data": test_data}, {})
view._on_data_update(_preview_update("eiger", "img2d", [np.zeros((4, 4)), test_data]))
np.testing.assert_array_equal(view.main_image.image, test_data)
def test_switching_device_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
def test_switching_device_replaces_preview_bridge(qtbot, mocked_client, monkeypatch):
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=2)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img")
connected.clear()
disconnected.clear()
first = bridges[-1]
assert first.sources == [("eiger", "img")]
view.device = "waveform1d"
assert MessageEndpoints.device_preview("eiger", "img") in disconnected
assert MessageEndpoints.device_preview("waveform1d", "img") in connected
assert first.closed is True
assert bridges[-1].sources == [("waveform1d", "img")]
assert bridges[-1].scan is None
def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
def test_switching_device_replaces_async_bridge(qtbot, mocked_client, monkeypatch):
"""
Verify that switching device while async_update=True disconnects device_async_signal
endpoints for both scan_id and old_scan_id on the old device before reconnecting to
the new device.
Verify that switching device while async_update=True closes the previous
scan-scoped bridge before subscribing to the new device.
"""
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
@@ -403,77 +472,44 @@ def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_clie
mocked_client, "waveform1d", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img")
assert view.async_update is True
assert view.subscriptions["main"].async_signal_name == "async_obj"
view.scan_id = "scan_current"
view.old_scan_id = "scan_previous"
connected.clear()
disconnected.clear()
first = bridges[-1]
assert first.scan == "live"
assert first.sources == [("eiger", "async_obj")]
view.device = "waveform1d"
# Both scan_id and old_scan_id endpoints for the old device must be disconnected
assert (
MessageEndpoints.device_async_signal("scan_current", "eiger", "async_obj") in disconnected
)
assert (
MessageEndpoints.device_async_signal("scan_previous", "eiger", "async_obj") in disconnected
)
# The new device's async endpoint for the current scan must be connected
assert (
MessageEndpoints.device_async_signal("scan_current", "waveform1d", "async_obj") in connected
)
assert first.closed is True
assert bridges[-1].scan == "live"
assert bridges[-1].sources == [("waveform1d", "async_obj")]
def test_switching_signal_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
def test_switching_signal_replaces_preview_bridge(qtbot, mocked_client, monkeypatch):
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2)
_set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
view.image(device="eiger", signal="img_a")
connected.clear()
disconnected.clear()
first = bridges[-1]
assert first.sources == [("eiger", "img_a")]
view.signal = "img_b"
assert MessageEndpoints.device_preview("eiger", "img_a") in disconnected
assert MessageEndpoints.device_preview("eiger", "img_b") in connected
assert first.closed is True
assert bridges[-1].sources == [("eiger", "img_b")]
assert bridges[-1].scan is None
def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
def test_switching_signal_replaces_async_bridge(qtbot, mocked_client, monkeypatch):
"""
When the current monitor is an async signal, switching to a different signal must
disconnect the previous async endpoint (based on scan_id/async_signal_name) before
reconnecting with the new signal's async endpoint.
When the current monitor is an async signal, switching to a different
signal must close the previous bridge and subscribe with the new signal's
obj_name as the DataAPI entry.
"""
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img_a", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_a"
@@ -482,40 +518,20 @@ def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_clie
mocked_client, "eiger", "img_b", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_b"
)
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
)
# Connect to img_a as an async signal; scan_id is None so no actual subscription is made
view.image(device="eiger", signal="img_a")
assert view.async_update is True
assert view.subscriptions["main"].async_signal_name == "async_obj_a"
assert view.subscriptions["main"].source == "device_monitor_2d"
# Simulate an active scan so that the async endpoint is real
view.scan_id = "scan_123"
connected.clear()
disconnected.clear()
first = bridges[-1]
assert first.sources == [("eiger", "async_obj_a")]
# Switch to a different signal
view.signal = "img_b"
# The previous async endpoint for img_a must have been disconnected
expected_disconnect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_a")
assert expected_disconnect in disconnected
# The new async endpoint for img_b must have been connected
expected_connect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_b")
assert expected_connect in connected
assert first.closed is True
assert view.subscriptions["main"].async_signal_name == "async_obj_b"
assert bridges[-1].sources == [("eiger", "async_obj_b")]
assert bridges[-1].scan == "live"
def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
@@ -568,8 +584,10 @@ def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
def test_image_async_signal_uses_obj_name(qtbot, mocked_client, monkeypatch):
"""
Verify async signals use obj_name for endpoints/payloads and reconnect with scan_id.
Verify async signals subscribe with their obj_name as the DataAPI entry
and render the delivered fragments.
"""
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
@@ -579,53 +597,33 @@ def test_image_async_signal_uses_obj_name(qtbot, mocked_client, monkeypatch):
assert view.subscriptions["main"].async_signal_name == "async_obj"
assert view.async_update is True
# Prepare scan ids and capture dispatcher calls
view.old_scan_id = "old_scan"
view.scan_id = "new_scan"
connected = []
disconnected = []
monkeypatch.setattr(
view.bec_dispatcher,
"connect_slot",
lambda slot, endpoint, from_start=False, cb_info=None: connected.append(
(slot, endpoint, from_start, cb_info)
),
)
monkeypatch.setattr(
view.bec_dispatcher,
"disconnect_slot",
lambda slot, endpoint: disconnected.append((slot, endpoint)),
)
bridge = bridges[-1]
assert bridge.scan == "live"
assert bridge.sources == [("eiger", "async_obj")]
assert bridge.max_points is None
view._setup_async_image(view.scan_id)
expected_new = MessageEndpoints.device_async_signal("new_scan", "eiger", "async_obj")
expected_old = MessageEndpoints.device_async_signal("old_scan", "eiger", "async_obj")
assert any(ep == expected_new for _, ep, _, _ in connected)
assert any(ep == expected_old for _, ep in disconnected)
# Payload extraction should use obj_name
# Rendering reads the source keyed by (device, obj_name)
payload = np.array([1, 2, 3])
msg = {"signals": {"async_obj": {"value": payload}}}
assert np.array_equal(view._get_payload_data(msg), payload)
source = _make_source(
"eiger", "async_obj", [payload], metadata={"async_update_type": "add", "max_shape": [None]}
)
view._on_data_update(_make_update(source))
np.testing.assert_array_equal(view.main_image.raw_data, payload.reshape(1, 3))
def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch):
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
view.image(device="eiger", signal="img")
view.scan_id = "scan_x"
view.old_scan_id = "scan_y"
view.subscriptions["main"].async_signal_name = "async_obj"
# Avoid touching real dispatcher
monkeypatch.setattr(view.bec_dispatcher, "disconnect_slot", lambda *args, **kwargs: None)
assert view.async_update is True
view.disconnect_monitor(device="eiger", signal="img")
assert bridges[-1].closed is True
assert view.subscriptions["main"].async_signal_name is None
assert view.async_update is False
@@ -634,7 +632,8 @@ def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch):
# Connection guardrails
def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client):
def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client, monkeypatch):
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img", signal_class="Signal", ndim=2)
@@ -643,9 +642,11 @@ def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client):
assert view.subscriptions["main"].source is None
assert view.subscriptions["main"].monitor_type is None
assert view.async_update is False
assert bridges == []
def test_image_disconnects_with_missing_entry(qtbot, mocked_client):
def test_image_disconnects_with_missing_entry(qtbot, mocked_client, monkeypatch):
bridges = _fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
@@ -656,85 +657,163 @@ def test_image_disconnects_with_missing_entry(qtbot, mocked_client):
view.image(device="eiger", signal=None)
assert view.device == ""
assert view.signal == ""
assert all(bridge.closed for bridge in bridges)
def test_handle_scan_change_clears_buffers_and_resets_crosshair(qtbot, mocked_client, monkeypatch):
def test_scan_rollover_resets_accumulation_and_crosshair(qtbot, mocked_client, monkeypatch):
"""A new scan id in the update stream starts a fresh 1D accumulation and
resets the crosshair (replaces the legacy _handle_scan_change data path)."""
_fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
view.scan_id = "scan_1"
view.main_image.buffer = [np.array([1.0, 2.0])]
view.main_image.max_len = 2
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
)
view.image(device="eiger", signal="img")
clear_called = []
monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True))
view._on_data_update(
_make_update(_make_source("eiger", "async_obj", [np.arange(4)]), scan_id="scan_1")
)
assert view.scan_id == "scan_1"
assert view.main_image.raw_data.shape == (1, 4)
view.hook_crosshair()
reset_called = []
if view.crosshair is not None:
monkeypatch.setattr(view.crosshair, "reset", lambda: reset_called.append(True))
monkeypatch.setattr(view.crosshair, "reset", lambda: reset_called.append(True))
view._handle_scan_change("scan_2")
# First update of the new scan: the DataAPI delivers a fresh per-scan series
view._on_data_update(
_make_update(_make_source("eiger", "async_obj", [np.arange(6)]), scan_id="scan_2")
)
assert view.old_scan_id == "scan_1"
assert view.scan_id == "scan_2"
assert clear_called == [True]
assert view.main_image.buffer == []
assert view.main_image.max_len == 0
if view.crosshair is not None:
assert reset_called == [True]
assert reset_called == [True]
assert view.main_image.raw_data.shape == (1, 6)
def test_handle_scan_change_reconnects_async(qtbot, mocked_client, monkeypatch):
def test_preview_scan_rollover_restricts_display_window(qtbot, mocked_client, monkeypatch):
"""Preview streams retain pre-rollover points; a scan change restricts the
displayed accumulation to the newest point onward."""
_fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
view.scan_id = "scan_1"
view.async_update = True
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=1)
view.image(device="waveform1d", signal="img")
called = []
monkeypatch.setattr(view, "_setup_async_image", lambda scan_id: called.append(scan_id))
rows = [np.full(5, i, dtype=float) for i in range(3)]
view._on_data_update(_preview_update("waveform1d", "img", rows, scan_id="scan_1"))
assert view.scan_id == "scan_1"
assert view.main_image.raw_data.shape == (3, 5)
view._handle_scan_change("scan_2")
# The device stream keeps the old rows; the newest one belongs to scan_2.
rows_after = rows + [np.full(5, 99.0)]
view._on_data_update(_preview_update("waveform1d", "img", rows_after, scan_id="scan_2"))
assert called == ["scan_2"]
assert view.old_scan_id == "scan_1"
assert view.scan_id == "scan_2"
assert view.main_image.raw_data.shape == (1, 5)
np.testing.assert_array_equal(view.main_image.raw_data[0], np.full(5, 99.0))
# Subsequent scan_2 rows extend the new accumulation.
rows_more = rows_after + [np.full(5, 100.0)]
view._on_data_update(_preview_update("waveform1d", "img", rows_more, scan_id="scan_2"))
assert view.main_image.raw_data.shape == (2, 5)
def test_handle_scan_change_same_scan_noop(qtbot, mocked_client, monkeypatch):
def test_scan_rollover_same_scan_noop(qtbot, mocked_client, monkeypatch):
_fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
view.scan_id = "scan_1"
view.main_image.buffer = [np.array([1.0])]
view.main_image.max_len = 1
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
)
view.image(device="eiger", signal="img")
clear_called = []
monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True))
view._handle_scan_change("scan_1")
view._on_data_update(
_make_update(_make_source("eiger", "async_obj", [np.arange(3)]), scan_id="scan_1")
)
view._on_data_update(
_make_update(
_make_source("eiger", "async_obj", [np.arange(3), np.arange(3)]), scan_id="scan_1"
)
)
assert view.scan_id == "scan_1"
assert clear_called == []
assert view.main_image.buffer == [np.array([1.0])]
assert view.main_image.max_len == 1
assert view.old_scan_id is None
assert view.main_image.raw_data.shape == (2, 3)
def test_image_data_update_2d(qtbot, mocked_client):
def test_image_data_update_2d(qtbot, mocked_client, monkeypatch):
_fake_bridge_factory(monkeypatch)
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.random.rand(20, 30)
message = {"data": test_data}
metadata = {}
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
bec_image_view.image(device="eiger", signal="img")
bec_image_view.on_image_update_2d(message, metadata)
test_data = np.random.rand(20, 30)
bec_image_view._on_data_update(_preview_update("eiger", "img", [test_data]))
np.testing.assert_array_equal(bec_image_view.main_image.image, test_data)
def test_image_data_update_1d(qtbot, mocked_client):
def test_image_data_update_1d(qtbot, mocked_client, monkeypatch):
_fake_bridge_factory(monkeypatch)
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=1)
bec_image_view.image(device="waveform1d", signal="img")
waveform1 = np.random.rand(50)
waveform2 = np.random.rand(60) # Different length, tests padding logic
metadata = {"scan_id": "scan_test"}
bec_image_view.on_image_update_1d({"data": waveform1}, metadata)
bec_image_view._on_data_update(_preview_update("waveform1d", "img", [waveform1]))
assert bec_image_view.main_image.raw_data.shape == (1, 50)
bec_image_view.on_image_update_1d({"data": waveform2}, metadata)
bec_image_view._on_data_update(_preview_update("waveform1d", "img", [waveform1, waveform2]))
assert bec_image_view.main_image.raw_data.shape == (2, 60)
def test_async_add_slice_displays_accumulated_rows(qtbot, mocked_client, monkeypatch):
"""add_slice sources deliver one accumulated row per ordinal; shorter rows
are zero-padded to the longest one."""
_fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
)
view.image(device="eiger", signal="img")
source = _make_source(
"eiger",
"async_obj",
[[1.0, 2.0, 3.0], [4.0]],
metadata={"async_update_type": "add_slice", "max_shape": [None, None]},
)
view._on_data_update(_make_update(source))
np.testing.assert_array_equal(
view.main_image.raw_data, np.array([[1.0, 2.0, 3.0], [4.0, 0.0, 0.0]])
)
def test_async_replace_displays_current_state(qtbot, mocked_client, monkeypatch):
"""replace sources expose a single element: the current full state."""
_fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
view.image(device="eiger", signal="img")
frame = np.arange(12, dtype=float).reshape(3, 4)
source = _make_source(
"eiger",
"async_obj",
[frame],
ordinals=(0,),
metadata={"async_update_type": "replace", "max_shape": [3, 4]},
)
view._on_data_update(_make_update(source))
np.testing.assert_array_equal(view.main_image.image, frame)
##############################################
# Toolbar and Actions Tests
@@ -826,6 +905,7 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
def test_setup_image_from_toolbar(qtbot, mocked_client, monkeypatch):
bridges = _fake_bridge_factory(monkeypatch)
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
@@ -1134,7 +1214,7 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
# Provide deterministic 2D data
test_data = np.arange(25).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view._render_image_data(test_data)
# Activate ROI crosshair
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
@@ -1164,7 +1244,7 @@ def test_roi_plots_ignore_rgb_images_and_clear_stale_curves(qtbot, mocked_client
"""RGB images have vector-valued pixels and cannot produce scalar profile curves."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
scalar_image = np.arange(25).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": scalar_image}, {})
bec_image_view._render_image_data(scalar_image)
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
@@ -1177,7 +1257,7 @@ def test_roi_plots_ignore_rgb_images_and_clear_stale_curves(qtbot, mocked_client
assert bec_image_view.y_roi_pinned is not None
rgb_image = np.zeros((5, 5, 3), dtype=np.uint8)
bec_image_view.on_image_update_2d({"data": rgb_image}, {})
bec_image_view._render_image_data(rgb_image)
assert bec_image_view._compute_image_slices(bec_image_view.main_image, 2, 3) is None
assert bec_image_view.x_roi_curve is None
@@ -1194,7 +1274,7 @@ def test_pinned_roi_profiles_freeze_and_clear(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view._render_image_data(test_data)
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
@@ -1231,7 +1311,7 @@ def test_pinned_roi_profiles_keep_style_on_theme_change(qtbot, mocked_client):
import numpy as np
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@@ -1253,7 +1333,7 @@ def test_pin_survives_scan_reset(qtbot, mocked_client):
import numpy as np
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@@ -1263,7 +1343,7 @@ def test_pin_survives_scan_reset(qtbot, mocked_client):
pin_point = bec_image_view.crosshair.pinned_point
assert pin_point is not None
# Image._handle_scan_change calls crosshair.reset() on each new scan id.
# Image._handle_scan_rollover calls crosshair.reset() on each new scan id.
bec_image_view.crosshair.reset()
# The pin marker and the frozen reference profiles must still be there.
@@ -1279,7 +1359,7 @@ def test_pin_and_profiles_restored_after_roi_toggle(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view._render_image_data(test_data)
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@@ -1311,7 +1391,7 @@ def test_detached_pin_can_be_removed_with_right_click(qtbot, mocked_client, monk
from qtpy.QtWidgets import QMenu
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@@ -1341,7 +1421,7 @@ def test_pinned_profiles_follow_image_updates(qtbot, mocked_client):
exactly like the pin's intensity label."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view._render_image_data(test_data)
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@@ -1351,7 +1431,7 @@ def test_pinned_profiles_follow_image_updates(qtbot, mocked_client):
np.testing.assert_array_equal(x_pinned, test_data[:, 3])
updated = test_data + 100.0
bec_image_view.on_image_update_2d({"data": updated}, {})
bec_image_view._render_image_data(updated)
qtbot.waitUntil(
lambda: np.array_equal(bec_image_view.x_roi_pinned.getData()[1], updated[:, 3]), timeout=500
@@ -1364,7 +1444,7 @@ def test_detached_pin_profiles_follow_image_updates(qtbot, mocked_client):
"""Pinned profiles keep following image data while the crosshair is toggled off."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view._render_image_data(test_data)
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@@ -1375,7 +1455,7 @@ def test_detached_pin_profiles_follow_image_updates(qtbot, mocked_client):
assert bec_image_view.x_roi_pinned is not None
updated = test_data + 100.0
bec_image_view.on_image_update_2d({"data": updated}, {})
bec_image_view._render_image_data(updated)
qtbot.waitUntil(
lambda: np.array_equal(bec_image_view.x_roi_pinned.getData()[1], updated[:, 3]), timeout=500
@@ -1387,7 +1467,7 @@ def test_crosshair_moves_update_profiles_immediately(qtbot, mocked_client):
them at the current crosshair position."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view._render_image_data(test_data)
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
@@ -1402,7 +1482,7 @@ def test_crosshair_moves_update_profiles_immediately(qtbot, mocked_client):
# New frames refresh the live profile at the crosshair position.
updated = test_data + 100.0
bec_image_view.on_image_update_2d({"data": updated}, {})
bec_image_view._render_image_data(updated)
np.testing.assert_array_equal(bec_image_view.x_roi_curve.getData()[1], updated[:, 2])
@@ -1411,7 +1491,7 @@ def test_live_label_intensity_updates_on_image_update(qtbot, mocked_client):
not only when the mouse moves."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view._render_image_data(test_data)
bec_image_view.hook_crosshair()
bec_image_view.crosshair.plot_item.vb.setRange(
xRange=(0, test_data.shape[0]), yRange=(0, test_data.shape[1]), padding=0
@@ -1420,14 +1500,14 @@ def test_live_label_intensity_updates_on_image_update(qtbot, mocked_client):
bec_image_view.crosshair.mouse_moved(manual_pos=(2.5, 3.5))
assert "Intensity: 13.000" in bec_image_view.crosshair.coord_label.toPlainText()
bec_image_view.on_image_update_2d({"data": test_data + 100.0}, {})
bec_image_view._render_image_data(test_data + 100.0)
assert "Intensity: 113.000" in bec_image_view.crosshair.coord_label.toPlainText()
def test_active_pin_label_intensity_updates_on_image_update(qtbot, mocked_client):
"""Pinned crosshair label intensity follows image updates at the pinned position."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
bec_image_view.hook_crosshair()
bec_image_view.crosshair.set_pin(2.0, 3.0)
@@ -1435,7 +1515,7 @@ def test_active_pin_label_intensity_updates_on_image_update(qtbot, mocked_client
"pin (2.500, 3.500)\nIntensity: 13.000"
)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 100}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5) + 100)
qtbot.waitUntil(
lambda: bec_image_view.crosshair.pinned_label.toPlainText()
@@ -1449,7 +1529,7 @@ def test_image_update_does_not_replay_active_crosshair_mouse_handling(
):
"""Image updates refresh a pin without re-snapping or emitting live crosshair updates."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
bec_image_view.hook_crosshair()
bec_image_view.crosshair.set_pin(2.0, 3.0)
@@ -1460,7 +1540,7 @@ def test_image_update_does_not_replay_active_crosshair_mouse_handling(
lambda *args, **kwargs: mouse_moves.append((args, kwargs)),
)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 100}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5) + 100)
qtbot.waitUntil(
lambda: bec_image_view.crosshair.pinned_label.toPlainText()
@@ -1473,7 +1553,7 @@ def test_image_update_does_not_replay_active_crosshair_mouse_handling(
def test_detached_pin_label_intensity_updates_on_image_update(qtbot, mocked_client):
"""Detached pin labels keep following image updates while crosshair is disabled."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
bec_image_view.hook_crosshair()
bec_image_view.crosshair.set_pin(2.0, 3.0)
bec_image_view.unhook_crosshair()
@@ -1484,7 +1564,7 @@ def test_detached_pin_label_intensity_updates_on_image_update(qtbot, mocked_clie
"pin (2.500, 3.500)\nIntensity: 13.000"
)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 200}, {})
bec_image_view._render_image_data(np.arange(25).reshape(5, 5) + 200)
qtbot.waitUntil(
lambda: bec_image_view._detached_pin["label"].toPlainText()
@@ -1496,7 +1576,7 @@ def test_detached_pin_label_intensity_updates_on_image_update(qtbot, mocked_clie
def test_detached_pin_label_omits_scalar_intensity_for_rgb_image(qtbot, mocked_client):
"""Detached RGB pin labels only show coordinates because a pixel is an array."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.zeros((5, 5, 3))}, {})
bec_image_view._render_image_data(np.zeros((5, 5, 3)))
bec_image_view.hook_crosshair()
bec_image_view.crosshair.set_pin(2.0, 3.0)
bec_image_view.unhook_crosshair()
@@ -1588,7 +1668,7 @@ def test_log_scale_does_not_crash_with_full_colorbar(qtbot, mocked_client, data)
view.log = True
# This is the exact path that used to raise "Cannot set range [nan, nan]".
view.on_image_update_2d({"data": data}, {})
view._render_image_data(data)
vmin, vmax = view.main_image.v_range
assert np.isfinite(vmin) and np.isfinite(vmax)
@@ -1604,7 +1684,7 @@ def test_log_scale_all_negative_keeps_finite_image(qtbot, mocked_client):
view.log = True
data = (-np.abs(np.random.rand(15, 15)) - 1.0).astype(np.float32)
view.on_image_update_2d({"data": data}, {})
view._render_image_data(data)
assert view.main_image.image is not None
assert np.all(np.isfinite(view.main_image.image))
@@ -1613,7 +1693,7 @@ def test_log_scale_all_negative_keeps_finite_image(qtbot, mocked_client):
def test_set_v_range_ignores_non_finite_levels(qtbot, mocked_client):
"""Non-finite v_range requests are rejected rather than forwarded to pg."""
view = create_widget(qtbot, Image, client=mocked_client)
view.on_image_update_2d({"data": np.random.rand(10, 10)}, {})
view._render_image_data(np.random.rand(10, 10))
good = view.main_image.v_range
view.main_image.set_v_range((np.nan, np.nan))
@@ -1643,16 +1723,20 @@ def test_image_processor_log_is_finite_for_non_positive():
##############################################
def test_adjust_image_buffer_coerces_list_and_scalar(qtbot, mocked_client):
def test_1d_buffer_coerces_list_and_scalar(qtbot, mocked_client, monkeypatch):
"""Non-ndarray payloads (python list, 0-d scalar) must not crash the
1D buffer accumulation."""
_fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
image = view.main_image
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=1)
view.image(device="waveform1d", signal="img")
buf = view.adjust_image_buffer(image, [1, 2, 3]) # python list
assert buf.shape == (1, 3)
buf = view.adjust_image_buffer(image, np.array(42.0)) # 0-d scalar
assert buf.shape[0] == 2
view._on_data_update(_preview_update("waveform1d", "img", [[1, 2, 3]])) # python list
assert view.main_image.raw_data.shape == (1, 3)
view._on_data_update(
_preview_update("waveform1d", "img", [[1, 2, 3], np.array(42.0)]) # 0-d scalar
)
assert view.main_image.raw_data.shape == (2, 3)
##############################################
@@ -1675,3 +1759,25 @@ def test_layer_accessors_safe_after_teardown(qtbot, mocked_client):
view.autorange_mode = "max"
view._sync_autorange_switch()
view._sync_colorbar_levels()
def test_history_numpy_source_renders(qtbot, mocked_client, monkeypatch):
"""Regression: numpy-array source columns from the history bulk path must
render (a truth-test on source.values raised ValueError, swallowed by
SafeSlot, leaving the image blank)."""
_fake_bridge_factory(monkeypatch)
view = create_widget(qtbot, Image, client=mocked_client)
_set_signal_config(
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
)
view.image(device="eiger", signal="img")
frame = np.arange(12, dtype=float).reshape(3, 4)
source = _make_source(
"eiger",
"async_obj",
np.asarray([frame]),
metadata={"async_update_type": "add", "max_shape": [None, None]},
as_numpy=True,
)
view._on_data_update(_make_update(source, reason="history"))
np.testing.assert_array_equal(view.main_image.raw_data, frame)