Flomni fixes 3 #325
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user