Updated pressure monitor widget
This commit is contained in:
@@ -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_())
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user