From 244838b46b4aab2c2bd50045ff8ecb1bab84db52 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Mon, 5 Oct 2026 12:20:38 +0200 Subject: [PATCH 1/2] Fit rolling pressure trends using all window readings --- CHANGELOG.md | 2 ++ README.md | 4 +-- cross_platform/README.md | 4 +-- cross_platform/docs/PACE_Controller_Manual.md | 6 ++-- cross_platform/src/pace_controller/leak.py | 18 ++++++---- cross_platform/tests/test_core.py | 35 ++++++++++++++++--- 6 files changed, 52 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f525d3..b9fcff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes are documented here. The project follows semantic versioning ## [Unreleased] +- Estimate short term and long term pressure-loss rates using linear least-squares fits over all readings in their rolling windows, instead of endpoint differences. + - 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. diff --git a/README.md b/README.md index fab88a0..c64447a 100644 --- a/README.md +++ b/README.md @@ -53,8 +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. -- 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. +- Immediate sample-side and cylinder-side loss indicators using a rolling five-minute linear fit, with rates in bar/h and estimated CONTROL autonomy. +- Independent manual long term leak measurements for sample and cylinder, using rolling two-hour linear fits 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 3344e75..a4c2807 100644 --- a/cross_platform/README.md +++ b/cross_platform/README.md @@ -48,8 +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. -- 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. +- Immediate sample-side and cylinder-side loss indicators using a rolling five-minute linear fit, with rates in bar/h and estimated CONTROL autonomy. +- Independent manual long term leak measurements for sample and cylinder, using rolling two-hour linear fits 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 647b249..8b2bf6e 100644 --- a/cross_platform/docs/PACE_Controller_Manual.md +++ b/cross_platform/docs/PACE_Controller_Manual.md @@ -307,9 +307,9 @@ 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. 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: +A linear least-squares fit of pressure versus time is calculated over all readings in the rolling five-minute window. The reported loss rate is the negative fitted slope; pressure-increase slopes are clipped to zero loss. The fit uses actual timestamps and a pressure interpolated at the window boundary when needed. Intermediate readings contribute to the fit, reducing sensitivity to noise at the two endpoints. During startup, the available history is used without waiting for five minutes. The fitted 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 | +| Display | Colour | Equivalent fitted loss rate | |---|---|---| | 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 | @@ -322,7 +322,7 @@ 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**, **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. +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 linear fit over a rolling two-hour window, 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. diff --git a/cross_platform/src/pace_controller/leak.py b/cross_platform/src/pace_controller/leak.py index 7765b9b..64f9d7c 100644 --- a/cross_platform/src/pace_controller/leak.py +++ b/cross_platform/src/pace_controller/leak.py @@ -4,7 +4,7 @@ from collections import deque from dataclasses import dataclass -from math import inf, isfinite +from math import fsum, inf, isfinite from .models import LeakThresholds @@ -32,7 +32,7 @@ class LeakAssessment: class LeakMonitor: - """Display loss immediately, averaging signed rates over a configurable rolling window.""" + """Display the loss rate fitted to pressure readings in a rolling window.""" def __init__(self, thresholds: LeakThresholds, window_minutes: float = 5.0) -> None: if not isfinite(window_minutes) or window_minutes <= 0: @@ -77,10 +77,16 @@ def add(self, timestamp: float, value: float, enabled: bool) -> LeakAssessment: 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) + # Fit all readings inside the window, including its interpolated + # boundary when needed. Relative times avoid large epoch timestamps. + points = [(start, start_value), *list(self.samples)[1:]] + xs = [(stamp - start) / 60.0 for stamp, _ in points] + ys = [pressure for _, pressure in points] + mean_x = fsum(xs) / len(xs) + mean_y = fsum(ys) / len(ys) + denominator = fsum((x - mean_x) ** 2 for x in xs) + slope = fsum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys)) / denominator + rate = max(0.0, -slope) t = self.thresholds green_rate = t.reference_drop_bar / t.green_minutes diff --git a/cross_platform/tests/test_core.py b/cross_platform/tests/test_core.py index c167e5d..76181f2 100644 --- a/cross_platform/tests/test_core.py +++ b/cross_platform/tests/test_core.py @@ -333,14 +333,14 @@ def test_five_minute_average_excludes_old_loss_and_keeps_green_confirmation() -> assert monitor.add(603.0, 49.0, True).level == "assessing" -def test_average_interpolates_boundary_and_weights_irregular_intervals() -> None: +def test_regression_interpolates_boundary_and_uses_irregular_timestamps() -> 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. + # Fit (50,49.5), (100,49), (200,49), (350,48): loss slope = 19/70 bar/min. assert result.observation_minutes == 5.0 - assert result.rate_bar_min == pytest.approx(0.3) + assert result.rate_bar_min == pytest.approx(19.0 / 70.0) def test_average_does_not_count_oscillating_noise_as_loss() -> None: @@ -403,7 +403,7 @@ def test_two_hour_window_is_independent_of_short_term_and_reset() -> None: 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_result.rate_bar_min == pytest.approx(2227.0 / 590480.0) assert long.samples[0][0] == 600.0 short_history = list(short.samples) long.reset() @@ -471,3 +471,30 @@ def test_long_term_buttons_start_and_reset_each_side_independently(monkeypatch, finally: window.close() app.processEvents() + + +@pytest.mark.parametrize("window_minutes", [5.0, 120.0]) +def test_linear_fit_recovers_loss_with_irregular_polling_and_epoch_timestamps(window_minutes): + monitor = LeakMonitor(LeakThresholds(), window_minutes=window_minutes) + origin = 1_800_000_000.0 + for offset in [0.0, 1.0, 9.0, 70.0, 150.0, 290.0, 350.0]: + result = monitor.add(origin + offset, 50.0 - 0.003 * offset / 60.0, True) + assert result.rate_bar_min == pytest.approx(0.003) + assert result.observation_minutes == pytest.approx(min(350.0 / 60.0, window_minutes)) + + +def test_regression_reduces_endpoint_noise_with_all_window_readings(): + monitor = LeakMonitor(LeakThresholds()) + for timestamp in range(301): + pressure = 50.1 if timestamp == 0 else 49.9 if timestamp == 300 else 50.0 + result = monitor.add(float(timestamp), pressure, True) + endpoint_rate = (50.1 - 49.9) / 5.0 + assert result.rate_bar_min < endpoint_rate / 10.0 + assert result.rate_bar_min == pytest.approx(1800.0 / 2272550.0) + + +def test_regression_uses_interior_readings_even_when_endpoints_match(): + monitor = LeakMonitor(LeakThresholds()) + for timestamp, pressure in [(0.0, 50.0), (60.0, 50.02), (120.0, 50.0), (180.0, 49.99), (240.0, 50.0)]: + result = monitor.add(timestamp, pressure, True) + assert result.rate_bar_min == pytest.approx(0.003) From d69b8b76cefb1ec485f654df5a9dc7b0bd31eb64 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Mon, 5 Oct 2026 12:24:31 +0200 Subject: [PATCH 2/2] Restart affected leak windows after discontinuous pressure changes --- CHANGELOG.md | 4 +- cross_platform/docs/PACE_Controller_Manual.md | 2 + cross_platform/src/pace_controller/i18n.py | 2 + cross_platform/src/pace_controller/leak.py | 24 +++++++- cross_platform/src/pace_controller/ui.py | 6 ++ cross_platform/tests/test_core.py | 60 +++++++++++++++++++ 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9fcff2..c10f872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,10 @@ All notable changes are documented here. The project follows semantic versioning ## [Unreleased] -- Estimate short term and long term pressure-loss rates using linear least-squares fits over all readings in their rolling windows, instead of endpoint differences. - - 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. +- Estimate short term and long term pressure-loss rates using linear least-squares fits over all readings in their rolling windows, instead of endpoint differences. +- Restart the affected side's short term and active long term regression windows after large pressure steps, such as cylinder refills, while retaining continuous drift. ## [1.1.1] - 2026-10-05 diff --git a/cross_platform/docs/PACE_Controller_Manual.md b/cross_platform/docs/PACE_Controller_Manual.md index 8b2bf6e..22c8b95 100644 --- a/cross_platform/docs/PACE_Controller_Manual.md +++ b/cross_platform/docs/PACE_Controller_Manual.md @@ -320,6 +320,8 @@ The SETTINGS page permits editing the reference drop and green, yellow, and oran 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. +A large pressure discontinuity (for example a cylinder refill) restarts the short term history and the active long term history for that side from the new reading. The detector compares each pressure change with the median recent drift, using up to ten intervals. The deviation must exceed both 0.1 bar and eight times the recent median absolute rate deviation multiplied by the polling interval. Continuous large drift is retained rather than treated as a step. A reset is recorded in the on-screen log; the window counters return to zero. The other side and stopped long term results are unaffected. + ### 13.4 Manual long term leak measurement 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 linear fit over a rolling two-hour window, 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. diff --git a/cross_platform/src/pace_controller/i18n.py b/cross_platform/src/pace_controller/i18n.py index 78b715d..4a842b7 100644 --- a/cross_platform/src/pace_controller/i18n.py +++ b/cross_platform/src/pace_controller/i18n.py @@ -53,6 +53,7 @@ "slight_leak": "WARNING: slight leak", "pressure_leak": "WARNING: pressure leak", "significant_leak": "WARNING: SIGNIFICANT PRESSURE LEAK", + "leak_step_reset": "Pressure step detected: {side}; active loss measurements restarted.", "long_term_start": "Measure long term leak", "long_term_reset": "Reset", "long_term_stop": "Stop", @@ -196,6 +197,7 @@ "slight_leak": "ATTENZIONE, lieve perdita", "pressure_leak": "ATTENZIONE, perdita pressione", "significant_leak": "ATTENZIONE, PERDITA SIGNIFICATIVA PRESSIONE", + "leak_step_reset": "Scalino di pressione rilevato: {side}; misure attive delle perdite reinizializzate.", "long_term_start": "Misura long term leak", "long_term_reset": "Reset", "long_term_stop": "Stop", diff --git a/cross_platform/src/pace_controller/leak.py b/cross_platform/src/pace_controller/leak.py index 64f9d7c..764b9a2 100644 --- a/cross_platform/src/pace_controller/leak.py +++ b/cross_platform/src/pace_controller/leak.py @@ -4,6 +4,8 @@ from collections import deque from dataclasses import dataclass +from itertools import islice +from statistics import median from math import fsum, inf, isfinite from .models import LeakThresholds @@ -29,6 +31,7 @@ class LeakAssessment: level: str rate_bar_min: float = 0.0 observation_minutes: float = 0.0 + history_reset: bool = False class LeakMonitor: @@ -59,6 +62,9 @@ def add(self, timestamp: float, value: float, enabled: bool) -> LeakAssessment: return LeakAssessment("assessing") if self.samples and timestamp <= self.samples[-1][0]: return LeakAssessment("assessing") + history_reset = self._is_pressure_step(timestamp, value) + if history_reset: + self.reset() if self.started_at is None: self.started_at = timestamp self.samples.append((timestamp, value)) @@ -68,7 +74,7 @@ def add(self, timestamp: float, value: float, enabled: bool) -> LeakAssessment: while len(self.samples) > 1 and self.samples[1][0] <= cutoff: self.samples.popleft() if len(self.samples) < 2: - return LeakAssessment("assessing") + return LeakAssessment("assessing", history_reset=history_reset) start, start_value = self.samples[0] if start < cutoff: @@ -104,3 +110,19 @@ def add(self, timestamp: float, value: float, enabled: bool) -> LeakAssessment: if (timestamp - self.started_at) / 60.0 >= t.green_minutes: return LeakAssessment("no_leak", rate, elapsed_minutes) return LeakAssessment("assessing", rate, elapsed_minutes) + + def _is_pressure_step(self, timestamp: float, value: float) -> bool: + """Detect a discontinuity relative to recent drift and measurement noise.""" + if len(self.samples) < 3: + return False + recent = list(islice(reversed(self.samples), 11)) + recent.reverse() + rates = [(p2 - p1) / (t2 - t1) for (t1, p1), (t2, p2) in zip(recent, recent[1:])] + drift = median(rates) + noise = median(abs(rate - drift) for rate in rates) + previous_time, previous_value = recent[-1] + interval = timestamp - previous_time + departure = abs(value - previous_value - drift * interval) + # A 0.1 bar floor avoids reacting to small fluctuations. Scale the + # threshold to recent rate variability and actual polling duration. + return departure > max(0.1, 8.0 * noise * interval) + 1e-9 diff --git a/cross_platform/src/pace_controller/ui.py b/cross_platform/src/pace_controller/ui.py index 82cc816..ac9e265 100644 --- a/cross_platform/src/pace_controller/ui.py +++ b/cross_platform/src/pace_controller/ui.py @@ -1127,6 +1127,9 @@ def on_telemetry(self, telemetry: object) -> None: self.sample_assessment = self.sample_monitor.add( telemetry.timestamp, telemetry.current_pressure_bar, not telemetry.control and not self.busy ) + if self.sample_assessment.history_reset: + self.long_term_monitors["sample"].reset() + self.log_view.append(self.t("leak_step_reset", side=self.t("sample_leak_title"))) # Source consumption is meaningful in steady CONTROL and a static source # leak is meaningful in MEASURE. Reset while a CONTROL target is moving. source_state = ( @@ -1144,6 +1147,9 @@ def on_telemetry(self, telemetry: object) -> None: self.inlet_assessment = self.inlet_monitor.add( telemetry.timestamp, telemetry.positive_source_bar, source_stable ) + if self.inlet_assessment.history_reset: + self.long_term_monitors["inlet"].reset() + self.log_view.append(self.t("leak_step_reset", side=self.t("inlet_leak_title"))) for side, pressure, enabled in ( ("sample", telemetry.current_pressure_bar, not telemetry.control and not self.busy), ("inlet", telemetry.positive_source_bar, source_stable), diff --git a/cross_platform/tests/test_core.py b/cross_platform/tests/test_core.py index 76181f2..746f4ae 100644 --- a/cross_platform/tests/test_core.py +++ b/cross_platform/tests/test_core.py @@ -498,3 +498,63 @@ def test_regression_uses_interior_readings_even_when_endpoints_match(): for timestamp, pressure in [(0.0, 50.0), (60.0, 50.02), (120.0, 50.0), (180.0, 49.99), (240.0, 50.0)]: result = monitor.add(timestamp, pressure, True) assert result.rate_bar_min == pytest.approx(0.003) + + +@pytest.mark.parametrize("jump", [5.0, -5.0]) +@pytest.mark.parametrize("window_minutes", [5.0, 120.0]) +def test_pressure_steps_restart_window_from_new_baseline(jump, window_minutes): + monitor = LeakMonitor(LeakThresholds(), window_minutes=window_minutes) + for timestamp in range(21): + monitor.add(float(timestamp), 50.0 - timestamp * 0.001, True) + result = monitor.add(21.0, 49.979 + jump, True) + assert result.history_reset + assert result.observation_minutes == 0.0 + assert len(monitor.samples) == 1 + next_result = monitor.add(22.0, 49.978 + jump, True) + assert not next_result.history_reset + assert next_result.rate_bar_min == pytest.approx(0.06) + + +def test_large_continuous_drift_does_not_restart_the_regression(): + monitor = LeakMonitor(LeakThresholds()) + for timestamp in range(31): + result = monitor.add(float(timestamp), 50.0 - 0.2 * timestamp, True) + assert not result.history_reset + assert result.observation_minutes == 0.5 + assert result.rate_bar_min == pytest.approx(12.0) + + +def test_cylinder_refill_resets_only_its_active_short_and_long_measurements(monkeypatch, tmp_path): + 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.start_long_term_leak("sample") + window.start_long_term_leak("inlet") + for timestamp in range(21): + window.on_telemetry(Telemetry(timestamp=float(timestamp), current_pressure_bar=10.0, positive_source_bar=50.0)) + window.on_telemetry(Telemetry(timestamp=21.0, current_pressure_bar=10.0, positive_source_bar=60.0)) + assert window.inlet_assessment.history_reset + assert window.inlet_assessment.observation_minutes == 0.0 + assert window.long_term_assessments["inlet"].observation_minutes == 0.0 + assert window.sample_assessment.observation_minutes == pytest.approx(21.0 / 60.0) + assert window.long_term_assessments["sample"].observation_minutes == pytest.approx(21.0 / 60.0) + assert "0.0 / 120 min" in window.inlet_leak.long_term_value.text() + assert window.long_term_active == {"sample", "inlet"} + window.on_telemetry(Telemetry(timestamp=22.0, current_pressure_bar=11.0, positive_source_bar=60.0)) + assert window.sample_assessment.history_reset + assert window.long_term_assessments["sample"].observation_minutes == 0.0 + assert window.inlet_assessment.observation_minutes > 0.0 + assert window.long_term_assessments["inlet"].observation_minutes > 0.0 + finally: + window.close() + app.processEvents()