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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion cross_platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions cross_platform/docs/PACE_Controller_Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 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,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",
Expand Down Expand Up @@ -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",
Expand Down
63 changes: 33 additions & 30 deletions cross_platform/src/pace_controller/leak.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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
Loading