diff --git a/xil_bec/bec_widgets/widgets/pressure_monitor/pressure_monitor.py b/xil_bec/bec_widgets/widgets/pressure_monitor/pressure_monitor.py index fff83a6..e992012 100644 --- a/xil_bec/bec_widgets/widgets/pressure_monitor/pressure_monitor.py +++ b/xil_bec/bec_widgets/widgets/pressure_monitor/pressure_monitor.py @@ -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_()) \ No newline at end of file diff --git a/xil_bec/device_configs/x09lb_basic.yaml b/xil_bec/device_configs/x09lb_basic.yaml index 11dfaf3..da9d8c1 100644 --- a/xil_bec/device_configs/x09lb_basic.yaml +++ b/xil_bec/device_configs/x09lb_basic.yaml @@ -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 \ No newline at end of file + - 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 +