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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions cross_platform/docs/PACE_Controller_Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions cross_platform/src/pace_controller/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down
30 changes: 25 additions & 5 deletions cross_platform/src/pace_controller/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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};")
Expand Down
20 changes: 20 additions & 0 deletions cross_platform/tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down