From 67cf4de4b318c1ca31cdad9975c1190ca53a3ff5 Mon Sep 17 00:00:00 2001 From: Sebastiano Romi Date: Mon, 5 Oct 2026 14:36:27 +0200 Subject: [PATCH] Make leak settings hourly and add editable paused indenting cycles --- cross_platform/README.md | 2 +- cross_platform/docs/PACE_Controller_Manual.md | 22 +-- cross_platform/src/pace_controller/i18n.py | 26 +++- cross_platform/src/pace_controller/leak.py | 31 ++-- cross_platform/src/pace_controller/models.py | 39 +++-- cross_platform/src/pace_controller/service.py | 71 +++++++-- cross_platform/src/pace_controller/storage.py | 6 +- cross_platform/src/pace_controller/ui.py | 136 ++++++++++++---- cross_platform/tests/test_core.py | 10 +- .../tests/test_indenting_settings.py | 146 ++++++++++++++++++ 10 files changed, 394 insertions(+), 95 deletions(-) create mode 100644 cross_platform/tests/test_indenting_settings.py diff --git a/cross_platform/README.md b/cross_platform/README.md index 1712fa5..4b76d62 100644 --- a/cross_platform/README.md +++ b/cross_platform/README.md @@ -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. diff --git a/cross_platform/docs/PACE_Controller_Manual.md b/cross_platform/docs/PACE_Controller_Manual.md index 3b065c8..5319177 100644 --- a/cross_platform/docs/PACE_Controller_Manual.md +++ b/cross_platform/docs/PACE_Controller_Manual.md @@ -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 @@ -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. diff --git a/cross_platform/src/pace_controller/i18n.py b/cross_platform/src/pace_controller/i18n.py index 3bdb6f6..523a807 100644 --- a/cross_platform/src/pace_controller/i18n.py +++ b/cross_platform/src/pace_controller/i18n.py @@ -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", @@ -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}", @@ -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", @@ -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.", }, } diff --git a/cross_platform/src/pace_controller/leak.py b/cross_platform/src/pace_controller/leak.py index 764b9a2..0baff5a 100644 --- a/cross_platform/src/pace_controller/leak.py +++ b/cross_platform/src/pace_controller/leak.py @@ -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 @@ -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.""" diff --git a/cross_platform/src/pace_controller/models.py b/cross_platform/src/pace_controller/models.py index 5b47fe8..0cf72e1 100644 --- a/cross_platform/src/pace_controller/models.py +++ b/cross_platform/src/pace_controller/models.py @@ -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 @@ -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) diff --git a/cross_platform/src/pace_controller/service.py b/cross_platform/src/pace_controller/service.py index 9737f8d..e26d6d7 100644 --- a/cross_platform/src/pace_controller/service.py +++ b/cross_platform/src/pace_controller/service.py @@ -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], @@ -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: @@ -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() @@ -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 @@ -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 @@ -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"]): @@ -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() diff --git a/cross_platform/src/pace_controller/storage.py b/cross_platform/src/pace_controller/storage.py index 6396c2d..1704d0f 100644 --- a/cross_platform/src/pace_controller/storage.py +++ b/cross_platform/src/pace_controller/storage.py @@ -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, diff --git a/cross_platform/src/pace_controller/ui.py b/cross_platform/src/pace_controller/ui.py index 05482a6..598b8c3 100644 --- a/cross_platform/src/pace_controller/ui.py +++ b/cross_platform/src/pace_controller/ui.py @@ -17,6 +17,7 @@ QCheckBox, QComboBox, QDialog, + QDoubleSpinBox, QDialogButtonBox, QFileDialog, QFormLayout, @@ -27,10 +28,13 @@ QHeaderView, QLabel, QLineEdit, + QLayout, QMainWindow, QMessageBox, QPushButton, + QSizePolicy, QSpinBox, + QScrollArea, QStackedWidget, QStatusBar, QTabWidget, @@ -66,8 +70,8 @@ QLabel, QCheckBox { background: transparent; } QGroupBox { background: white; border: 1px solid #cbd3db; border-radius: 6px; margin-top: 12px; padding-top: 8px; font-weight: 600; } QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 5px; } -QLineEdit, QComboBox, QSpinBox { background: white; border: 1px solid #aeb9c4; border-radius: 3px; padding: 5px; min-height: 22px; } -QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled { background: #e8ebee; color: #68727c; } +QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox { background: white; border: 1px solid #aeb9c4; border-radius: 3px; padding: 5px; min-height: 22px; } +QLineEdit:disabled, QComboBox:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled { background: #e8ebee; color: #68727c; } QPushButton, QToolButton { background: #e7edf3; border: 1px solid #9eabb8; border-radius: 4px; padding: 7px 12px; } QPushButton:hover, QToolButton:hover { background: #d8e5f1; } QPushButton:disabled { color: #8b949d; background: #edf0f2; } @@ -239,6 +243,8 @@ def __init__( self.t = Translator(settings.language) self.connected = False self.busy = False + self.indent_running = False + self.indent_paused = False self.parameters_unlocked = False self.current_telemetry = Telemetry() self.capabilities = DeviceCapabilities() @@ -454,7 +460,7 @@ def _build_ui(self) -> None: self.tabs = QTabWidget() self.tabs.setDocumentMode(False) - self.tabs.setMinimumHeight(350) + self.tabs.setMinimumHeight(220) self.manual_tab = self._build_manual_tab() self.indenting_tab = self._build_indenting_tab() self.routine_tab = self._build_routine_tab() @@ -465,6 +471,7 @@ def _build_ui(self) -> None: self.tabs.addTab(self.routine_tab, "") self.tabs.addTab(self.settings_tab, "") self.tabs.addTab(self.log_tab, "") + self.tabs.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Ignored) root.addWidget(self.tabs, 1) self.status = QStatusBar() @@ -584,14 +591,25 @@ def _build_indenting_tab(self) -> QWidget: self._localized.append((self.indent_description, "indenting_description")) form_widget = QWidget() form = QFormLayout(form_widget) + form.setSizeConstraint(QLayout.SetMinimumSize) self.indent_target_edit = QLineEdit("1") self.indent_slew_edit = QLineEdit("0.1") + self.indent_decompression_edit = QLineEdit("0.1") + self.indent_dwell_spin = QDoubleSpinBox() + self.indent_dwell_spin.setRange(0, 86400) + self.indent_dwell_spin.setDecimals(1) + self.indent_dwell_spin.setSuffix(" s") + self.indent_dwell_spin.setValue(120) self.indent_target_label = QLabel() self._localized.append((self.indent_target_label, "target_bar")) self.indent_slew_label = QLabel() - self._localized.append((self.indent_slew_label, "slew_bar_s")) + self._localized.append((self.indent_slew_label, "compression_slew")) form.addRow(self.indent_target_label, self.indent_target_edit) form.addRow(self.indent_slew_label, self.indent_slew_edit) + for key, widget in (("decompression_slew", self.indent_decompression_edit), ("indent_wait", self.indent_dwell_spin)): + label = QLabel() + self._localized.append((label, key)) + form.addRow(label, widget) buttons = QHBoxLayout() self.start_indent_button = QPushButton() self.start_indent_button.setObjectName("primary") @@ -599,7 +617,12 @@ def _build_indenting_tab(self) -> QWidget: self.stop_indent_button = QPushButton() self.stop_indent_button.setObjectName("danger") self._localized.append((self.stop_indent_button, "measure_stop")) + self.pause_indent_button = QPushButton() + self.resume_indent_button = QPushButton() + self._localized.extend([(self.pause_indent_button, "pause_change"), (self.resume_indent_button, "resume")]) buttons.addWidget(self.start_indent_button) + buttons.addWidget(self.pause_indent_button) + buttons.addWidget(self.resume_indent_button) buttons.addWidget(self.stop_indent_button) buttons.addStretch(1) layout.addWidget(self.indent_title) @@ -607,7 +630,12 @@ def _build_indenting_tab(self) -> QWidget: layout.addWidget(form_widget) layout.addLayout(buttons) layout.addStretch(1) - return tab + layout.setSizeConstraint(QLayout.SetMinimumSize) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + scroll.setWidget(tab) + return scroll def _build_routine_tab(self) -> QWidget: tab = QWidget() @@ -679,21 +707,28 @@ def _build_settings_tab(self) -> QWidget: self.leak_settings_group = QGroupBox() self._localized.append((self.leak_settings_group, "leak_settings")) form = QFormLayout(self.leak_settings_group) - self.reference_drop_edit = QLineEdit("0.005") - self.green_time_edit = QLineEdit("10") - self.yellow_time_edit = QLineEdit("5") - self.orange_time_edit = QLineEdit("1") - labels: list[tuple[QLabel, str, QLineEdit]] = [] - for key, edit in ( - ("reference_drop", self.reference_drop_edit), - ("green_time", self.green_time_edit), - ("yellow_time", self.yellow_time_edit), - ("orange_time", self.orange_time_edit), - ): + form.setSizeConstraint(QLayout.SetMinimumSize) + self.leak_rate_spins = [] + for key, value in (("green_limit_hour", 0.1), ("yellow_limit_hour", 0.3), ("orange_limit_hour", 0.6)): + edit = QDoubleSpinBox() + edit.setDecimals(3) + edit.setRange(0.001, 10000) + edit.setSingleStep(0.05) + edit.setSuffix(" bar/h") + edit.setValue(value) + self.leak_rate_spins.append(edit) label = QLabel() self._localized.append((label, key)) - labels.append((label, key, edit)) form.addRow(label, edit) + self.leak_bands_label = QLabel() + self.leak_bands_label.setWordWrap(True) + form.addRow(self.leak_bands_label) + self.leak_defaults_button = QPushButton() + self._localized.append((self.leak_defaults_button, "restore_defaults")) + self.leak_defaults_button.clicked.connect(self.restore_leak_defaults) + form.addRow(self.leak_defaults_button) + for edit in self.leak_rate_spins: + edit.valueChanged.connect(self.update_leak_bands) self.save_settings_button = QPushButton() self.save_settings_button.setObjectName("primary") self._localized.append((self.save_settings_button, "save_settings")) @@ -716,7 +751,12 @@ def _build_settings_tab(self) -> QWidget: self._localized.append((self.safety_info_label, "safety_info")) layout.addWidget(self.safety_info_label) layout.addStretch(1) - return tab + layout.setSizeConstraint(QLayout.SetMinimumSize) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + scroll.setWidget(tab) + return scroll def _build_log_tab(self) -> QWidget: tab = QWidget() @@ -739,6 +779,8 @@ def _connect_signals(self) -> None: self.measure_button.clicked.connect(self.service.stop_and_measure) self.start_indent_button.clicked.connect(self.start_indenting) self.stop_indent_button.clicked.connect(self.service.stop_and_measure) + self.pause_indent_button.clicked.connect(self.service.pause_indenting) + self.resume_indent_button.clicked.connect(self.resume_indenting) self.add_step_button.clicked.connect(self.add_routine_step) self.remove_step_button.clicked.connect(self.remove_routine_step) self.save_routine_button.clicked.connect(self.save_routine) @@ -797,10 +839,8 @@ def _load_settings_into_ui(self) -> None: if index >= 0: self.serial_port_combo.setCurrentIndex(index) thresholds = self.settings.leak_thresholds - self.reference_drop_edit.setText(f"{thresholds.reference_drop_bar:g}") - self.green_time_edit.setText(f"{thresholds.green_minutes:g}") - self.yellow_time_edit.setText(f"{thresholds.yellow_minutes:g}") - self.orange_time_edit.setText(f"{thresholds.orange_minutes:g}") + for edit, value in zip(self.leak_rate_spins, (thresholds.green_max_bar_hour, thresholds.yellow_max_bar_hour, thresholds.orange_max_bar_hour)): + edit.setValue(value) def apply_language(self) -> None: self.setWindowTitle(self.t("window_title", version=__version__)) @@ -837,6 +877,7 @@ def apply_language(self) -> None: self.automation_status.setText(self.t("automation_idle") if not self.busy else self.automation_status.text()) self._set_parameter_lock_ui() self._refresh_leak_texts() + self.update_leak_bands() def _language_changed(self, index: int) -> None: self.settings.language = "it" if index == 1 else "en" @@ -928,6 +969,9 @@ def set_connected(self, connected: bool) -> None: self._set_parameter_lock_ui() self._refresh_long_term_leaks() + if not connected: + self.indent_running = self.indent_paused = False + self._update_indent_controls() def set_busy(self, busy: bool) -> None: self.busy = busy @@ -945,6 +989,9 @@ def set_busy(self, busy: bool) -> None: self.stop_indent_button.setEnabled(self.connected) self.stop_routine_button.setEnabled(self.connected) self.connect_button.setEnabled(not self.connected and not busy) + if not busy: + self.indent_running = self.indent_paused = False + self._update_indent_controls() def toggle_parameter_lock(self) -> None: if self.parameters_unlocked: @@ -998,17 +1045,36 @@ def apply_manual_target(self) -> None: except ValueError as exc: self.show_error(str(exc)) + def indent_steps(self) -> list[PressureStep]: + return [ + PressureStep(self.number(self.indent_target_edit.text()), self.number(self.indent_slew_edit.text()), self.indent_dwell_spin.value()), + PressureStep(0, self.number(self.indent_decompression_edit.text()), 0), + ] + def start_indenting(self) -> None: try: - target = self.number(self.indent_target_edit.text()) - slew = self.number(self.indent_slew_edit.text()) - steps = [PressureStep(target, slew, 120), PressureStep(0, slew, 0)] + steps = self.indent_steps() + if not self.confirm_steps(steps): + return + self.service.start_indenting(steps[0].target_bar, steps[0].slew_bar_s, self.control_parameters(), steps[1].slew_bar_s, steps[0].dwell_seconds) + except ValueError as exc: + self.show_error(str(exc)) + + def resume_indenting(self) -> None: + try: + steps = self.indent_steps() if not self.confirm_steps(steps): return - self.service.start_indenting(target, slew, self.control_parameters()) + self.service.resume_indenting(steps[0].target_bar, steps[0].slew_bar_s, steps[1].slew_bar_s, steps[0].dwell_seconds) except ValueError as exc: self.show_error(str(exc)) + def _update_indent_controls(self) -> None: + self.pause_indent_button.setEnabled(self.connected and self.indent_running and not self.indent_paused) + self.resume_indent_button.setEnabled(self.connected and self.indent_running and self.indent_paused) + for widget in (self.indent_target_edit, self.indent_slew_edit, self.indent_decompression_edit, self.indent_dwell_spin): + widget.setEnabled(self.connected and (not self.busy or self.indent_paused)) + def routine_steps(self) -> list[PressureStep]: steps: list[PressureStep] = [] for row in range(self.routine_table.rowCount()): @@ -1172,6 +1238,9 @@ def on_automation(self, event: object) -> None: data = dict(event) if isinstance(event, dict) else {"key": "automation_idle"} key = str(data.pop("key", "automation_idle")) self.automation_status.setText(self.t(key, **data)) + self.indent_running = data.get("mode") == "INDENTING" and key in {"moving", "holding", "indenting_paused"} + self.indent_paused = self.indent_running and key == "indenting_paused" + self._update_indent_controls() def on_alarm(self, event: object) -> None: data = dict(event) if isinstance(event, dict) else {} @@ -1231,14 +1300,17 @@ def load_routine(self) -> None: except (ValueError, TypeError, OSError, json.JSONDecodeError) as exc: self.show_error(str(exc)) + def restore_leak_defaults(self) -> None: + for edit, value in zip(self.leak_rate_spins, (0.1, 0.3, 0.6)): + edit.setValue(value) + + def update_leak_bands(self) -> None: + green, yellow, orange = (edit.value() for edit in self.leak_rate_spins) + self.leak_bands_label.setText(self.t("leak_bands", green=green, yellow=yellow, orange=orange)) + def save_leak_settings(self) -> None: try: - thresholds = LeakThresholds( - reference_drop_bar=self.number(self.reference_drop_edit.text()), - green_minutes=self.number(self.green_time_edit.text()), - yellow_minutes=self.number(self.yellow_time_edit.text()), - orange_minutes=self.number(self.orange_time_edit.text()), - ) + thresholds = LeakThresholds(*(edit.value() for edit in self.leak_rate_spins)) thresholds.validate() self.settings.leak_thresholds = thresholds self.sample_monitor.update_thresholds(thresholds) diff --git a/cross_platform/tests/test_core.py b/cross_platform/tests/test_core.py index 746f4ae..dd13967 100644 --- a/cross_platform/tests/test_core.py +++ b/cross_platform/tests/test_core.py @@ -285,9 +285,9 @@ def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str ("elapsed", "drop", "expected"), [ (600.0, 0.001, "no_leak"), - (300.0, 0.004, "slight_leak"), - (180.0, 0.009, "pressure_leak"), - (180.0, 0.024, "significant_leak"), + (300.0, 0.0125, "slight_leak"), + (180.0, 0.0225, "pressure_leak"), + (180.0, 0.045, "significant_leak"), ], ) def test_leak_classification(elapsed: float, drop: float, expected: str) -> None: @@ -306,7 +306,7 @@ def test_leak_monitor_pauses_during_control() -> None: @pytest.mark.parametrize( ("rate", "expected"), - [(0.0008, "slight_leak"), (0.003, "pressure_leak"), (0.008, "significant_leak")], + [(0.0025, "slight_leak"), (0.0075, "pressure_leak"), (0.015, "significant_leak")], ) def test_leak_rate_and_warnings_are_available_immediately(rate: float, expected: str) -> None: monitor = LeakMonitor(LeakThresholds()) @@ -330,7 +330,7 @@ def test_five_minute_average_excludes_old_loss_and_keeps_green_confirmation() -> assert monitor.samples[0][0] == 300.0 monitor.add(601.0, 49.0, False) assert monitor.add(602.0, 49.0, True).level == "assessing" - assert monitor.add(603.0, 49.0, True).level == "assessing" + assert monitor.add(603.0, 49.0, True).level == "no_leak" def test_regression_interpolates_boundary_and_uses_irregular_timestamps() -> None: diff --git a/cross_platform/tests/test_indenting_settings.py b/cross_platform/tests/test_indenting_settings.py new file mode 100644 index 0000000..fbe3aad --- /dev/null +++ b/cross_platform/tests/test_indenting_settings.py @@ -0,0 +1,146 @@ +import os + +import pytest + +from pace_controller.leak import LeakMonitor +from pace_controller.models import ControlParameters, LeakThresholds, PressureStep, Telemetry +from pace_controller.service import PaceService +from pace_controller.storage import load_settings, save_settings +from pace_controller.models import AppSettings + + +@pytest.mark.parametrize("rate,level", [(0.0,"no_leak"),(0.099,"no_leak"),(0.1,"slight_leak"),(0.299,"slight_leak"),(0.3,"pressure_leak"),(0.6,"pressure_leak"),(0.601,"significant_leak")]) +def test_hourly_threshold_boundaries(rate, level): + monitor = LeakMonitor(LeakThresholds()) + monitor.add(0, 50, True) + assert monitor.add(60, 50-rate/60, True).level == level + + +def test_migration_and_settings_roundtrip(monkeypatch, tmp_path): + monkeypatch.setenv("PACE_CONTROLLER_DATA_DIR", str(tmp_path)) + old = dict(reference_drop_bar=0.005, green_minutes=10, yellow_minutes=5, orange_minutes=1) + assert LeakThresholds.from_dict(old) == LeakThresholds(0.1,0.3,0.6) + old["reference_drop_bar"] = 0.01 + assert LeakThresholds.from_dict(old) == LeakThresholds(0.06,0.12,0.6) + settings = AppSettings(leak_thresholds=LeakThresholds(0.2,0.5,1.0)) + save_settings(settings) + assert load_settings().leak_thresholds == settings.leak_thresholds + for limits in [(0.3,0.1,0.6),(0.1,0.3,float("nan")),(0.1,0.1,0.6)]: + with pytest.raises(ValueError): + LeakThresholds(*limits).validate() + + +@pytest.fixture +def cycle(monkeypatch, tmp_path): + monkeypatch.setenv("PACE_CONTROLLER_DATA_DIR", str(tmp_path)) + service = PaceService() + service._connected = True + service._transport = object() + service._telemetry = Telemetry(current_pressure_bar=0.5, positive_source_bar=50) + writes = [] + monkeypatch.setattr(service, "_apply_pressure_step", lambda step, parameters: writes.append(step)) + monkeypatch.setattr(service, "_set_measure", lambda: None) + service._start_sequence("INDENTING", [PressureStep(2,0.1,120),PressureStep(0,0.2,0)], ControlParameters(), False) + return service, writes + + +def test_indenting_public_command_uses_separate_rates_and_wait(monkeypatch, tmp_path): + monkeypatch.setenv("PACE_CONTROLLER_DATA_DIR", str(tmp_path)) + service = PaceService() + service.start_indenting(2,0.1,ControlParameters(),0.3,80) + command, args = service._commands.get_nowait() + assert command == "sequence" + assert args[1][0].slew_bar_s == 0.1 + assert args[1][1].slew_bar_s == 0.3 + assert args[1][0].dwell_seconds == 80 + + +def test_pause_compression_freezes_deadline_and_resumes_same_stage(cycle, monkeypatch): + service, writes = cycle + monkeypatch.setattr("pace_controller.service.time.monotonic", lambda: 10) + service._pause_indenting() + assert writes[-1].target_bar == 0.5 + assert service._automation["state"] == "paused" + service._process_automation(10000) + assert service._automation["state"] == "paused" + service._resume_indenting(3,0.15,0.4,90) + assert service._automation["index"] == 0 + assert service._automation["state"] == "waiting" + assert writes[-1].target_bar == 3 + assert service._automation["steps"][1].slew_bar_s == 0.4 + + +def test_pause_hold_excludes_paused_time_and_preserves_elapsed_wait(cycle, monkeypatch): + service, writes = cycle + service._telemetry.in_limits = True + service._process_automation(20) + assert service._automation["state"] == "dwelling" + monkeypatch.setattr("pace_controller.service.time.monotonic", lambda: 50) + service._pause_indenting() + assert service._automation["dwell_elapsed"] == 30 + monkeypatch.setattr("pace_controller.service.time.monotonic", lambda: 500) + service._resume_indenting(3,0.1,0.2,120) + service._telemetry.in_limits = False + service._process_automation(510) + assert service._automation["state"] == "waiting" + service._telemetry.in_limits = True + service._process_automation(520) + assert service._automation["dwell_end"] == 610 + monkeypatch.setattr("pace_controller.service.time.monotonic", lambda: 540) + service._pause_indenting() + assert service._automation["dwell_elapsed"] == 50 + service._resume_indenting(3,0.1,0.2,80) + service._process_automation(600) + assert service._automation["dwell_end"] == 630 + + +def test_pause_decompression_retains_return_phase_and_invalid_resume_stays_paused(cycle, monkeypatch): + service, writes = cycle + service._automation["index"] = 1 + monkeypatch.setattr("pace_controller.service.time.monotonic", lambda: 10) + service._pause_indenting() + with pytest.raises(ValueError): + service._resume_indenting(3,-0.1,0.4,90) + assert service._automation["state"] == "paused" + service._resume_indenting(3,0.15,0.4,90) + assert service._automation["index"] == 1 + assert writes[-1].target_bar == 0 + assert writes[-1].slew_bar_s == 0.4 + + +def test_resume_rechecks_source_margin_and_keeps_paused(cycle): + service, writes = cycle + service._pause_indenting() + service._telemetry.positive_source_bar = 3.5 + with pytest.raises(ValueError): + service._resume_indenting(3, 0.1, 0.2, 120) + assert service._automation["state"] == "paused" + assert writes[-1].target_bar == 0.5 + + +def test_ui_settings_and_pause_editability(monkeypatch, tmp_path): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + 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"}) + assert [edit.value() for edit in window.leak_rate_spins] == [0.1,0.3,0.6] + assert window.indent_dwell_spin.value() == 120 + window.set_busy(True) + window.on_automation({"key":"moving","mode":"INDENTING","index":1,"total":2,"target":2}) + assert window.pause_indent_button.isEnabled() + assert not window.indent_target_edit.isEnabled() + window.on_automation({"key":"indenting_paused","mode":"INDENTING","index":1,"total":2}) + assert window.resume_indent_button.isEnabled() + assert window.indent_target_edit.isEnabled() + assert window.indent_decompression_edit.isEnabled() + window.set_busy(False) + assert not window.resume_indent_button.isEnabled() + finally: + window.close() + app.processEvents()