diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md index b797064d..1dfd1054 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/PANDA_POSITION_VALIDATION.md @@ -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. diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index a9a54206..be88dd87 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -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 diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 3dc569d0..cddc75ca 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -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 diff --git a/csaxs_bec/devices/jungfraujoch/eiger.py b/csaxs_bec/devices/jungfraujoch/eiger.py index 789168af..c597ac10 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger.py +++ b/csaxs_bec/devices/jungfraujoch/eiger.py @@ -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 diff --git a/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py b/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py new file mode 100644 index 00000000..a7a898f9 --- /dev/null +++ b/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py @@ -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, + ) diff --git a/csaxs_bec/devices/omny/galil/galil_ophyd.py b/csaxs_bec/devices/omny/galil/galil_ophyd.py index ea71c7f3..41553378 100644 --- a/csaxs_bec/devices/omny/galil/galil_ophyd.py +++ b/csaxs_bec/devices/omny/galil/galil_ophyd.py @@ -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: """ diff --git a/csaxs_bec/file_writer/csaxs_nexus.py b/csaxs_bec/file_writer/csaxs_nexus.py index 5ac05c11..aaa37ae1 100644 --- a/csaxs_bec/file_writer/csaxs_nexus.py +++ b/csaxs_bec/file_writer/csaxs_nexus.py @@ -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 + / 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: diff --git a/tests/tests_devices/test_eiger.py b/tests/tests_devices/test_eiger.py index 285dff5b..979494c2 100644 --- a/tests/tests_devices/test_eiger.py +++ b/tests/tests_devices/test_eiger.py @@ -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