Updated pressure monitor widget

This commit is contained in:
x09lb
2026-08-21 09:14:07 +02:00
parent 23ef9c1c6f
commit 85fd48deff
2 changed files with 380 additions and 13 deletions
@@ -92,9 +92,16 @@ class TimeSeriesBuffer:
return self._n
def append(self, timestamp: float, value: float) -> None:
"""Append a single sample."""
"""Append a single sample.
Timestamps are clamped to be non-decreasing. Out-of-order samples do occur
(IOC clock jitter, batched publishes) and would silently corrupt the
``searchsorted`` based trimming, which is what keeps the window correct.
"""
if self._n == self._t.size:
self._grow()
if self._n and timestamp < self._t[self._n - 1]:
timestamp = self._t[self._n - 1]
self._t[self._n] = timestamp
self._v[self._n] = value
self._n += 1
@@ -123,11 +130,19 @@ class TimeSeriesBuffer:
self._v[:keep] = self._v[count : self._n]
self._n = keep
def trim(self, t_min: float) -> None:
"""Drop all samples older than ``t_min``."""
def trim(self, t_min: float, keep_one_before: bool = True) -> None:
"""Drop samples older than ``t_min``.
With ``keep_one_before`` (the default) the most recent sample *before* the
window is retained as a carry-in point. EPICS only sends a monitor when the
value changes, so a channel sitting still legitimately has no sample inside
the window; without the carry-in its curve would simply vanish.
"""
if self._n == 0:
return
cut = int(np.searchsorted(self._t[: self._n], t_min, side="left"))
if keep_one_before:
cut = max(0, cut - 1)
self._drop_head(cut)
def data(self) -> tuple[np.ndarray, np.ndarray]:
@@ -167,6 +182,23 @@ class MonitoredSignal:
self.source = source
self.curve = curve
self.buffer = TimeSeriesBuffer()
self.color: str = "#ffffff"
# Wall-clock time of the last accepted sample, for the staleness indicator.
self.last_sample_wallclock: float | None = None
self._symbols_shown: bool | None = None
def set_symbols(self, enabled: bool) -> None:
"""Show/hide point markers. Only touches the curve when the state changes."""
if self._symbols_shown is enabled:
return
self._symbols_shown = enabled
if enabled:
self.curve.setSymbol("o")
self.curve.setSymbolSize(5)
self.curve.setSymbolPen(self.color)
self.curve.setSymbolBrush(self.color)
else:
self.curve.setSymbol(None)
# --------------------------------------------------------------------------------------
@@ -215,6 +247,13 @@ class RollingSignalPlot(PlotBase):
"x_mode.setter",
"timestamp_source",
"timestamp_source.setter",
"hold_last_value",
"hold_last_value.setter",
"symbol_threshold",
"symbol_threshold.setter",
"stale_after",
"stale_after.setter",
"channel_stats",
"y_quantity",
"y_quantity.setter",
"clear_data",
@@ -246,6 +285,9 @@ class RollingSignalPlot(PlotBase):
self._auto_follow = True
self._time_span = float(time_span)
self._refresh_interval = 200 # ms
self._hold_last_value = True
self._symbol_threshold = 50
self._stale_after = 5.0
self._latest_device_timestamp = 0.0
self._date_axis: pg.DateAxisItem | None = None
self._default_bottom_axis = None
@@ -478,6 +520,7 @@ class RollingSignalPlot(PlotBase):
timestamp = time.time()
entry.buffer.append(timestamp, value)
entry.last_sample_wallclock = time.time()
# ------------------------------------------------------------------------ drawing
def _reference_time(self) -> float:
@@ -490,6 +533,7 @@ class RollingSignalPlot(PlotBase):
"""Trim the buffers to the window and push the data to the curves."""
now = self._reference_time()
t_min = now - self._time_span
log_y = self.y_log
for entry in self._entries.values():
# Keep a little margin so a curve does not visibly snap at the left edge.
entry.buffer.trim(t_min - 0.05 * self._time_span)
@@ -497,6 +541,28 @@ class RollingSignalPlot(PlotBase):
if t.size == 0:
entry.curve.setData([], [])
continue
# Sample-and-hold: EPICS sends a monitor only when the value changes, so
# "no new sample" means "unchanged", not "no data". Extend the curve to the
# right edge at the last known value instead of letting it stall or vanish.
if self._hold_last_value and t[-1] < now:
t = np.append(t, now)
v = np.append(v, v[-1])
if log_y:
# Non-positive readings cannot be drawn on a log axis, and letting
# pyqtgraph turn them into -inf wrecks the autorange for every curve.
positive = v > 0
if not positive.all():
t, v = t[positive], v[positive]
if t.size == 0:
entry.curve.setData([], [])
continue
# A single point drawn with a pen and no marker is invisible; show markers
# while a curve is sparse so slow channels are not mistaken for dead ones.
entry.set_symbols(t.size <= self._symbol_threshold)
x = t if self.config.x_mode == "absolute" else t - now
entry.curve.setData(x, v)
@@ -519,8 +585,12 @@ class RollingSignalPlot(PlotBase):
except Exception: # pragma: no cover - fall back to pyqtgraph defaults
colors = [pg.intColor(i, hues=max(9, len(entries))).name() for i in range(len(entries))]
for i, entry in enumerate(entries):
pen = pg.mkPen(color=colors[i % len(colors)], width=2)
entry.curve.setPen(pen)
entry.color = colors[i % len(colors)]
entry.curve.setPen(pg.mkPen(color=entry.color, width=2))
# Force the symbol pen/brush to be re-applied with the new color.
shown = entry._symbols_shown
entry._symbols_shown = None
entry.set_symbols(bool(shown))
def _update_y_label(self) -> None:
"""Show the engineering units on the y axis when all curves agree on them."""
@@ -543,7 +613,56 @@ class RollingSignalPlot(PlotBase):
super().apply_theme(theme)
self._assign_colors()
# -------------------------------------------------------------------- diagnostics
def channel_stats(self) -> dict[str, dict]:
"""Per-device acquisition stats, for diagnosing quiet channels.
Returns:
Mapping ``device -> {"points": int, "age": float | None, "stale": bool,
"signal": str, "last_value": float | None}`` where ``age`` is the wall-clock
time in seconds since the last readback actually arrived.
"""
now = time.time()
stats: dict[str, dict] = {}
for name, entry in self._entries.items():
age = None if entry.last_sample_wallclock is None else now - entry.last_sample_wallclock
_, values = entry.buffer.data()
stats[name] = {
"points": len(entry.buffer),
"age": age,
"stale": age is None or age > self._stale_after,
"signal": entry.signal_key,
"last_value": float(values[-1]) if values.size else None,
}
return stats
# --------------------------------------------------------------------- properties
@SafeProperty(bool, doc="Extend each curve to the right edge at its last value.")
def hold_last_value(self) -> bool:
return self._hold_last_value
@hold_last_value.setter
def hold_last_value(self, value: bool) -> None:
self._hold_last_value = bool(value)
self._redraw()
@SafeProperty(int, doc="Show point markers while a curve has at most this many points.")
def symbol_threshold(self) -> int:
return self._symbol_threshold
@symbol_threshold.setter
def symbol_threshold(self, value: int) -> None:
self._symbol_threshold = max(0, int(value))
self._redraw()
@SafeProperty(float, doc="Seconds without a readback after which a channel counts as stale.")
def stale_after(self) -> float:
return self._stale_after
@stale_after.setter
def stale_after(self, value: float) -> None:
self._stale_after = max(0.1, float(value))
@SafeProperty(float, doc="Displayed time window in seconds.")
def time_span(self) -> float:
return self._time_span
@@ -799,6 +918,7 @@ class PressureMonitor(BECWidget, QWidget):
"signals_only",
"signals_only.setter",
"refresh_device_list",
"channel_stats",
"clear_data",
"export_csv",
"copy_to_clipboard",
@@ -953,6 +1073,11 @@ class PressureMonitor(BECWidget, QWidget):
self.refresh_button.clicked.connect(self.refresh_device_list)
self.device_list.itemChanged.connect(self._on_item_changed)
# Refresh the per-channel staleness markers once a second.
self._status_timer = QTimer(self)
self._status_timer.timeout.connect(self._update_channel_status)
self._status_timer.start(1000)
# ------------------------------------------------------------------ device list
@staticmethod
def _device_tags_of(obj) -> set[str]:
@@ -1055,6 +1180,37 @@ class PressureMonitor(BECWidget, QWidget):
def _on_select_none(self) -> None:
self.select_devices([])
@SafeSlot()
def _update_channel_status(self) -> None:
"""Mark monitored channels that have not produced a readback recently.
A stale channel is almost always the IOC being quiet (record deadband, slow
scan rate, disconnected PV) rather than a problem in the widget, so it is
worth being able to see it without digging through logs.
"""
stats = self.plot.channel_stats()
self._updating_list = True
try:
for i in range(self.device_list.count()):
item = self.device_list.item(i)
entry = stats.get(item.text())
if entry is None:
item.setData(Qt.ItemDataRole.ForegroundRole, None)
continue
age = entry["age"]
age_text = "never" if age is None else f"{age:.1f} s ago"
item.setToolTip(
f"{item.text()} — signal '{entry['signal']}'\n"
f"{entry['points']} samples buffered\n"
f"last readback: {age_text}"
+ ("\n(no EPICS update — value assumed unchanged)" if entry["stale"] else "")
)
font = item.font()
font.setItalic(bool(entry["stale"]))
item.setFont(font)
finally:
self._updating_list = False
def _on_device_config_update(self, *_args, **_kwargs) -> None: # pragma: no cover
"""Called by BEC on a worker thread whenever the device config changes."""
self.device_config_updated.emit()
@@ -1184,8 +1340,13 @@ class PressureMonitor(BECWidget, QWidget):
"""Write the data currently displayed to ``path`` as CSV."""
return self.plot.export_csv(path, layout=layout)
def channel_stats(self) -> dict[str, dict]:
"""Per-device acquisition stats — see :meth:`RollingSignalPlot.channel_stats`."""
return self.plot.channel_stats()
# ----------------------------------------------------------------------- cleanup
def cleanup(self) -> None:
self._status_timer.stop()
if self._device_update_callback_id is not None:
try:
self.client.callbacks.remove(self._device_update_callback_id)
@@ -1204,4 +1365,4 @@ if __name__ == "__main__": # pragma: no cover
widget = PressureMonitor(time_span=300.0)
widget.resize(1100, 600)
widget.show()
sys.exit(app.exec_())
sys.exit(app.exec_())
+213 -7
View File
@@ -181,18 +181,224 @@ fe_sto_pressure:
###################################
## Pinhole PIN0 ##
## Temperature M1 ##
###################################
pin0_trx:
readoutPriority: baseline
description: Pinhole PIN0 X Translation
deviceClass: ophyd_devices.EpicsMotorEC
m1_temp:
readoutPriority: monitored
description: m1 temperature
deviceClass: ophyd_devices.EpicsSignalRO
deviceConfig:
prefix: X09LB-OP-PIN0:TRX
read_pv: X09LB-OP-M1-ETTC-1010:TEMP
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- pinhole
- temperature
###################################
## Temperature M2 ##
###################################
m2_temp:
readoutPriority: monitored
description: M2 temperature
deviceClass: ophyd_devices.EpicsSignalRO
deviceConfig:
read_pv: X09LB-OP-M2-ETTC-2010:TEMP
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- optics
- temeprature
###################################
## Temperature FE Slit1-1 ##
###################################
fw_slit1_1_temp:
readoutPriority: monitored
description: FE Slit1 temperature
deviceClass: ophyd_devices.EpicsSignalRO
deviceConfig:
read_pv: X09L-FE-SL1-ETTC-0010:TEMP
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- temeprature
###################################
## Temperature FE Slit1_2 ##
###################################
fw_slit1_2_temp:
readoutPriority: baseline
description: FE Slit2 temperature
deviceClass: ophyd_devices.EpicsSignalRO
deviceConfig:
read_pv: X09L-FE-SL1-ETTC-0020:TEMP
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- temeprature
###################################
## Temperature FE Slit2-1 ##
###################################
fw_slit2_1_temp:
readoutPriority: monitored
description: FE Slit2 temperature
deviceClass: ophyd_devices.EpicsSignalRO
deviceConfig:
read_pv: X09L-FE-SL2-ETTC-0010:TEMP
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- temeprature
###################################
## Temperature FE Slit2_2 ##
###################################
fw_slit2_2_temp:
readoutPriority: monitored
description: FE Slit2 temperature
deviceClass: ophyd_devices.EpicsSignalRO
deviceConfig:
read_pv: X09L-FE-SL2-ETTC-0020:TEMP
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- temeprature
### FE Slits Motors ###
###################################
## Wall Slit ##
###################################
fe_slit_wall_trx:
readoutPriority: baseline
description: Wall slit X Translation
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X09L-FE-SL1:TRXW
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- Motors
###################################
## Ring Slit ##
###################################
fe_slit_ring_trx:
readoutPriority: baseline
description: Ring slit X Translation
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X09L-FE-SL1:TRXR
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- Motors
###################################
## Top Slit ##
###################################
fe_slit_top_try:
readoutPriority: baseline
description: Top slit Y Translation
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X09L-FE-SL1:TRYT
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- Motors
###################################
## Bottom Slit ##
###################################
fe_slit_bottom_try:
readoutPriority: baseline
description: Top slit Y Translation
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: X09L-FE-SL1:TRYB
onFailure: retry
enabled: true
softwareTrigger: false
deviceTags:
- FE
- Motors
##################################
## Slits center X ##
##################################
sl1xc:
description: "Slit 1 (frontend) x center"
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: "X09L-FE-SL1:CENTERX"
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- frontend
##################################
## Slits center Y ##
##################################
sl1yc:
description: "Slit 1 (frontend) y center"
deviceClass: ophyd_devices.EpicsMotorEC
deviceConfig:
prefix: "X09L-FE-SL1:CENTERY"
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- frontend
##################################
## ID - Gap ##
##################################
id_gap:
description: "ID gap"
deviceClass: ophyd_devices.EpicsSignal
deviceConfig:
read_pv: "X09LB-UIND:GAP-SP.RBV"
write_pv: "X09LB-UIND:GAP-SP.VAL"
onFailure: raise
enabled: true
readoutPriority: baseline
readOnly: false
softwareTrigger: false
deviceTags:
- ID