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: 1 addition & 1 deletion cross_platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Ethernet and RS-232 use the same SCPI control engine and the same safety checks.
- Current pressure, target, positive and negative source, measured slew, valve effort, CONTROL/MEASURE state, in-limits state, and source margin.
- Manual target control with Linear or Maximum slew mode.
- Optional **Keep CONTROL at target**, disabled by default.
- **Indenting**: target, 120-second hold, return to zero, final MEASURE.
- **Indenting**: separate compression/decompression slew rates, configurable hold (default 120 seconds), pause/edit/resume, 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 rolling five-minute linear fit, with rates in bar/h and estimated CONTROL autonomy.
Expand Down
22 changes: 8 additions & 14 deletions cross_platform/docs/PACE_Controller_Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,17 +228,11 @@ VENT has a separate confirmation even when the panel is unlocked. The panel auto

## 10. Indenting cycle

The indenting page executes a fixed sequence:
Choose the target pressure, separate compression and decompression slew rates (bar/s), and the hold duration (seconds, default **120**). The cycle reaches the target in CONTROL, waits for in-limits, holds for the configured duration, returns to zero using the decompression slew, then requests MEASURE. The shared stable-zero criterion also applies to the return.

1. set the selected slew;
2. enter CONTROL and reach the requested pressure;
3. wait for the PACE in-limits indication;
4. maintain CONTROL for 120 seconds;
5. command zero bar with the same slew;
6. wait for in-limits at zero;
7. request MEASURE.
**Pause and change** holds the current measured pressure in CONTROL and freezes the hold timer. While paused, edit the target, both slew rates, and the hold duration. **Resume** continues the current phase with the new values. During compression or holding, it reaches the updated target before completing the remaining hold time; time already spent holding is retained and time paused is excluded. If the new duration is shorter than the elapsed hold time, no further hold is required. During decompression, it continues returning to zero using the updated decompression slew; completed phases are not repeated.

Stopping the cycle requests MEASURE immediately.
**STOP / MEASURE** ends the cycle and requests MEASURE immediately. Connection, module-range and source-margin checks remain active, including while paused and on resume.

## 11. Programmable routines

Expand Down Expand Up @@ -317,12 +311,12 @@ A linear least-squares fit of pressure versus time is calculated over all readin

| 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 |
| WARNING: pressure leak | Orange | `>0.001` and `<=0.005 bar/min` immediately |
| WARNING: SIGNIFICANT PRESSURE LEAK | Red | `>0.005 bar/min` immediately |
| NO LEAK | Green | `<0.1 bar/h` |
| WARNING: slight leak | Yellow | `>=0.1` and `<0.3 bar/h` |
| WARNING: pressure leak | Orange | `>=0.3` and `<=0.6 bar/h` |
| WARNING: SIGNIFICANT PRESSURE LEAK | Red | `>0.6 bar/h` |

The SETTINGS page permits editing the reference drop and green, yellow, and orange times. Required ordering is `green > yellow > orange > 0`.
The SETTINGS page directly edits the three colour boundaries in **bar/h**, with a live band summary and a **Restore defaults** button. Press **Save settings** to apply. Required ordering is `0 < green boundary < yellow boundary < orange boundary`. All colours are available as soon as a fitted rate exists, without a separate green waiting period. These thresholds apply to both sides and both short and long term measurements. Existing custom reference-drop/time settings are converted to equivalent hourly limits; old factory defaults become the new 0.1 / 0.3 / 0.6 bar/h defaults.

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.

