From 9ad88f048be50f3e7b17385fa8dfde3cdae19d56 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 23 Sep 2026 10:05:31 +0200 Subject: [PATCH 1/4] docs(flomni): document that PandA stdev needs the analog cap-sensor term Cross-checked PandA's raw within-point sample stdev against RT's own per-point control-loop stdev on S06313. Confirmed the capacitive-sensor subtraction from the position conversion formula isn't optional for a noise estimate either: dropping it (interferometer counts alone) gives a noise estimate ~10x too large and nearly uncorrelated with RT's (r~0.2), because interf and cap are anti-correlated at r~-0.96 within a point. With the full formula, PandA and RT's independent stdev estimates agree to ~8-14% and correlate at r~0.98-0.99. Also flags average_stdeviations_x_st_fzp/_y as a cumulative-mean field, not a per-point stdev, to avoid it being used by mistake in a future session. --- .../AI_docs/PANDA_POSITION_VALIDATION.md | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) 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. -- 2.54.0 From 88026e82109f3a3e0e8fe543a0d8a1f8995f5cd7 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 29 Sep 2026 11:54:24 +0200 Subject: [PATCH 2/4] feat(eiger): integrate Eiger 2 Si 500K and make NeXus writer detector-agnostic Add the Eiger2Si500K device class (subclassing the shared JungfrauJoch Eiger base) and make it the active detector in bl_detectors.yaml. The cSAXS NeXus writer hardcoded "eiger_1_5" everywhere, so switching the active detector silently dropped the frame-data links (entry_ptycho/data, data_soft, and the NXdetector group) for any other Eiger model. Resolve the active detector via a new "eiger" deviceTag instead, and look up per-model NXdetector metadata (pixel size, sensor description/thickness, orientation) from a small table so a future model can't reintroduce the same silent failure. Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/device_configs/bl_detectors.yaml | 30 ++++- csaxs_bec/devices/jungfraujoch/eiger.py | 6 +- .../devices/jungfraujoch/eiger2_si_500k.py | 62 +++++++++ csaxs_bec/file_writer/csaxs_nexus.py | 120 +++++++++++++----- tests/tests_devices/test_eiger.py | 84 +++++++++++- 5 files changed, 254 insertions(+), 48 deletions(-) create mode 100644 csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index a9a54206..74cbc7b4 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -1,18 +1,33 @@ -eiger_1_5: - description: Eiger 1.5M in-vacuum detector - deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M +eiger2_si_500k: + description: Eiger 2 Si 500K detector + deviceClass: csaxs_bec.devices.jungfraujoch.eiger2_si_500k.Eiger2Si500K deviceConfig: - detector_distance: 2200 - beam_center: [870, 1203] + 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 +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M +# deviceConfig: +# detector_distance: 2200 +# beam_center: [870, 1203] +# onFailure: raise +# enabled: True +# readoutPriority: async +# 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 +36,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/devices/jungfraujoch/eiger.py b/csaxs_bec/devices/jungfraujoch/eiger.py index 789168af..6e41a007 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, 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/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..5e036de5 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." ) @@ -546,7 +617,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 +625,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 +647,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 +769,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 -- 2.54.0 From c88256b9f64dece121d5e2b6c9f0d729e1bbf344 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 29 Sep 2026 12:06:34 +0200 Subject: [PATCH 3/4] fix(flomni): adjust heater/X-ray-eye calibration and Galil limit message Correct fttrx_in offset for feyex (-2.4 -> -2.3), disable fheater (enabled: 0) pending further commissioning, and clarify the Galil "Limit reached." console message to "Limit switch reached." Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/device_configs/ptycho_flomni.yaml | 4 ++-- csaxs_bec/devices/omny/galil/galil_ophyd.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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: """ -- 2.54.0 From addffa1299556c00a385e724ee8a3bd09c58cfd9 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 29 Sep 2026 12:35:56 +0200 Subject: [PATCH 4/4] fix(eiger): resolve live preview data channel per detector, not "default" only The Eiger2 Si 500K's multi-threshold JFJ preview messages key the frame under a threshold name (e.g. "threshold_1"), not "default" as eiger_1_5/ eiger_9 do -- confirmed by capturing the live JFJ ZMQ preview stream. _preview_callback hardcoded "default", so it silently dropped every frame for eiger2_si_500k (251+ "without data" errors in DeviceServer.log, matching the empty BEC image widget). Now prefers "default" when present and falls back to whichever single channel the broker actually sent. Also document the JFJoch Web UI (http://sls-jfjoch-001.psi.ch:8080) and the Eiger 2 Si 500K detector server address (129.129.122.159) in bl_detectors.yaml's Eiger section. Co-Authored-By: Claude Sonnet 5 --- csaxs_bec/device_configs/bl_detectors.yaml | 43 +++++++++++++--------- csaxs_bec/devices/jungfraujoch/eiger.py | 13 +++++-- tests/tests_devices/test_eiger.py | 23 ++++++++++++ 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/csaxs_bec/device_configs/bl_detectors.yaml b/csaxs_bec/device_configs/bl_detectors.yaml index 74cbc7b4..be88dd87 100644 --- a/csaxs_bec/device_configs/bl_detectors.yaml +++ b/csaxs_bec/device_configs/bl_detectors.yaml @@ -1,23 +1,16 @@ -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 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: -# eiger_1_5: -# description: Eiger 1.5M in-vacuum detector -# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M +# description: Eiger 2 Si 500K detector +# deviceClass: csaxs_bec.devices.jungfraujoch.eiger2_si_500k.Eiger2Si500K # deviceConfig: -# detector_distance: 2200 -# beam_center: [870, 1203] +# detector_distance: 1000 +# beam_center: [200, 200] # onFailure: raise # enabled: True # readoutPriority: async @@ -26,6 +19,20 @@ eiger2_si_500k: # - bl_detectors # - eiger +eiger_1_5: + description: Eiger 1.5M in-vacuum detector + deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M + deviceConfig: + detector_distance: 2200 + beam_center: [870, 1203] + onFailure: raise + enabled: True + readoutPriority: async + softwareTrigger: False + deviceTags: + - bl_detectors + - eiger + # eiger_9: # description: Eiger 9M detector # deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M diff --git a/csaxs_bec/devices/jungfraujoch/eiger.py b/csaxs_bec/devices/jungfraujoch/eiger.py index 6e41a007..c597ac10 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger.py +++ b/csaxs_bec/devices/jungfraujoch/eiger.py @@ -226,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/tests/tests_devices/test_eiger.py b/tests/tests_devices/test_eiger.py index 5e036de5..979494c2 100644 --- a/tests/tests_devices/test_eiger.py +++ b/tests/tests_devices/test_eiger.py @@ -542,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. -- 2.54.0