Flomni fixes 3 #325

Merged
holler merged 5 commits from flomni_fixes_3 into main 2026-09-29 12:44:42 +02:00
8 changed files with 360 additions and 51 deletions

No files matched your search

@@ -47,7 +47,11 @@ and known-good (used successfully in reconstruction):
not against `target_x`/`_y`.
- `rt_positions_data_stdev_x_st_fzp`/`_y` — RT's own per-point stdev, useful
as a sanity scale for how much residual noise to expect (order 0.005 µm in
the validated scan).
the validated scan). See §9 for a raw-PandA cross-check against this value.
Don't confuse this with `rt_positions_data_average_stdeviations_x_st_fzp`/
`_y`, which is a running/cumulative mean of `stdev_x_st_fzp` up to that
point in the scan (confirmed by direct comparison, 2026-09-23), not a
per-point noise value — it's the wrong field for a per-point comparison.
One row per scan point (472 points in the validated scan, `S06313`).
@@ -351,3 +355,65 @@ never overlaps a gated exposure.
**Reduced data mode** (`S00399`) validated cleanly at full accuracy — see
§6 for the row-count/gate-fraction characterization. No contamination, no
missing points, same R² bar as the other burst scans.
## 9. Per-point stdev cross-check against RT's control-loop stdev (`S06313`, 2026-09-23)
Besides the per-point mean position (§4), PandA's raw samples let you
cross-check RT's own per-point noise estimate
(`rt_positions_data_stdev_x_st_fzp`/`_y`, §2) against an independent
measurement — a single-frame scan has ~998 raw PandA samples landing within
each point's gate window (`CLOCK1`'s free-running rate over one exposure),
so each point's within-group sample scatter is itself a noise estimate.
### 9.1 Method
For each grouped point (§3.2, after applying §3.3's stale-prefix drop where
needed), compute `pos_x_um`/`pos_y_um` (§3.4's full formula) **per
individual raw sample**, not just on the group mean, then take the
within-group sample stdev (`ddof=1`). Compare against
`rt_positions_data_stdev_x_st_fzp`/`_y` (not
`average_stdeviations_x_st_fzp`/`_y` — see the note in §2).
### 9.2 The analog `cap_voltage_fzp_x`/`_y` term is required, not optional
Naively computing per-sample stdev from `interf_st_fzp_x`/`_y` alone
(skipping the `CapX_FZP_microns`/`CapY_FZP_microns` subtraction in §3.4)
gives a wildly wrong noise estimate:
| | X stdev (µm) | Y stdev (µm) | corr. with RT stdev (472 points) |
|---|---|---|---|
| Interferometer only, no cap term | 0.072 | 0.049 | 0.22 / 0.24 |
| Full formula (interf − cap, §3.4) | 0.0074 | 0.0063 | 0.98 / 0.99 |
| RT's own stdev | 0.0065 | 0.0058 | — |
Within a single point, `interf_st_fzp_x` and `cap_voltage_fzp_x` are
anti-correlated at **r ≈ −0.96** (checked on point index 5, n=998 raw
samples). The raw interferometer counts carry a large common-mode
noise/drift component roughly 10x bigger than the true positional noise; the
capacitive-sensor subtraction in §3.4 is what cancels it — the same
combination that's already required for the mean position (§4) turns out to
be even more load-bearing for the noise estimate. Both `FMC_IN.VAL1/VAL2`
(the capacitive analog inputs) and `INENC1`-`4.VAL` (the encoder position
inputs) are captured with `CAPTURE=Value` off the same `CLOCK1` tick (see
`docs/developer/panda_box_free_running_setup.md`), so this isn't a
sampling-rate mismatch between the two channels — the interferometer signal
genuinely is that much noisier on its own.
**Practical implication:** any future per-sample noise/variance analysis of
Mode B data must use the full interf+cap combination (§3.4), never the raw
interferometer signal alone — skipping the cap term breaks the noise
estimate far more severely than it breaks the mean position.
### 9.3 Result (`S06313`, all 472 points)
PandA's within-point stdev (full formula) runs ~8–14% higher than RT's own
stdev (X: 0.0074 vs 0.0065 µm; Y: 0.0063 vs 0.0058 µm), and the two
independent noise estimates correlate at r ≈ 0.98 (X) / 0.99 (Y)
point-by-point. The small systematic excess is expected, not a discrepancy:
RT's stdev comes from its own already-averaged control-loop samples, while
PandA's ~998 raw samples per point pick up additional high-rate noise RT's
loop doesn't report, plus whatever small residual noise the interf/cap
combination itself adds. This doesn't affect the mean-position validation in
§4 (a noise-floor cross-check, not a bias check) — it's a useful additional
confirmation that PandA's raw stream and RT's control loop are seeing
consistent underlying physics, not just consistent means.
+27 -2
View File
@@ -1,5 +1,26 @@
# Eiger detectors (JungfrauJoch). Only one may be enabled at a time -- the JFJ broker
# serves a single selected/initialised detector, so switching the active one here also
# requires selecting/initialising the matching detector on the JFJ broker itself.
# JungfrauJoch-side config (detector selection/init, pixel mask, azimuthal integration
# settings) is done in the Web UI at http://sls-jfjoch-001.psi.ch:8080.
# The Eiger 2 Si 500K detector server itself is reachable at 129.129.122.159.
# eiger2_si_500k:
# description: Eiger 2 Si 500K detector
# deviceClass: csaxs_bec.devices.jungfraujoch.eiger2_si_500k.Eiger2Si500K
# deviceConfig:
# detector_distance: 1000
# beam_center: [200, 200]
# onFailure: raise
# enabled: True
# readoutPriority: async
# softwareTrigger: False
# deviceTags:
# - bl_detectors
# - eiger
eiger_1_5:
description: Eiger 1.5M in-vacuum detector
description: Eiger 1.5M in-vacuum detector
deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
deviceConfig:
detector_distance: 2200
@@ -10,9 +31,10 @@ eiger_1_5:
softwareTrigger: False
deviceTags:
- bl_detectors
- eiger
# eiger_9:
# description: Eiger 9M detector
# description: Eiger 9M detector
# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M
# deviceConfig:
# detector_distance: 6975.225
@@ -21,6 +43,9 @@ eiger_1_5:
# enabled: True
# readoutPriority: async
# softwareTrigger: False
# deviceTags:
# - bl_detectors
# - eiger
# ids_cam:
# description: IDS camera for live image acquisition
+2 -2
View File
@@ -21,7 +21,7 @@ feyex:
userParameter:
in: -16.453
out: -1
fttrx_in: -2.4
fttrx_in: -2.3
fttrx_out: -27
deviceTags:
- ptycho_flomni
@@ -66,7 +66,7 @@ fheater:
userParameter:
#in: -0.5
in: -11.5
enabled: 1
enabled: 0
deviceTags:
- ptycho_flomni
+14 -5
View File
@@ -114,7 +114,7 @@ class Eiger(PSIDeviceBase):
Args:
name (str) : Name of the device
detector_name (str): Name of the detector. Supports ["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"]
detector_name (str): Name of the detector. Supports ["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M", "Dectris EIGER2 Si 500K"]
host (str): Hostname of the Jungfrau Joch server.
port (int): Port of the Jungfrau Joch server.
scan_info (ScanInfo): The scan info to use.
@@ -141,7 +141,9 @@ class Eiger(PSIDeviceBase):
def __init__(
self,
name: str,
detector_name: Literal["EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M"],
detector_name: Literal[
"EIGER 9M", "EIGER 8.5M (tmp)", "EIGER 1.5M", "Dectris EIGER2 Si 500K"
],
host: str = "http://sls-jfjoch-001",
port: int = 8080,
detector_distance: float = 100.0,
@@ -224,11 +226,18 @@ class Eiger(PSIDeviceBase):
message (dict): The message received from the preview stream.
"""
if message.get("type", "") == "image":
data = message.get("data", {}).get("default", None)
if data is None:
channels = message.get("data", {})
# The data dict's key varies by detector: "default" for eiger_1_5/eiger_9,
# but a threshold name (e.g. "threshold_1") for the multi-threshold-capable
# Eiger2 Si 500K. Prefer "default" when present, else take whichever single
# channel the broker actually sent.
frame = channels.get("default")
if frame is None:
frame = next(iter(channels.values()), None)
if frame is None:
logger.error(f"Received image message on device {self.name} without data.")
return
self.preview_image.put(data)
self.preview_image.put(frame)
# pylint: disable=missing-function-docstring
@property
@@ -0,0 +1,62 @@
"""
Eiger 2 Si 500K specific integration. It is based on the Eiger base integration for the JungfrauJoch backend
which is placed in eiger_csaxs, and where code that is equivalent for the Eiger9M, Eiger1.5M and
Eiger2 Si 500K is shared.
Please check the eiger_csaxs.py class for more details about the relevant services.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from csaxs_bec.devices.jungfraujoch.eiger import RAISE_ON_MISSING_PACKETS, Eiger
# Gap this model needs between the end of an acquisition and the next trigger. Kept
# per model on purpose -- readout is a detector property, not a beamline constant. The
# delay generator's gap (DDG2 DEFAULT_READOUT_TIMES["ab"]) must be at least as large as
# the largest value among the detectors in a scan, or the slowest one overruns.
EIGER2_SI_500K_READOUT_TIME = 2e-4 # s -- placeholder until a measured value exists
DETECTOR_NAME = "Dectris EIGER2 Si 500K"
if TYPE_CHECKING: # pragma no cover
from bec_lib.devicemanager import ScanInfo
from bec_server.device_server.device_server import DeviceManagerDS
# pylint:disable=invalid-name
class Eiger2Si500K(Eiger):
"""
Eiger 2 Si 500K specific integration.
The logic implemented here is coupled to the DelayGenerator integration,
repsonsible for the global triggering of all devices through a single Trigger logic.
Please check the eiger.py class for more details about the integration of relevant backend
services. The detector_name must be set to "Dectris EIGER2 Si 500K":
"""
USER_ACCESS = Eiger.USER_ACCESS + [] # Add more user_access methods here.
def __init__(
self,
name: str,
detector_distance: float = 100.0,
beam_center: tuple[float, float] = (0.0, 0.0),
scan_info: ScanInfo = None,
device_manager: DeviceManagerDS = None,
readout_time: float = EIGER2_SI_500K_READOUT_TIME,
raise_on_missing_packets: bool = RAISE_ON_MISSING_PACKETS,
**kwargs,
) -> None:
super().__init__(
name=name,
detector_name=DETECTOR_NAME,
readout_time=readout_time,
raise_on_missing_packets=raise_on_missing_packets,
detector_distance=detector_distance,
beam_center=beam_center,
scan_info=scan_info,
device_manager=device_manager,
**kwargs,
)
+1 -1
View File
@@ -249,7 +249,7 @@ class GalilController(Controller):
if not limit:
raise GalilError(f"Failed to drive axis {axis_Id}/{axis_Id_numeric} to limit.")
else:
print("Limit reached.")
print("Limit switch reached.")
def find_reference(self, axis_Id_numeric: int, verbose=0, raise_error=1) -> None:
"""
+86 -34
View File
@@ -3,6 +3,38 @@ from __future__ import annotations
import numpy as np
from bec_server.file_writer.default_writer import DefaultFormat
# Physical metadata for the NXdetector block, per Eiger model. Looked up by the device
# name resolved via _active_eiger_name(); a model not listed here (e.g. a future one)
# still gets a working NXdetector group via _DEFAULT_EIGER_INFO rather than silently
# losing its data links, which is the failure mode this table replaces.
_EIGER_DETECTOR_INFO = {
"eiger_1_5": {
"description": "Eiger 1.5M detector, in-house developed, Paul Scherrer Institute",
"type": "Single-photon counting detector, 320 micron-thick Si chip",
"pixel_size_um": 75.0,
"orientation_transpose": 1,
"orientation_rot90": 3,
},
"eiger2_si_500k": {
"description": "Dectris EIGER2 Si 500K detector",
"type": "Single-photon counting detector, 450 micron-thick Si chip",
"pixel_size_um": 75.0,
# TODO orientation not yet determined for this detector -- confirm with beam
# during commissioning, then fill these in (see eiger_1_5 for the convention).
# JungfrauJoch-side config (pixel mask, azimuthal integration settings, detector
# selection/init) is done in the Web UI at http://sls-jfjoch-001.psi.ch:8080.
"orientation_transpose": None,
"orientation_rot90": None,
},
}
_DEFAULT_EIGER_INFO = {
"description": "Eiger detector (Dectris/PSI, JungfrauJoch)",
"type": "Single-photon counting detector",
"pixel_size_um": 75.0,
"orientation_transpose": None,
"orientation_rot90": None,
}
class cSAXSNeXusFormat(DefaultFormat):
"""
@@ -35,7 +67,10 @@ class cSAXSNeXusFormat(DefaultFormat):
/slit_3 … slit_5
/filter_set
/beam_stop_1 … beam_stop_2
/eiger_1_5 NXdetector
/<active eiger> NXdetector ← named after whichever Eiger-family
device (deviceTag "eiger") is enabled
in bl_detectors.yaml, e.g. eiger_1_5
or eiger2_si_500k; see _active_eiger_name
/mcs NXdetector
/flOMNI NXpositioner
@@ -48,7 +83,8 @@ class cSAXSNeXusFormat(DefaultFormat):
bs1x / bs1y → bs1x / bs1y
bs2x / bs2y → bs2x / bs2y
dettrx → dettrx
eiger_4 → eiger_1_5
eiger_4 → eiger_1_5, eiger_9 or eiger2_si_500k (whichever carries the
"eiger" deviceTag and is enabled -- see _active_eiger_name)
mcs → mcs
filter_array → filter_array_1_x … filter_array_4_x
xbpm3 → xbpm3x / xbpm3y (stage positions; signal readouts TODO)
@@ -94,6 +130,23 @@ class cSAXSNeXusFormat(DefaultFormat):
except Exception:
pass
def _active_eiger_name(self) -> str | None:
"""Name of the currently enabled Eiger-family detector, if any.
Resolved via the 'eiger' deviceTag rather than a hardcoded device name, so this
layout follows whichever Eiger model is actually active in bl_detectors.yaml
(only one is ever enabled at a time).
"""
try:
candidates = [
dev
for dev in self.device_manager.devices.get_devices_with_tags(["eiger"])
if dev.enabled and dev.name in self.file_references
]
except Exception:
return None
return candidates[0].name if candidates else None
def _slit_blades(self, group, prefix: str) -> None:
"""
Store individual blade motor positions for a 4-blade slit set.
@@ -118,7 +171,8 @@ class cSAXSNeXusFormat(DefaultFormat):
# Canonical paths referenced by multiple groups
RT_POS_PATH = "/entry/instrument/flOMNI/rt_positions"
EIGER_COLL = "/entry/collection/file_references/eiger_1_5"
eiger_name = self._active_eiger_name()
EIGER_COLL = f"/entry/collection/file_references/{eiger_name}" if eiger_name else None
# ── Root entry ────────────────────────────────────────────────────────
entry = self.storage.create_group("entry")
@@ -157,11 +211,12 @@ class cSAXSNeXusFormat(DefaultFormat):
nxdata.attrs["NX_class"] = "NXdata"
nxdata.attrs["signal"] = "data"
# Detector frames
try:
for k in self.file_references["eiger_1_5"].hinted_h5_entries.keys():
self._safe_soft_link(nxdata, k, f"{EIGER_COLL}/{k}")
except Exception:
pass
if eiger_name:
try:
for k in self.file_references[eiger_name].hinted_h5_entries.keys():
self._safe_soft_link(nxdata, k, f"{EIGER_COLL}/{k}")
except Exception:
pass
# Scan positions
self._safe_soft_link(nxdata, "positions", RT_POS_PATH)
@@ -172,11 +227,12 @@ class cSAXSNeXusFormat(DefaultFormat):
# Convenience group mirroring the old /entry/data hardlink from layout.xml.
data_soft = entry.create_group("data_soft")
data_soft.attrs["NX_class"] = "NXentry"
try:
for k in self.file_references["eiger_1_5"].hinted_h5_entries.keys():
self._safe_soft_link(data_soft, k, f"{EIGER_COLL}/{k}")
except Exception:
pass
if eiger_name:
try:
for k in self.file_references[eiger_name].hinted_h5_entries.keys():
self._safe_soft_link(data_soft, k, f"{EIGER_COLL}/{k}")
except Exception:
pass
# ── /entry/control ────────────────────────────────────────────────────
control = entry.create_group("control")
@@ -357,38 +413,34 @@ class cSAXSNeXusFormat(DefaultFormat):
units="mm", description="Detector x-translation stage",
)
# ── Eiger 1.5M detector ───────────────────────────────────────────────
if (
"eiger_1_5" in self.device_manager.devices
and self.device_manager.devices.eiger_1_5.enabled
and "eiger_1_5" in self.file_references
):
eiger = instrument.create_group("eiger_1_5")
# ── Eiger detector (whichever model is currently active) ────────────────
if eiger_name:
info = _EIGER_DETECTOR_INFO.get(eiger_name, _DEFAULT_EIGER_INFO)
eiger = instrument.create_group(eiger_name)
eiger.attrs["NX_class"] = "NXdetector"
eiger.create_dataset("x_pixel_size", data=75.0).attrs["units"] = "um"
eiger.create_dataset("y_pixel_size", data=75.0).attrs["units"] = "um"
eiger.create_dataset("x_pixel_size", data=info["pixel_size_um"]).attrs["units"] = "um"
eiger.create_dataset("y_pixel_size", data=info["pixel_size_um"]).attrs["units"] = "um"
eiger.create_dataset("polar_angle", data=0.0).attrs["units"] = "degrees"
eiger.create_dataset("azimuthal_angle", data=0.0).attrs["units"] = "degrees"
eiger.create_dataset("rotation_angle", data=0.0).attrs["units"] = "degrees"
eiger.create_dataset(
"description",
data="Eiger 1.5M detector, in-house developed, Paul Scherrer Institute",
)
eiger.create_dataset(
"type",
data="Single-photon counting detector, 320 micron-thick Si chip",
)
eiger.create_dataset("description", data=info["description"])
eiger.create_dataset("type", data=info["type"])
orientation = eiger.create_group("orientation")
orientation.attrs["description"] = (
"Orientation defines the number of counterclockwise rotations by 90 deg "
"followed by a transposition to reach the 'cameraman orientation', "
"looking towards the beam."
)
orientation.create_dataset("transpose", data=1)
orientation.create_dataset("rot90", data=3)
# Only written once known for this model (see _EIGER_DETECTOR_INFO); a model
# pending commissioning gets the group and convention documented but no
# guessed values.
if info["orientation_transpose"] is not None:
orientation.create_dataset("transpose", data=info["orientation_transpose"])
if info["orientation_rot90"] is not None:
orientation.create_dataset("rot90", data=info["orientation_rot90"])
# Soft-link recorded frame data from the BEC collection
try:
for k in self.file_references["eiger_1_5"].hinted_h5_entries.keys():
for k in self.file_references[eiger_name].hinted_h5_entries.keys():
self._safe_soft_link(eiger, k, f"{EIGER_COLL}/{k}")
except Exception:
pass
@@ -396,7 +448,7 @@ class cSAXSNeXusFormat(DefaultFormat):
try:
eiger.create_ext_link(
"pixel_mask",
self.file_references["eiger_1_5"].file_path,
self.file_references[eiger_name].file_path,
"/entry/instrument/detector/pixel_mask",
)
except Exception:
+101 -6
View File
@@ -31,6 +31,10 @@ from csaxs_bec.devices.jungfraujoch.eiger import (
)
from csaxs_bec.devices.jungfraujoch.eiger_1_5m import EIGER1_5M_READOUT_TIME, Eiger1_5M
from csaxs_bec.devices.jungfraujoch.eiger_9m import EIGER9M_READOUT_TIME, Eiger9M
from csaxs_bec.devices.jungfraujoch.eiger2_si_500k import (
EIGER2_SI_500K_READOUT_TIME,
Eiger2Si500K,
)
if TYPE_CHECKING: # pragma no cover
from bec_lib.messages import FileMessage
@@ -73,7 +77,7 @@ def mock_scan_info(request, tmpdir):
yield scan_info
@pytest.fixture(scope="function", params=[(1,), (2,)])
@pytest.fixture(scope="function", params=[(1,), (2,), (3,)])
def detector_list(request) -> Generator[DetectorList, None, None]:
"""Fixture for the detector list."""
current_id = request.param[0]
@@ -109,6 +113,21 @@ def detector_list(request) -> Generator[DetectorList, None, None]:
min_count_time_ns=100000,
type="EIGER",
),
DetectorListElement(
id=3,
description="Dectris EIGER2 Si 500K",
serial_number="123456",
base_ipv4_addr="192.168.0.1",
udp_interface_count=1,
nmodules=1,
width=512,
height=512,
pixel_size_mm=0.1,
readout_time_ns=100000,
min_frame_time_ns=1000000,
min_count_time_ns=100000,
type="EIGER",
),
],
current_id=current_id,
)
@@ -142,9 +161,23 @@ def eiger_9m(mock_scan_info) -> Generator[Eiger9M, None, None]:
dev.destroy()
def test_eiger_wait_for_connection(eiger_1_5m, eiger_9m):
@pytest.fixture(scope="function")
def eiger2_si_500k(mock_scan_info) -> Generator[Eiger2Si500K, None, None]:
"""Fixture for the Eiger 2 Si 500K device.
Currently only on_connected is different for both devices, all other methods are the same."""
name = "eiger2_si_500k"
dev = Eiger2Si500K(name=name)
dev.scan_info.msg = mock_scan_info
try:
yield dev
finally:
if dev._destroyed is False:
dev.destroy()
def test_eiger_wait_for_connection(eiger_1_5m, eiger_9m, eiger2_si_500k):
"""Test the wait_for_connection metho is calling status_get on the JFJ API client."""
for eiger in (eiger_1_5m, eiger_9m):
for eiger in (eiger_1_5m, eiger_9m, eiger2_si_500k):
with mock.patch.object(eiger.jfj_client.api, "status_get") as mock_status_get:
eiger.wait_for_connection(timeout=1)
mock_status_get.assert_called_once_with(_request_timeout=1)
@@ -188,6 +221,44 @@ def test_eiger_1_5m_on_connected(eiger_1_5m, detector_list, detector_state):
assert mock_jfj_preview_client.start.call_count == 1
@pytest.mark.parametrize("detector_state", ["Idle", "Inactive"])
def test_eiger2_si_500k_on_connected(eiger2_si_500k, detector_list, detector_state):
"""Test the on_connected logic of the Eiger detector."""
eiger = eiger2_si_500k
detector_id = 3
with (
mock.patch.object(eiger.jfj_client, "stop") as mock_jfj_client_stop,
mock.patch.object(
eiger.jfj_client.api, "config_select_detector_get", return_value=detector_list
),
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state=detector_state)
),
mock.patch.object(eiger.jfj_client, "set_detector_settings") as mock_set_det,
mock.patch.object(eiger.jfj_client.api, "config_file_writer_put") as mock_file_writer,
mock.patch.object(eiger, "jfj_preview_client") as mock_jfj_preview_client,
):
if detector_state != "Idle" or detector_list.current_id != detector_id:
with pytest.raises(RuntimeError):
eiger.on_connected()
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.call_count == 0
else:
eiger.on_connected()
assert mock_set_det.call_args == mock.call(
DetectorSettings(frame_time_us=500, timing=DetectorTiming.TRIGGER), timeout=5
)
assert mock_file_writer.call_args == mock.call(
file_writer_settings=FileWriterSettings(
overwrite=True, format=FileWriterFormat.NXMXVDS
),
_request_timeout=10,
)
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.connect.call_count == 1
assert mock_jfj_preview_client.start.call_count == 1
@pytest.mark.skip(
reason="This test is currently disabled because the Eiger 9M config is replaced by the 8_5M config."
)
@@ -471,6 +542,29 @@ def test_eiger_preview_callback(eiger_1_5m):
mock_preview_put.assert_not_called()
def test_eiger_preview_callback_falls_back_to_non_default_channel(eiger2_si_500k):
"""The Eiger2 Si 500K's multi-threshold preview data has no "default" key (it's
named after the threshold, e.g. "threshold_1" -- confirmed against a live capture
of the JFJ preview stream); the callback must still forward that single channel.
"""
eiger = eiger2_si_500k
frame = np.array([[1, 2], [3, 4]])
msg = {"type": "image", "data": {"threshold_1": frame}}
with mock.patch.object(eiger.preview_image, "put") as mock_preview_put:
eiger._preview_callback(msg)
mock_preview_put.assert_called_once_with(frame)
def test_eiger_preview_callback_logs_when_data_is_truly_empty(eiger_1_5m):
"""No channel at all (empty or missing data dict) must still be treated as missing,
not silently pass through some other unrelated dict key."""
eiger = eiger_1_5m
for msg in [{"type": "image", "data": {}}, {"type": "image"}]:
with mock.patch.object(eiger.preview_image, "put") as mock_preview_put:
eiger._preview_callback(msg)
mock_preview_put.assert_not_called()
def test_eiger_acquisition_matches_the_gated_window(eiger_1_5m):
"""The detector must acquire for exp_time - readout, not for the whole period.
@@ -546,7 +640,7 @@ def test_eiger_minimum_exposure_check_is_unchanged(eiger_1_5m):
eiger.stage()
def test_eiger_models_may_declare_their_own_readout_time(eiger_1_5m, eiger_9m):
def test_eiger_models_may_declare_their_own_readout_time(eiger_1_5m, eiger_9m, eiger2_si_500k):
"""Readout is a detector property, so each model states its own value.
They coincide today, but nothing should force them to: a 9M has more modules
@@ -554,6 +648,7 @@ def test_eiger_models_may_declare_their_own_readout_time(eiger_1_5m, eiger_9m):
"""
assert eiger_1_5m._readout_time == EIGER1_5M_READOUT_TIME
assert eiger_9m._readout_time == EIGER9M_READOUT_TIME
assert eiger2_si_500k._readout_time == EIGER2_SI_500K_READOUT_TIME
def test_eiger_readout_time_is_overridable_per_deployment(mock_scan_info):
@@ -575,7 +670,7 @@ def test_eiger_subclasses_name_readout_time_for_the_device_server():
**kwargs silently drops readout_time -- and passing it explicitly used to raise
'got multiple values for keyword argument'.
"""
for cls in (Eiger1_5M, Eiger9M):
for cls in (Eiger1_5M, Eiger9M, Eiger2Si500K):
assert "readout_time" in inspect.signature(cls).parameters
@@ -697,5 +792,5 @@ def test_eiger_missing_packet_tolerance_is_overridable_per_deployment(mock_scan_
def test_eiger_subclasses_name_raise_on_missing_packets_for_the_device_server():
"""Same rule as readout_time: a deviceConfig key only reaches a device if the
class signature names it, so **kwargs alone would silently drop it."""
for cls in (Eiger1_5M, Eiger9M):
for cls in (Eiger1_5M, Eiger9M, Eiger2Si500K):
assert "raise_on_missing_packets" in inspect.signature(cls).parameters