Expand Down
26 changes: 24 additions & 2 deletions cross_platform/src/pace_controller/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"danger_title": "Danger area",
"danger_warning": "DANGER AREA: change these parameters only if you know exactly what you are doing. Incorrect values can cause hazardous pressure behaviour. Unlock the panel?",
"indenting_title": "Automatic indenting cycle",
"indenting_description": "Reach the target at the selected slew, hold CONTROL for 120 s, return to 0 bar at the same slew, then finish in MEASURE.",
"indenting_description": "Compress at the selected rate, hold for the configured time, then decompress to 0 bar. Pause holds the current pressure; edit values and Resume to continue the same phase. Time in pause does not count toward the hold.",
"start_indenting": "START INDENTING",
"routine_description": "Each row reaches its target, waits for In-limits, and holds for the specified time.",
"add_step": "Add step",
Expand Down Expand Up @@ -148,6 +148,17 @@
"project_updates": "Project and updates",
"open_link_title": "Open link",
"open_link_error": "PACE Controller could not open this item automatically. Copy it into your browser or file manager:\n\n{target}",
"compression_slew": "Compression slew (bar/s)",
"decompression_slew": "Decompression slew (bar/s)",
"indent_wait": "Hold at target",
"pause_change": "Pause and change",
"resume": "Resume",
"indenting_paused": "INDENTING: PAUSED at step {index}/{total}; edit values and Resume",
"green_limit_hour": "Green / yellow boundary",
"yellow_limit_hour": "Yellow / orange boundary",
"orange_limit_hour": "Orange / red boundary",
"restore_defaults": "Restore defaults",
"leak_bands": "Green < {green:g}; yellow {green:g}–{yellow:g}; orange {yellow:g}–{orange:g}; red > {orange:g} bar/h.",
},
"it": {
"window_title": "Controllore di pressione PACE {version}",
Expand Down Expand Up @@ -238,7 +249,7 @@
"danger_title": "Area pericolosa",
"danger_warning": "AREA PERICOLOSA: modifica questi parametri solo se sai esattamente cosa stai facendo. Valori errati possono causare un comportamento pericoloso della pressione. Sbloccare il pannello?",
"indenting_title": "Ciclo automatico di indenting",
"indenting_description": "Raggiunge il target alla slew selezionata, mantiene CONTROL per 120 s, torna a 0 bar con la stessa slew e termina in MEASURE.",
"indenting_description": "Comprime alla slew scelta, attende al set point e decomprime fino a 0 bar. La pausa mantiene la pressione corrente: modifica i valori e premi Riprendi per continuare dalla stessa fase. Il tempo in pausa non conta come attesa.",
"start_indenting": "AVVIA INDENTING",
"routine_description": "Ogni riga raggiunge il target, attende In-limits e mantiene la pressione per il tempo indicato.",
"add_step": "Aggiungi passo",
Expand Down Expand Up @@ -293,6 +304,17 @@
"project_updates": "Progetto e aggiornamenti",
"open_link_title": "Apri collegamento",
"open_link_error": "PACE Controller non è riuscito ad aprire automaticamente questo elemento. Copialo nel browser o nel file manager:\n\n{target}",
"compression_slew": "Slew compressione (bar/s)",
"decompression_slew": "Slew decompressione (bar/s)",
"indent_wait": "Attesa al set point",
"pause_change": "Pausa e cambia",
"resume": "Riprendi",
"indenting_paused": "INDENTING: IN PAUSA al passo {index}/{total}; modifica i valori e Riprendi",
"green_limit_hour": "Confine verde / giallo",
"yellow_limit_hour": "Confine giallo / arancio",
"orange_limit_hour": "Confine arancio / rosso",
"restore_defaults": "Ripristina predefiniti",
"leak_bands": "Verde < {green:g}; giallo {green:g}–{yellow:g}; arancio {yellow:g}–{orange:g}; rosso > {orange:g} bar/h.",
},
}

Expand Down
31 changes: 15 additions & 16 deletions cross_platform/src/pace_controller/leak.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dataclasses import dataclass
from itertools import islice
from statistics import median
from math import fsum, inf, isfinite
from math import fsum, inf, isclose, isfinite

from .models import LeakThresholds

Expand Down Expand Up @@ -95,21 +95,20 @@ def add(self, timestamp: float, value: float, enabled: bool) -> LeakAssessment:
rate = max(0.0, -slope)

t = self.thresholds
green_rate = t.reference_drop_bar / t.green_minutes
yellow_rate = t.reference_drop_bar / t.yellow_minutes
orange_rate = t.reference_drop_bar / t.orange_minutes

if rate > orange_rate:
return LeakAssessment("significant_leak", rate, elapsed_minutes)
if rate > yellow_rate:
return LeakAssessment("pressure_leak", rate, elapsed_minutes)
if rate > green_rate:
return LeakAssessment("slight_leak", rate, elapsed_minutes)
# 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)
rate_hour = rate * 60.0
# Normalize numerical roundoff at the inclusive colour boundaries.
for boundary in (t.green_max_bar_hour, t.yellow_max_bar_hour, t.orange_max_bar_hour):
if isclose(rate_hour, boundary, rel_tol=1e-9, abs_tol=1e-12):
rate_hour = boundary
if rate_hour > t.orange_max_bar_hour:
level = "significant_leak"
elif rate_hour >= t.yellow_max_bar_hour:
level = "pressure_leak"
elif rate_hour >= t.green_max_bar_hour:
level = "slight_leak"
else:
level = "no_leak"
return LeakAssessment(level, rate, elapsed_minutes)

