From 08b8978e52519948e26fc024c0a3417d8a610959 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Mon, 5 Oct 2026 11:22:25 +0200 Subject: [PATCH] Add independent long term Stop buttons and short term window counters --- CHANGELOG.md | 5 ++++ cross_platform/docs/PACE_Controller_Manual.md | 6 ++-- cross_platform/src/pace_controller/i18n.py | 8 +++++ cross_platform/src/pace_controller/ui.py | 30 +++++++++++++++---- cross_platform/tests/test_core.py | 20 +++++++++++++ 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d82ab01..24a713b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes are documented here. The project follows semantic versioning. +## [Unreleased] + +- Add independent Stop buttons for long term measurements, keeping the last result visible until a new measurement starts. +- Display available short term averaging history out of five minutes and long term history from 0 / 120 min. + ## [1.1.1] - 2026-10-05 - Automated validated cross-platform maintenance release. diff --git a/cross_platform/docs/PACE_Controller_Manual.md b/cross_platform/docs/PACE_Controller_Manual.md index 045fa0c..5e8dcaa 100644 --- a/cross_platform/docs/PACE_Controller_Manual.md +++ b/cross_platform/docs/PACE_Controller_Manual.md @@ -307,7 +307,7 @@ Sample-side history is collected in MEASURE with no active automation. Cylinder/ ### 13.3 Calculation and default classes -The signed pressure-loss rates are averaged over a rolling five-minute window, weighted by the duration between readings. This equals the net pressure drop divided by the window duration, with negative average losses clipped to zero. A boundary value is interpolated when needed. During startup, the available history is used without waiting for five minutes. The average loss rate is displayed in bar/h. During steady CONTROL, cylinder autonomy is estimated as $(P_{source,+} - P_{sample}) / r$, where $r$ is the cylinder pressure-loss rate in bar/h. This is an operational estimate, not a safety guarantee; it does not reserve the 2 bar software-interlock margin. With default settings: +The signed pressure-loss rates are averaged over a rolling five-minute window, weighted by the duration between readings. This equals the net pressure drop divided by the window duration, with negative average losses clipped to zero. A boundary value is interpolated when needed. During startup, the available history is used without waiting for five minutes. The average loss rate is displayed in bar/h. Each short term panel also displays its available window duration out of five minutes, starting at 0 / 5 min. During steady CONTROL, cylinder autonomy is estimated as $(P_{source,+} - P_{sample}) / r$, where $r$ is the cylinder pressure-loss rate in bar/h. This is an operational estimate, not a safety guarantee; it does not reserve the 2 bar software-interlock margin. With default settings: | Display | Colour | Equivalent average loss rate | |---|---|---| @@ -322,7 +322,9 @@ A pressure trend does not uniquely identify a physical leak. Thermal equilibrati ### 13.4 Manual long term leak measurement -Each side has its own **Measure long term leak** button and adjacent **Reset** button. Start this measurement when the system is stable. The additional result uses a two-hour moving average, independently of the always-visible five-minute result and of the other side. During startup, it uses the available readings and displays the collected window duration out of 120 minutes. +Each side has its own **Measure long term leak**, **Reset**, and **Stop** buttons. Start this measurement when the system is stable. The additional result uses a two-hour moving average, independently of the always-visible five-minute result and of the other side. During startup, it uses the available readings and displays the collected window duration out of 120 minutes. + +**Stop** ends collection only for that side and keeps the last result visible, marked STOPPED. The other side and both short term measurements continue. **Measure long term leak** or **Reset** starts a fresh measurement. **Reset** clears only that side's long term history and restarts collection from new readings. The short term result and the other side are unaffected. Sample-side collection requires MEASURE without automation; cylinder-side collection permits MEASURE or steady, in-limits CONTROL. Leaving those conditions clears the corresponding history, and changing the cylinder operating mode also restarts its history. Disconnecting ends both manual measurements; reconnect and start them again as needed. diff --git a/cross_platform/src/pace_controller/i18n.py b/cross_platform/src/pace_controller/i18n.py index 82747b7..78b715d 100644 --- a/cross_platform/src/pace_controller/i18n.py +++ b/cross_platform/src/pace_controller/i18n.py @@ -55,6 +55,10 @@ "significant_leak": "WARNING: SIGNIFICANT PRESSURE LEAK", "long_term_start": "Measure long term leak", "long_term_reset": "Reset", + "long_term_stop": "Stop", + "long_term_stop_hint": "Stop collecting this side and keep its last result visible.", + "long_term_stopped": "Long term leak (2 h): STOPPED — {status}", + "short_term_window": "Available window: {minutes:.1f} / 5 min", "long_term_hint": "Start at a stable system. Independent two-hour moving average; startup uses available data.", "long_term_reset_hint": "Restart only this side's long term measurement from new readings.", "long_term_status": "Long term leak (2 h): {status}", @@ -194,6 +198,10 @@ "significant_leak": "ATTENZIONE, PERDITA SIGNIFICATIVA PRESSIONE", "long_term_start": "Misura long term leak", "long_term_reset": "Reset", + "long_term_stop": "Stop", + "long_term_stop_hint": "Ferma la raccolta di questo lato e mantiene visibile l'ultimo risultato.", + "long_term_stopped": "Long term leak (2 h): FERMO — {status}", + "short_term_window": "Finestra disponibile: {minutes:.1f} / 5 min", "long_term_hint": "Avvia a sistema stabile. Media mobile indipendente di due ore; all'avvio usa i dati disponibili.", "long_term_reset_hint": "Ricomincia solo la misura long term di questo lato con nuove letture.", "long_term_status": "Long term leak (2 h): {status}", diff --git a/cross_platform/src/pace_controller/ui.py b/cross_platform/src/pace_controller/ui.py index 38ce959..82cc816 100644 --- a/cross_platform/src/pace_controller/ui.py +++ b/cross_platform/src/pace_controller/ui.py @@ -166,8 +166,10 @@ def __init__(self, title_key: str) -> None: controls = QHBoxLayout() self.long_term_button = QPushButton() self.long_term_reset = QPushButton() + self.long_term_stop = QPushButton() controls.addWidget(self.long_term_button, 1) controls.addWidget(self.long_term_reset) + controls.addWidget(self.long_term_stop) layout.addLayout(controls) self.long_term_value = QLabel() self.long_term_value.setAlignment(Qt.AlignCenter) @@ -250,6 +252,7 @@ def __init__( for side in ("sample", "inlet") } self.long_term_active: set[str] = set() + self.long_term_stopped: set[str] = set() self.long_term_assessments = { side: LeakAssessment("assessing") for side in self.long_term_monitors } @@ -442,6 +445,9 @@ def _build_ui(self) -> None: card.long_term_reset.clicked.connect( lambda checked=False, side=side: self.start_long_term_leak(side) ) + card.long_term_stop.clicked.connect( + lambda checked=False, side=side: self.stop_long_term_leak(side) + ) leak_layout.addWidget(self.sample_leak, 1) leak_layout.addWidget(self.inlet_leak, 1) root.addLayout(leak_layout) @@ -908,6 +914,7 @@ def set_connected(self, connected: bool) -> None: self.module_combo.setEnabled(not connected) if not connected: self.long_term_active.clear() + self.long_term_stopped.clear() for side, monitor in self.long_term_monitors.items(): monitor.reset() self.long_term_assessments[side] = LeakAssessment("assessing") @@ -1264,6 +1271,7 @@ def _apply_leak( if math.isinf(autonomy_hours) else self.t("control_autonomy", hours=autonomy_hours) ) + lines.append(self.t("short_term_window", minutes=assessment.observation_minutes)) card.set_level(assessment.level, "\n".join(lines)) @staticmethod @@ -1291,26 +1299,38 @@ def start_long_term_leak(self, side: str) -> None: """Start or reinitialize only the selected side's manual two-hour average.""" self.long_term_monitors[side].reset() self.long_term_active.add(side) + self.long_term_stopped.discard(side) self.long_term_assessments[side] = LeakAssessment("assessing") self._refresh_long_term_leaks() + def stop_long_term_leak(self, side: str) -> None: + """Freeze this side's last result without affecting other measurements.""" + self.long_term_active.discard(side) + self.long_term_stopped.add(side) + self._refresh_long_term_leaks() + def _refresh_long_term_leaks(self) -> None: for side, card in self.leak_cards.items(): active = side in self.long_term_active + stopped = side in self.long_term_stopped card.long_term_button.setText(self.t("long_term_start")) card.long_term_button.setToolTip(self.t("long_term_hint")) + card.long_term_stop.setText(self.t("long_term_stop")) + card.long_term_stop.setToolTip(self.t("long_term_stop_hint")) + card.long_term_stop.setEnabled(self.connected and active) card.long_term_reset.setText(self.t("long_term_reset")) card.long_term_reset.setToolTip(self.t("long_term_reset_hint")) card.long_term_button.setEnabled(self.connected and not active) - card.long_term_reset.setEnabled(self.connected and active) - card.long_term_value.setVisible(active) - if not active: + card.long_term_reset.setEnabled(self.connected and (active or stopped)) + card.long_term_value.setVisible(active or stopped) + if not (active or stopped): continue assessment = self.long_term_assessments[side] - lines = [self.t("long_term_status", status=self.t(assessment.level))] + status_key = "long_term_stopped" if stopped else "long_term_status" + lines = [self.t(status_key, status=self.t(assessment.level))] if assessment.observation_minutes > 0: lines.append(self.t("loss_rate_hour", rate=assessment.rate_bar_min * 60.0)) - lines.append(self.t("long_term_window", minutes=assessment.observation_minutes)) + lines.append(self.t("long_term_window", minutes=assessment.observation_minutes)) card.long_term_value.setText("\n".join(lines)) foreground = LeakCard.COLORS.get(assessment.level, LeakCard.COLORS["assessing"])[1] card.long_term_value.setStyleSheet(f"color: {foreground};") diff --git a/cross_platform/tests/test_core.py b/cross_platform/tests/test_core.py index a4c3437..c167e5d 100644 --- a/cross_platform/tests/test_core.py +++ b/cross_platform/tests/test_core.py @@ -376,8 +376,12 @@ def test_ui_shows_early_loss_rate_and_autonomy() -> None: assert displayed[0][0] == "pressure_leak" assert "0.180 bar/h" in displayed[0][1] assert "10.0 h" in displayed[0][1] + assert "0.0 / 5 min" in displayed[0][1] MainWindow._apply_leak(window, card, LeakAssessment("paused_control")) assert "bar/h" not in displayed[1][1] + assert "0.0 / 5 min" in displayed[1][1] + MainWindow._apply_leak(window, card, LeakAssessment("no_leak", 0.0, 5.0)) + assert "5.0 / 5 min" in displayed[2][1] def test_control_autonomy_uses_source_to_sample_pressure_headroom() -> None: @@ -427,16 +431,32 @@ def test_long_term_buttons_start_and_reset_each_side_independently(monkeypatch, window.on_telemetry(Telemetry(timestamp=0.0, current_pressure_bar=10.0, positive_source_bar=50.0)) window.sample_leak.long_term_button.click() assert window.long_term_active == {"sample"} + assert "0.0 / 120 min" in window.sample_leak.long_term_value.text() assert not window.inlet_leak.long_term_reset.isEnabled() window.inlet_leak.long_term_button.click() for timestamp in (1.0, 61.0): window.on_telemetry(Telemetry(timestamp=timestamp, current_pressure_bar=10.0 - timestamp * 0.001, positive_source_bar=50.0 - timestamp * 0.002)) assert "bar/h" in window.sample_leak.long_term_value.text() assert "bar/h" in window.inlet_leak.long_term_value.text() + sample_history = list(window.long_term_monitors["sample"].samples) + sample_assessment = window.long_term_assessments["sample"] + window.sample_leak.long_term_stop.click() + assert window.long_term_active == {"inlet"} + assert window.long_term_stopped == {"sample"} + assert "STOPPED" in window.sample_leak.long_term_value.text() + assert window.sample_leak.long_term_button.isEnabled() + assert not window.sample_leak.long_term_stop.isEnabled() + window.on_telemetry(Telemetry(timestamp=61.5, current_pressure_bar=9.9, positive_source_bar=49.8)) + assert list(window.long_term_monitors["sample"].samples) == sample_history + assert window.long_term_assessments["sample"] is sample_assessment + assert window.long_term_assessments["inlet"].observation_minutes > sample_assessment.observation_minutes inlet_history = list(window.long_term_monitors["inlet"].samples) short_history = list(window.sample_monitor.samples) window.sample_leak.long_term_reset.click() assert not window.long_term_monitors["sample"].samples + assert "sample" in window.long_term_active + assert "sample" not in window.long_term_stopped + assert "0.0 / 120 min" in window.sample_leak.long_term_value.text() assert window.long_term_assessments["sample"].observation_minutes == 0.0 assert list(window.long_term_monitors["inlet"].samples) == inlet_history assert list(window.sample_monitor.samples) == short_history