From f44bd6e4939b6b303cdf623a1f53dea4f5ccd692 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Mon, 5 Oct 2026 10:47:11 +0200 Subject: [PATCH] Show leak rates immediately and add independent two-hour monitoring --- CHANGELOG.md | 8 + README.md | 3 +- cross_platform/README.md | 3 +- cross_platform/docs/PACE_Controller_Manual.md | 20 ++- cross_platform/src/pace_controller/i18n.py | 12 ++ cross_platform/src/pace_controller/leak.py | 63 ++++---- cross_platform/src/pace_controller/ui.py | 88 +++++++++-- cross_platform/tests/test_core.py | 148 +++++++++++++++++- 8 files changed, 290 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdebf7f..ba0e66f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes are documented here. The project follows semantic versioning. +## [Unreleased] + +- Add independent manual long term leak measurements for cell and cylinder, with two-hour moving averages and separate Reset buttons. + +- Show pressure-loss rates and leak warnings from the first two valid readings, without the three-minute delay. +- Use a time-weighted five-minute moving average, including available startup history, for both loss indicators and CONTROL autonomy. +- Keep green confirmation tied to uninterrupted monitoring time, independently of the averaging window. + ## [1.1.0] - 2026-09-07 - Stabilized sample-side and cylinder-side pressure-loss estimates by requiring at least three minutes of regression history before displaying a rate. diff --git a/README.md b/README.md index f9dbe48..311ece1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ No NI-VISA, Druck USB driver, Python, LabVIEW, or Internet connection is require - Nine telemetry cards visible together on two rows, with three-decimal display. - Manual targets, selectable slew, CONTROL/MEASURE behaviour, and protected advanced parameters. - Indenting cycle and editable multi-step JSON routines. -- Stable sample-side and cylinder-side loss indicators based on at least three minutes of data, with rates in bar/h and estimated CONTROL autonomy. +- Immediate sample-side and cylinder-side loss indicators using a five-minute moving average, with rates in bar/h and estimated CONTROL autonomy. +- Independent manual long term leak measurements for sample and cylinder, using two-hour moving averages and separate Reset buttons. - Source-margin interlock: CONTROL stops below 2.0 bar margin and rearms at 2.2 bar. - Confirmation for target increases of at least 10 bar and slew above 0.5 bar/s. - Automatic MEASURE attempt on required-telemetry loss during CONTROL. diff --git a/cross_platform/README.md b/cross_platform/README.md index ae59c82..caa1777 100644 --- a/cross_platform/README.md +++ b/cross_platform/README.md @@ -48,7 +48,8 @@ Ethernet and RS-232 use the same SCPI control engine and the same safety checks. - **Indenting**: target, 120-second hold, return to zero, final MEASURE. - Editable JSON pressure routines with target, slew, dwell, and notes. - Protected pressurization parameters behind a padlock and danger confirmation. -- Stable sample-side and cylinder-side loss indicators based on at least three minutes of data, with rates in bar/h and estimated CONTROL autonomy. +- Immediate sample-side and cylinder-side loss indicators using a five-minute moving average, with rates in bar/h and estimated CONTROL autonomy. +- Independent manual long term leak measurements for sample and cylinder, using two-hour moving averages and separate Reset buttons. - CSV telemetry and diagnostic logs at full received precision. - Offline simulator for hardware-free validation. - Bilingual Help menu with author/affiliation acknowledgements and a direct diff --git a/cross_platform/docs/PACE_Controller_Manual.md b/cross_platform/docs/PACE_Controller_Manual.md index 6586a72..15b03f5 100644 --- a/cross_platform/docs/PACE_Controller_Manual.md +++ b/cross_platform/docs/PACE_Controller_Manual.md @@ -303,23 +303,29 @@ Only decreases are considered pressure losses. Increases are clipped to zero los ### 13.2 Operating condition -Sample-side history is collected in MEASURE with no active automation. Cylinder/positive-source history is collected both in MEASURE and during steady, in-limits CONTROL, but is reset while the target is moving. No rate is shown until at least three minutes of data have been averaged by regression, avoiding unstable instantaneous estimates. +Sample-side history is collected in MEASURE with no active automation. Cylinder/positive-source history is collected both in MEASURE and during steady, in-limits CONTROL, but is reset while the target is moving. The loss rate is shown as soon as two valid readings at different times are available; there is no three-minute waiting period. ### 13.3 Calculation and default classes -A linear least-squares slope is calculated over the rolling history. The fitted 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. 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 fitted loss rate | +| Display | Colour | Equivalent average loss rate | |---|---|---| -| NO LEAK | Green | `<=0.0005 bar/min` after 10 min | -| WARNING: slight leak | Yellow | `>0.0005` and `<=0.001 bar/min` after 5 min | -| WARNING: pressure leak | Orange | `>0.001` and `<=0.005 bar/min` after at least 3 min | -| WARNING: SIGNIFICANT PRESSURE LEAK | Red | `>0.005 bar/min` after at least 3 min | +| NO LEAK | Green | `<=0.0005 bar/min` after 10 min of uninterrupted monitoring | +| WARNING: slight leak | Yellow | `>0.0005` and `<=0.001 bar/min`, immediately | +| WARNING: pressure leak | Orange | `>0.001` and `<=0.005 bar/min` immediately | +| WARNING: SIGNIFICANT PRESSURE LEAK | Red | `>0.005 bar/min` immediately | The SETTINGS page permits editing the reference drop and green, yellow, and orange times. Required ordering is `green > yellow > orange > 0`. A pressure trend does not uniquely identify a physical leak. Thermal equilibration, regulator hysteresis, pressure-medium behaviour, and sensor noise can produce similar signals. Confirm warnings using an appropriate laboratory leak-test procedure. +### 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. + +**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. + ## 14. Data and logs The application writes: diff --git a/cross_platform/src/pace_controller/i18n.py b/cross_platform/src/pace_controller/i18n.py index c3d93ef..82747b7 100644 --- a/cross_platform/src/pace_controller/i18n.py +++ b/cross_platform/src/pace_controller/i18n.py @@ -53,6 +53,12 @@ "slight_leak": "WARNING: slight leak", "pressure_leak": "WARNING: pressure leak", "significant_leak": "WARNING: SIGNIFICANT PRESSURE LEAK", + "long_term_start": "Measure long term leak", + "long_term_reset": "Reset", + "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}", + "long_term_window": "Available window: {minutes:.1f} / 120 min", "loss_rate_hour": "Loss: {rate:.3f} bar/h", "control_autonomy": "CONTROL autonomy: ~{hours:.1f} h", "control_autonomy_infinite": "CONTROL autonomy: ∞ at current rate", @@ -186,6 +192,12 @@ "slight_leak": "ATTENZIONE, lieve perdita", "pressure_leak": "ATTENZIONE, perdita pressione", "significant_leak": "ATTENZIONE, PERDITA SIGNIFICATIVA PRESSIONE", + "long_term_start": "Misura long term leak", + "long_term_reset": "Reset", + "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}", + "long_term_window": "Finestra disponibile: {minutes:.1f} / 120 min", "loss_rate_hour": "Perdita: {rate:.3f} bar/h", "control_autonomy": "Autonomia CONTROL: ~{hours:.1f} h", "control_autonomy_infinite": "Autonomia CONTROL: ∞ al tasso attuale", diff --git a/cross_platform/src/pace_controller/leak.py b/cross_platform/src/pace_controller/leak.py index f41b0e4..7765b9b 100644 --- a/cross_platform/src/pace_controller/leak.py +++ b/cross_platform/src/pace_controller/leak.py @@ -32,17 +32,19 @@ class LeakAssessment: class LeakMonitor: - def __init__( - self, thresholds: LeakThresholds, minimum_observation_minutes: float = 3.0 - ) -> None: + """Display loss immediately, averaging signed rates over a configurable rolling window.""" + + def __init__(self, thresholds: LeakThresholds, window_minutes: float = 5.0) -> None: + if not isfinite(window_minutes) or window_minutes <= 0: + raise ValueError("Averaging window must be finite and positive") + self.window_seconds = window_minutes * 60.0 self.thresholds = thresholds - if minimum_observation_minutes <= 0: - raise ValueError("Minimum observation time must be positive") - self.minimum_observation_minutes = minimum_observation_minutes self.samples: deque[tuple[float, float]] = deque() + self.started_at: float | None = None def reset(self) -> None: self.samples.clear() + self.started_at = None def update_thresholds(self, thresholds: LeakThresholds) -> None: thresholds.validate() @@ -53,21 +55,32 @@ def add(self, timestamp: float, value: float, enabled: bool) -> LeakAssessment: if not enabled: self.reset() return LeakAssessment("paused_control") - if not isfinite(value): + if not (isfinite(timestamp) and isfinite(value)): + return LeakAssessment("assessing") + if self.samples and timestamp <= self.samples[-1][0]: return LeakAssessment("assessing") + if self.started_at is None: + self.started_at = timestamp self.samples.append((timestamp, value)) - window_seconds = self.thresholds.green_minutes * 60.0 - while self.samples and timestamp - self.samples[0][0] > window_seconds: + cutoff = timestamp - self.window_seconds + # Keep one point at/before the boundary for interpolation when polling + # times do not land exactly on the rolling-window cutoff. + while len(self.samples) > 1 and self.samples[1][0] <= cutoff: self.samples.popleft() if len(self.samples) < 2: return LeakAssessment("assessing") - elapsed_minutes = (self.samples[-1][0] - self.samples[0][0]) / 60.0 - if elapsed_minutes <= 0: - return LeakAssessment("assessing") - rate = max(0.0, -self._linear_slope_bar_min()) - if elapsed_minutes < self.minimum_observation_minutes: - return LeakAssessment("assessing") + start, start_value = self.samples[0] + if start < cutoff: + next_time, next_value = self.samples[1] + fraction = (cutoff - start) / (next_time - start) + start_value += fraction * (next_value - start_value) + start = cutoff + elapsed_minutes = (timestamp - start) / 60.0 + # Time-weighted mean of successive signed pressure-loss rates: + # sum(rate_i * dt_i) / sum(dt_i) = net pressure drop / duration. + # Clip only the final mean, so noise/increases do not create false loss. + rate = max(0.0, (start_value - value) / elapsed_minutes) t = self.thresholds green_rate = t.reference_drop_bar / t.green_minutes @@ -76,22 +89,12 @@ def add(self, timestamp: float, value: float, enabled: bool) -> LeakAssessment: if rate > orange_rate: return LeakAssessment("significant_leak", rate, elapsed_minutes) - if elapsed_minutes >= t.orange_minutes and rate > yellow_rate: + if rate > yellow_rate: return LeakAssessment("pressure_leak", rate, elapsed_minutes) - if elapsed_minutes >= t.yellow_minutes and rate > green_rate: + if rate > green_rate: return LeakAssessment("slight_leak", rate, elapsed_minutes) - if elapsed_minutes >= t.green_minutes and rate <= green_rate: + # Green confirmation uses total uninterrupted monitoring time, not + # window length: its configurable default is longer than the averaging window. + if (timestamp - self.started_at) / 60.0 >= t.green_minutes: return LeakAssessment("no_leak", rate, elapsed_minutes) return LeakAssessment("assessing", rate, elapsed_minutes) - - def _linear_slope_bar_min(self) -> float: - origin = self.samples[0][0] - xs = [(stamp - origin) / 60.0 for stamp, _ in self.samples] - ys = [value for _, value in self.samples] - count = len(xs) - mean_x = sum(xs) / count - mean_y = sum(ys) / count - denominator = sum((x - mean_x) ** 2 for x in xs) - if denominator == 0: - return 0.0 - return sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys)) / denominator diff --git a/cross_platform/src/pace_controller/ui.py b/cross_platform/src/pace_controller/ui.py index 4ac9043..38ce959 100644 --- a/cross_platform/src/pace_controller/ui.py +++ b/cross_platform/src/pace_controller/ui.py @@ -85,11 +85,6 @@ ISSUES_URL = f"{REPOSITORY_URL}/issues" -def assessment_rate_minimum_minutes() -> float: - """Minimum regression span used by every UI leak monitor.""" - return 3.0 - - class LockButton(QToolButton): def __init__(self) -> None: super().__init__() @@ -168,6 +163,17 @@ def __init__(self, title_key: str) -> None: self.value.setFont(font) layout.addWidget(self.title) layout.addWidget(self.value) + controls = QHBoxLayout() + self.long_term_button = QPushButton() + self.long_term_reset = QPushButton() + controls.addWidget(self.long_term_button, 1) + controls.addWidget(self.long_term_reset) + layout.addLayout(controls) + self.long_term_value = QLabel() + self.long_term_value.setAlignment(Qt.AlignCenter) + self.long_term_value.setWordWrap(True) + self.long_term_value.hide() + layout.addWidget(self.long_term_value) self.set_level("assessing", "ASSESSING") def set_level(self, level: str, text: str) -> None: @@ -234,12 +240,19 @@ def __init__( self.parameters_unlocked = False self.current_telemetry = Telemetry() self.capabilities = DeviceCapabilities() - minimum_minutes = assessment_rate_minimum_minutes() - self.sample_monitor = LeakMonitor(settings.leak_thresholds, minimum_minutes) - self.inlet_monitor = LeakMonitor(settings.leak_thresholds, minimum_minutes) + self.sample_monitor = LeakMonitor(settings.leak_thresholds) + self.inlet_monitor = LeakMonitor(settings.leak_thresholds) self.sample_assessment = LeakAssessment("assessing") self.inlet_assessment = LeakAssessment("assessing") self.source_monitoring_state: str | None = None + self.long_term_monitors = { + side: LeakMonitor(settings.leak_thresholds, window_minutes=120.0) + for side in ("sample", "inlet") + } + self.long_term_active: set[str] = set() + self.long_term_assessments = { + side: LeakAssessment("assessing") for side in self.long_term_monitors + } self._localized: list[tuple[object, str]] = [] self.setObjectName("mainWindow") @@ -421,6 +434,14 @@ def _build_ui(self) -> None: leak_layout.setSpacing(8) self.sample_leak = LeakCard("sample_leak_title") self.inlet_leak = LeakCard("inlet_leak_title") + self.leak_cards = {"sample": self.sample_leak, "inlet": self.inlet_leak} + for side, card in self.leak_cards.items(): + card.long_term_button.clicked.connect( + lambda checked=False, side=side: self.start_long_term_leak(side) + ) + card.long_term_reset.clicked.connect( + lambda checked=False, side=side: self.start_long_term_leak(side) + ) leak_layout.addWidget(self.sample_leak, 1) leak_layout.addWidget(self.inlet_leak, 1) root.addLayout(leak_layout) @@ -886,6 +907,10 @@ def set_connected(self, connected: bool) -> None: self.transport_stack.setEnabled(not connected) self.module_combo.setEnabled(not connected) if not connected: + self.long_term_active.clear() + for side, monitor in self.long_term_monitors.items(): + monitor.reset() + self.long_term_assessments[side] = LeakAssessment("assessing") self.sample_monitor.reset() self.inlet_monitor.reset() self.sample_assessment = LeakAssessment("assessing") @@ -895,6 +920,8 @@ def set_connected(self, connected: bool) -> None: self.parameters_unlocked = False self._set_parameter_lock_ui() + self._refresh_long_term_leaks() + def set_busy(self, busy: bool) -> None: self.busy = busy for widget in ( @@ -1104,11 +1131,21 @@ def on_telemetry(self, telemetry: object) -> None: ) if source_state != self.source_monitoring_state: self.inlet_monitor.reset() + self.long_term_monitors["inlet"].reset() self.source_monitoring_state = source_state source_stable = source_state != "moving" self.inlet_assessment = self.inlet_monitor.add( telemetry.timestamp, telemetry.positive_source_bar, source_stable ) + for side, pressure, enabled in ( + ("sample", telemetry.current_pressure_bar, not telemetry.control and not self.busy), + ("inlet", telemetry.positive_source_bar, source_stable), + ): + if side in self.long_term_active: + self.long_term_assessments[side] = self.long_term_monitors[side].add( + telemetry.timestamp, pressure, enabled + ) + self._refresh_long_term_leaks() self._apply_leak(self.sample_leak, self.sample_assessment) self._apply_leak( self.inlet_leak, @@ -1191,6 +1228,10 @@ def save_leak_settings(self) -> None: self.settings.leak_thresholds = thresholds self.sample_monitor.update_thresholds(thresholds) self.inlet_monitor.update_thresholds(thresholds) + for side, monitor in self.long_term_monitors.items(): + monitor.update_thresholds(thresholds) + self.long_term_assessments[side] = LeakAssessment("assessing") + self._refresh_long_term_leaks() save_settings(self.settings) self.log_view.append(self.t("settings_saved")) except ValueError as exc: @@ -1215,7 +1256,7 @@ def _apply_leak( autonomy_hours: float | None = None, ) -> None: lines = [self.t(assessment.level)] - if assessment.observation_minutes >= assessment_rate_minimum_minutes(): + if assessment.observation_minutes > 0: lines.append(self.t("loss_rate_hour", rate=assessment.rate_bar_min * 60.0)) if autonomy_hours is not None: lines.append( @@ -1244,6 +1285,35 @@ def _refresh_leak_texts(self) -> None: self.inlet_assessment, self._control_autonomy_hours(self.inlet_assessment, self.current_telemetry), ) + self._refresh_long_term_leaks() + + 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_assessments[side] = LeakAssessment("assessing") + 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 + card.long_term_button.setText(self.t("long_term_start")) + card.long_term_button.setToolTip(self.t("long_term_hint")) + 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: + continue + assessment = self.long_term_assessments[side] + lines = [self.t("long_term_status", 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)) + 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};") def _set_if_finite(self, edit: QLineEdit, value: object) -> None: try: diff --git a/cross_platform/tests/test_core.py b/cross_platform/tests/test_core.py index b9e9325..a4c3437 100644 --- a/cross_platform/tests/test_core.py +++ b/cross_platform/tests/test_core.py @@ -304,16 +304,150 @@ def test_leak_monitor_pauses_during_control() -> None: assert not monitor.samples -def test_leak_rate_is_hidden_until_three_minutes_are_averaged() -> None: +@pytest.mark.parametrize( + ("rate", "expected"), + [(0.0008, "slight_leak"), (0.003, "pressure_leak"), (0.008, "significant_leak")], +) +def test_leak_rate_and_warnings_are_available_immediately(rate: float, expected: str) -> None: + monitor = LeakMonitor(LeakThresholds()) + first = monitor.add(0.0, 50.0, True) + assert first.observation_minutes == 0.0 + early = monitor.add(1.0, 50.0 - rate / 60.0, True) + assert early.level == expected + assert early.observation_minutes == pytest.approx(1.0 / 60.0) + assert early.rate_bar_min == pytest.approx(rate) + + +def test_five_minute_average_excludes_old_loss_and_keeps_green_confirmation() -> None: + monitor = LeakMonitor(LeakThresholds()) + monitor.add(0.0, 50.0, True) + monitor.add(60.0, 49.0, True) + for timestamp in range(120, 601, 60): + result = monitor.add(float(timestamp), 49.0, True) + assert result.rate_bar_min == 0.0 + assert result.observation_minutes == 5.0 + assert result.level == "no_leak" + assert monitor.samples[0][0] == 300.0 + monitor.add(601.0, 49.0, False) + assert monitor.add(602.0, 49.0, True).level == "assessing" + assert monitor.add(603.0, 49.0, True).level == "assessing" + + +def test_average_interpolates_boundary_and_weights_irregular_intervals() -> None: + monitor = LeakMonitor(LeakThresholds()) + for timestamp, value in [(0.0, 50.0), (100.0, 49.0), (200.0, 49.0)]: + monitor.add(timestamp, value, True) + result = monitor.add(350.0, 48.0, True) + # Boundary at 50 seconds: pressure 49.5, net loss 1.5 bar over 5 min. + assert result.observation_minutes == 5.0 + assert result.rate_bar_min == pytest.approx(0.3) + + +def test_average_does_not_count_oscillating_noise_as_loss() -> None: + monitor = LeakMonitor(LeakThresholds()) + monitor.add(0.0, 50.0, True) + monitor.add(1.0, 49.0, True) + result = monitor.add(3.0, 50.0, True) + assert result.rate_bar_min == 0.0 + + +def test_invalid_or_repeated_timestamps_do_not_corrupt_average() -> None: monitor = LeakMonitor(LeakThresholds()) monitor.add(0.0, 50.0, True) - early = monitor.add(179.0, 49.0, True) - ready = monitor.add(180.0, 49.0, True) - assert early.level == "assessing" - assert early.observation_minutes == 0.0 - assert ready.observation_minutes == pytest.approx(3.0) - assert ready.rate_bar_min > 0 + for timestamp, value in [(float("nan"), 49.0), (1.0, float("nan")), (0.0, 40.0)]: + assert monitor.add(timestamp, value, True).level == "assessing" + result = monitor.add(60.0, 49.0, True) + assert result.rate_bar_min == pytest.approx(1.0) + + +def test_ui_shows_early_loss_rate_and_autonomy() -> None: + from types import SimpleNamespace + + from pace_controller.i18n import Translator + from pace_controller.leak import LeakAssessment + from pace_controller.ui import MainWindow + + displayed = [] + card = SimpleNamespace(set_level=lambda level, text: displayed.append((level, text))) + window = SimpleNamespace(t=Translator("en")) + MainWindow._apply_leak( + window, card, LeakAssessment("pressure_leak", 0.003, 1.0 / 60.0), 10.0 + ) + assert displayed[0][0] == "pressure_leak" + assert "0.180 bar/h" in displayed[0][1] + assert "10.0 h" in displayed[0][1] + MainWindow._apply_leak(window, card, LeakAssessment("paused_control")) + assert "bar/h" not in displayed[1][1] def test_control_autonomy_uses_source_to_sample_pressure_headroom() -> None: assert control_autonomy_hours(50.0, 40.0, 1.0 / 60.0) == pytest.approx(10.0) + + +@pytest.mark.parametrize("window_minutes", [0.0, -1.0, float("nan"), float("inf")]) +def test_invalid_averaging_windows_are_rejected(window_minutes: float) -> None: + with pytest.raises(ValueError): + LeakMonitor(LeakThresholds(), window_minutes) + + +def test_two_hour_window_is_independent_of_short_term_and_reset() -> None: + short = LeakMonitor(LeakThresholds()) + long = LeakMonitor(LeakThresholds(), window_minutes=120.0) + for timestamp in range(0, 7801, 60): + pressure = 50.0 - 0.01 * min(timestamp / 60.0, 60.0) + short_result = short.add(float(timestamp), pressure, True) + long_result = long.add(float(timestamp), pressure, True) + assert short_result.rate_bar_min == 0.0 + assert long_result.observation_minutes == 120.0 + assert long_result.rate_bar_min == pytest.approx(0.5 / 120.0) + assert long.samples[0][0] == 600.0 + short_history = list(short.samples) + long.reset() + assert not long.samples + assert list(short.samples) == short_history + assert long.add(7801.0, 49.4, True).observation_minutes == 0.0 + + +def test_long_term_buttons_start_and_reset_each_side_independently(monkeypatch, tmp_path) -> None: + import os + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from pace_controller.models import AppSettings, Telemetry + from pace_controller.service import PaceService + from pace_controller.ui import MainWindow + + monkeypatch.setenv("PACE_CONTROLLER_DATA_DIR", str(tmp_path)) + monkeypatch.setattr(PaceService, "start", lambda self: None) + monkeypatch.setattr(PaceService, "shutdown", lambda self: None) + app = QApplication.instance() or QApplication([]) + window = MainWindow(PaceService(), AppSettings()) + try: + window.on_connection_changed(True, {"key": "connected"}) + 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 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() + 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 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 + window.on_telemetry(Telemetry(timestamp=62.0, current_pressure_bar=9.9, positive_source_bar=49.8)) + assert len(window.long_term_monitors["sample"].samples) == 1 + window.on_telemetry(Telemetry(timestamp=63.0, current_pressure_bar=9.9, positive_source_bar=49.8, control=True, in_limits=False)) + assert not window.long_term_monitors["sample"].samples + assert not window.long_term_monitors["inlet"].samples + window.on_connection_changed(False, {"key": "disconnected"}) + assert not window.long_term_active + assert not window.sample_leak.long_term_button.isEnabled() + finally: + window.close() + app.processEvents()