def _is_pressure_step(self, timestamp: float, value: float) -> bool:
"""Detect a discontinuity relative to recent drift and measurement noise."""
Expand Down
39 changes: 28 additions & 11 deletions cross_platform/src/pace_controller/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from dataclasses import asdict, dataclass, field
from enum import Enum
from math import nan
from math import isfinite, nan
from typing import Any


Expand Down Expand Up @@ -84,18 +84,35 @@ class DeviceCapabilities:

@dataclass(slots=True)
class LeakThresholds:
reference_drop_bar: float = 0.005
green_minutes: float = 10.0
yellow_minutes: float = 5.0
orange_minutes: float = 1.0
green_max_bar_hour: float = 0.1
yellow_max_bar_hour: float = 0.3
orange_max_bar_hour: float = 0.6

def validate(self) -> None:
if self.reference_drop_bar <= 0:
raise ValueError("Reference pressure drop must be positive.")
if not self.green_minutes > self.yellow_minutes > self.orange_minutes > 0:
raise ValueError(
"Leak times must satisfy green > yellow > orange > 0."
)
values = (self.green_max_bar_hour, self.yellow_max_bar_hour, self.orange_max_bar_hour)
if not all(isfinite(value) for value in values) or not 0 < values[0] < values[1] < values[2]:
raise ValueError("Thresholds in bar/h must satisfy 0 < green < yellow < orange.")

@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "LeakThresholds":
if any(key in raw for key in cls.__dataclass_fields__):
result = cls(**{key: float(value) for key, value in raw.items() if key in cls.__dataclass_fields__})
elif "reference_drop_bar" in raw:
old = tuple(float(raw.get(key, default)) for key, default in (
("reference_drop_bar", 0.005), ("green_minutes", 10),
("yellow_minutes", 5), ("orange_minutes", 1),
))
if old == (0.005, 10.0, 5.0, 1.0):
result = cls()
else:
reference, green, yellow, orange = old
if not 0 < orange < yellow < green or not isfinite(reference) or reference <= 0:
raise ValueError("Invalid legacy leak thresholds")
result = cls(*(reference * 60.0 / minutes for minutes in (green, yellow, orange)))
else:
result = cls()
result.validate()
return result


