Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ All notable changes are documented here. The project follows semantic versioning

- 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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions cross_platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions cross_platform/docs/PACE_Controller_Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -320,9 +320,11 @@ 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 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.

Expand Down
2 changes: 2 additions & 0 deletions cross_platform/src/pace_controller/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
42 changes: 35 additions & 7 deletions cross_platform/src/pace_controller/leak.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

from collections import deque
from dataclasses import dataclass
from math import inf, isfinite
from itertools import islice
from statistics import median
from math import fsum, inf, isfinite

from .models import LeakThresholds

Expand All @@ -29,10 +31,11 @@ class LeakAssessment:
level: str
rate_bar_min: float = 0.0
observation_minutes: float = 0.0
history_reset: bool = False


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:
Expand All @@ -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))
Expand All @@ -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:
Expand All @@ -77,10 +83,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
Expand All @@ -98,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
6 changes: 6 additions & 0 deletions cross_platform/src/pace_controller/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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),
Expand Down
95 changes: 91 additions & 4 deletions cross_platform/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -471,3 +471,90 @@ 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)


@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()