@dataclass(slots=True)
Expand Down
71 changes: 62 additions & 9 deletions cross_platform/src/pace_controller/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,19 @@ def start_manual(
self._commands.put(("sequence", ("MANUAL", [step], parameters, keep_control)))

def start_indenting(
self, target_bar: float, slew_bar_s: float, parameters: ControlParameters
self, target_bar: float, compression_slew: float, parameters: ControlParameters,
decompression_slew: float | None = None, dwell_seconds: float = 120.0,
) -> None:
steps = [
PressureStep(target_bar, slew_bar_s, 120.0),
PressureStep(0.0, slew_bar_s, 0.0),
]
decompression_slew = compression_slew if decompression_slew is None else decompression_slew
steps = [PressureStep(target_bar, compression_slew, dwell_seconds), PressureStep(0.0, decompression_slew, 0.0)]
self._commands.put(("sequence", ("INDENTING", steps, parameters, False)))

def pause_indenting(self) -> None:
self._commands.put(("pause_indenting", ()))

def resume_indenting(self, target_bar: float, compression_slew: float, decompression_slew: float, dwell_seconds: float) -> None:
self._commands.put(("resume_indenting", (target_bar, compression_slew, decompression_slew, dwell_seconds)))

def start_routine(
self,
steps: list[PressureStep],
Expand Down Expand Up @@ -171,7 +176,7 @@ def _run(self) -> None:
except Exception as exc: # final containment for the worker thread
self._emit_alarm("device_error", error=str(exc))
self._write_log(f"Command failed: {exc}")
self.busy_changed.emit(False)
self.busy_changed.emit(self._automation is not None)

now = time.monotonic()
if self._connected and now >= next_poll:
Expand All @@ -194,6 +199,10 @@ def _dispatch(self, command: str, arguments: tuple[Any, ...]) -> None:
self.automation_changed.emit({"key": "automation_idle"})
elif command == "vent":
self._start_vent(*arguments)
elif command == "pause_indenting":
self._pause_indenting()
elif command == "resume_indenting":
self._resume_indenting(*arguments)
elif command == "load_parameters":
self._load_parameters()

Expand Down Expand Up @@ -346,7 +355,7 @@ def _validate_steps(self, steps: list[PressureStep]) -> None:
raise ValueError("Pressure step contains a non-finite value")
if step.slew_bar_s <= 0 and not step.maximum_rate:
raise ValueError("Slew rate must be positive")
if step.dwell_seconds < 0:
if not math.isfinite(step.dwell_seconds) or step.dwell_seconds < 0:
raise ValueError("Dwell time cannot be negative")
low = self._capabilities.range_min_bar
high = self._capabilities.range_max_bar
Expand All @@ -363,6 +372,47 @@ def _validate_steps(self, steps: list[PressureStep]) -> None:
f"Target {step.target_bar} bar would leave less than {self.minimum_source_margin_bar:.1f} bar positive-source margin"
)

def _pause_indenting(self) -> None:
self._require_connected()
cycle = self._automation
if cycle is None or cycle["mode"] != "INDENTING" or cycle["state"] == "paused":
raise ValueError("No running indenting cycle to pause")
now = time.monotonic()
current = self._telemetry.current_pressure_bar
if not math.isfinite(current):
raise ValueError("Cannot pause without valid pressure telemetry")
hold = PressureStep(current, cycle["steps"][cycle["index"]].slew_bar_s, 0)
self._validate_steps([hold])
try:
self._apply_pressure_step(hold, cycle["parameters"])
except Exception:
self._cancel_automation()
self._set_measure()
raise
if cycle["state"] == "dwelling":
cycle["dwell_elapsed"] = cycle.get("dwell_elapsed", 0.0) + max(0.0, now - cycle["dwell_started"])
cycle["state"] = "paused"
self.automation_changed.emit({"key": "indenting_paused", "mode": "INDENTING", "index": cycle["index"] + 1, "total": len(cycle["steps"])})

def _resume_indenting(self, target_bar: float, compression_slew: float, decompression_slew: float, dwell_seconds: float) -> None:
self._require_connected()
cycle = self._automation
if cycle is None or cycle["mode"] != "INDENTING" or cycle["state"] != "paused":
raise ValueError("No paused indenting cycle to resume")
steps = [PressureStep(target_bar, compression_slew, dwell_seconds), PressureStep(0.0, decompression_slew, 0.0)]
self._validate_parameters(cycle["parameters"])
self._validate_steps(steps)
margin = self._telemetry.positive_source_bar - self._telemetry.current_pressure_bar
if not math.isfinite(margin) or margin < self.minimum_source_margin_bar or (self._supply_interlock_latched and margin < self.source_margin_rearm_bar):
raise ValueError("Insufficient source margin to resume")
cycle["steps"] = steps
try:
self._start_current_step()
except Exception:
self._cancel_automation()
self._set_measure()
raise

def _start_current_step(self) -> None:
if self._automation is None:
return
Expand Down Expand Up @@ -427,9 +477,11 @@ def _process_automation(self, now: float) -> None:
step = steps[index]
if state == "waiting":
if self._telemetry.in_limits or (step.target_bar == 0.0 and self._telemetry.zero_target_stable):
if step.dwell_seconds > 0:
remaining = max(0.0, step.dwell_seconds - self._automation.get("dwell_elapsed", 0.0))
if remaining > 0:
self._automation["state"] = "dwelling"
self._automation["dwell_end"] = now + step.dwell_seconds
self._automation["dwell_started"] = now
self._automation["dwell_end"] = now + remaining
else:
self._complete_step()
elif now > float(self._automation["deadline"]):
Expand Down Expand Up @@ -458,6 +510,7 @@ def _complete_step(self) -> None:
if self._automation is None:
return
self._automation["index"] = int(self._automation["index"]) + 1
self._automation["dwell_elapsed"] = 0.0
steps: list[PressureStep] = self._automation["steps"]
if int(self._automation["index"]) < len(steps):
self._start_current_step()
Expand Down
6 changes: 1 addition & 5 deletions cross_platform/src/pace_controller/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,7 @@ def load_settings() -> AppSettings:
raw = json.loads(path.read_text(encoding="utf-8"))
connection = _coerce_connection(dict(raw.get("connection", {})))
leak_raw = dict(raw.get("leak_thresholds", {}))
leak_allowed = LeakThresholds.__dataclass_fields__
leak = LeakThresholds(
**{key: value for key, value in leak_raw.items() if key in leak_allowed}
)
leak.validate()
leak = LeakThresholds.from_dict(leak_raw)
return AppSettings(
language=str(raw.get("language", "en")),
connection=connection,
Expand Down
Loading