UI updates

This commit is contained in:
Ayzen
2026-04-01 20:21:16 +03:00
parent 4abc95c372
commit 669205d8f8
43 changed files with 2055 additions and 973 deletions
+9 -1
View File
@@ -6,7 +6,12 @@ import json
from pathlib import Path
from python_app.models.run_config_model import RunConfigModel, parse_combos_from_text
from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, PREPROCESS_ASSET_SPECS, preprocess_asset_model
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_KEYS,
PREPROCESS_ASSET_SPECS,
preprocess_asset_model,
runtime_preprocess_asset_keys,
)
from python_app.storage.npz_store import NpzStore
@@ -26,6 +31,9 @@ class ConfigWriter:
) -> None:
"""Export selected preprocess sets into runtime bundles and update config paths."""
for key in PREPROCESS_ASSET_KEYS:
preprocess_asset_model(config, key).bundle_path = ""
for key in runtime_preprocess_asset_keys(config):
spec = PREPROCESS_ASSET_SPECS[key]
asset = preprocess_asset_model(config, key)
bundle_path = self._runtime_dir / spec.runtime_filename
@@ -0,0 +1,52 @@
"""Persistent session-state helpers for GUI-only runtime preferences."""
from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
@dataclass(slots=True)
class GuiSessionState:
"""Small persisted GUI session state."""
last_profile_path: str = ""
class GuiSessionStateStore:
"""Atomic JSON store for GUI session-state file."""
def __init__(self, path: Path) -> None:
"""Create store targeting `path`."""
self._path = path
self._path.parent.mkdir(parents=True, exist_ok=True)
@property
def path(self) -> Path:
"""Return backing session-state file path."""
return self._path
def load(self) -> GuiSessionState:
"""Load session-state from disk or return empty defaults when missing."""
if not self._path.exists():
return GuiSessionState()
payload = json.loads(self._path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"GUI session-state root must be JSON object: {self._path}")
raw_path = payload.get("last_profile_path", "")
if not isinstance(raw_path, str):
raise ValueError("GUI session-state `last_profile_path` must be a string")
return GuiSessionState(last_profile_path=raw_path)
def write(self, state: GuiSessionState) -> Path:
"""Atomically write session-state JSON file."""
temp_path = self._path.with_suffix(self._path.suffix + ".tmp")
temp_path.write_text(
json.dumps({"last_profile_path": state.last_profile_path}, indent=2),
encoding="utf-8",
)
temp_path.replace(self._path)
return self._path
@@ -12,8 +12,6 @@ class ProcessingLiveConfig:
"""Runtime-adjustable processing parameters shared with data processor."""
processor_mode: str = "pass_through"
gain_db: float = 0.0
phase_deg: float = 0.0
pass_through_channel: str = "s21"
pass_through_fixed_y_enabled: bool = False
pass_through_y_min_db: float = -100.0
@@ -52,8 +50,6 @@ class ProcessingLiveConfig:
"""Convert live config to JSON-serializable dictionary."""
return {
"processor_mode": str(self.processor_mode),
"gain_db": float(self.gain_db),
"phase_deg": float(self.phase_deg),
"pass_through_channel": str(self.pass_through_channel),
"pass_through_fixed_y_enabled": bool(self.pass_through_fixed_y_enabled),
"pass_through_y_min_db": float(self.pass_through_y_min_db),
@@ -66,6 +66,9 @@ PREPROCESS_ASSET_SPECS = {
PREPROCESS_ASSET_KEYS = tuple(PREPROCESS_ASSET_SPECS.keys())
S21_PREPROCESS_ASSET_KEYS = ("s21_calibration", "s21_reference")
S11_PREPROCESS_ASSET_KEYS = ("s11_open", "s11_short", "s11_load", "s11_reference")
S11_PREPROCESS_CALIBRATION_KEYS = ("s11_open", "s11_short", "s11_load")
VISIBLE_PREPROCESS_ASSET_KEYS = S21_PREPROCESS_ASSET_KEYS
REQUIRED_PREPROCESS_ASSET_KEYS = S21_PREPROCESS_ASSET_KEYS
def preprocess_asset_model(config: RunConfigModel, key: str) -> PreprocessAssetModel:
@@ -93,3 +96,25 @@ def preprocess_asset_display_name(key: str) -> str:
def preprocess_asset_channel(key: str) -> str:
"""Return associated trace channel for preprocess asset."""
return PREPROCESS_ASSET_SPECS[key].channel
def preprocess_asset_set_name(config: RunConfigModel, key: str) -> str:
"""Return normalized selected set name for one preprocess asset."""
return str(preprocess_asset_model(config, key).set_name).strip()
def runtime_preprocess_asset_keys(config: RunConfigModel) -> tuple[str, ...]:
"""Return preprocess assets that should be exported and validated for runtime."""
enabled_keys = list(REQUIRED_PREPROCESS_ASSET_KEYS)
has_full_s11_calibration = all(
preprocess_asset_set_name(config, key)
for key in S11_PREPROCESS_CALIBRATION_KEYS
)
if not has_full_s11_calibration:
return tuple(enabled_keys)
enabled_keys.extend(S11_PREPROCESS_CALIBRATION_KEYS)
if preprocess_asset_set_name(config, "s11_reference"):
enabled_keys.append("s11_reference")
return tuple(enabled_keys)
+95 -27
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import shlex
import subprocess
import time
from typing import Iterable
@@ -16,9 +17,50 @@ class ManagedProcess:
name: str
command: list[str]
allow_clean_exit: bool
handle: subprocess.Popen[str]
@dataclass(slots=True)
class ProcessExitReport:
"""Structured report for one exited managed process."""
name: str
command: list[str]
working_directory: Path
return_code: int
stdout: str
stderr: str
expected_clean_exit: bool
@property
def level(self) -> str:
"""Return log level appropriate for this exit report."""
return "INFO" if self.expected_clean_exit else "ERROR"
def format(self) -> str:
"""Render human-readable multiline exit report."""
if self.expected_clean_exit:
headline = f"Process `{self.name}` completed normally with code {self.return_code}."
elif self.return_code == 0:
headline = f"Process `{self.name}` exited unexpectedly with code 0."
else:
headline = f"Process `{self.name}` exited with code {self.return_code}."
lines = [
headline,
f"Command: {shlex.join(self.command)}",
f"Working directory: {self.working_directory}",
]
if self.stderr:
lines.append(f"stderr:\n{self.stderr}")
if self.stdout:
lines.append(f"stdout:\n{self.stdout}")
if not self.stderr and not self.stdout:
lines.append("stdout/stderr: none")
return "\n".join(lines)
class ProcessSupervisor:
"""Start, monitor, and stop pipeline subprocesses."""
@@ -36,7 +78,7 @@ class ProcessSupervisor:
"""Return whether data processor process is alive."""
return self._is_alive("data_processor")
def start(self, config_path: Path) -> None:
def start(self, config_path: Path, *, allow_clean_orchestrator_exit: bool = False) -> None:
"""Start required pipeline binaries and wait until they are ready."""
if self.is_running():
raise RuntimeError("Acquisition processes are already running")
@@ -59,16 +101,22 @@ class ProcessSupervisor:
],
}
processor_was_running = self.is_processor_running()
required_processes: list[str] = ["data_preprocessor", "sweep_orchestrator"]
required_processes: list[str] = ["data_preprocessor"]
if not allow_clean_orchestrator_exit:
required_processes.append("sweep_orchestrator")
if not processor_was_running:
required_processes.insert(0, "data_processor")
try:
if not processor_was_running:
self._spawn("data_processor", command_specs["data_processor"])
self._spawn("data_processor", command_specs["data_processor"], allow_clean_exit=False)
self._spawn("data_preprocessor", command_specs["data_preprocessor"])
self._spawn("sweep_orchestrator", command_specs["sweep_orchestrator"])
self._spawn("data_preprocessor", command_specs["data_preprocessor"], allow_clean_exit=False)
self._spawn(
"sweep_orchestrator",
command_specs["sweep_orchestrator"],
allow_clean_exit=allow_clean_orchestrator_exit,
)
self._wait_until_ready(required_processes)
except Exception:
if processor_was_running:
@@ -93,20 +141,32 @@ class ProcessSupervisor:
"""Stop all managed processes."""
self._stop_processes(["sweep_orchestrator", "data_preprocessor", "data_processor"])
def _spawn(self, name: str, command: list[str]) -> None:
def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None:
"""Spawn one process unless same process is already alive."""
existing = self._processes.get(name)
if existing is not None and existing.handle.poll() is None:
return
handle = subprocess.Popen(
command,
cwd=self._project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
try:
handle = subprocess.Popen(
command,
cwd=self._project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except OSError as exc:
command_text = shlex.join(command)
raise RuntimeError(
f"Failed to spawn {name} with command `{command_text}` from `{self._project_root}`: "
f"{type(exc).__name__}: {exc}"
) from exc
self._processes[name] = ManagedProcess(
name=name,
command=command,
allow_clean_exit=allow_clean_exit,
handle=handle,
)
self._processes[name] = ManagedProcess(name=name, command=command, handle=handle)
def _stop_processes(self, names: Iterable[str]) -> None:
"""Gracefully terminate processes, then force-kill on timeout."""
@@ -148,9 +208,9 @@ class ProcessSupervisor:
return False
return process.handle.poll() is None
def collect_crash_reports(self) -> list[str]:
"""Collect stderr/stdout reports from processes that have exited."""
reports: list[str] = []
def collect_exit_reports(self) -> list[ProcessExitReport]:
"""Collect reports for managed processes that have exited."""
reports: list[ProcessExitReport] = []
exited_names: list[str] = []
for name, process in self._processes.items():
@@ -165,13 +225,17 @@ class ProcessSupervisor:
if process.handle.stderr is not None:
stderr = process.handle.stderr.read().strip()
details = stderr
if stdout and stderr:
details = f"{stderr}\nstdout:\n{stdout}"
elif stdout:
details = f"stdout:\n{stdout}"
reports.append(f"{process.name} exited with code {return_code}: {details}")
reports.append(
ProcessExitReport(
name=process.name,
command=list(process.command),
working_directory=self._project_root,
return_code=int(return_code),
stdout=stdout,
stderr=stderr,
expected_clean_exit=bool(process.allow_clean_exit and int(return_code) == 0),
)
)
exited_names.append(name)
for name in exited_names:
@@ -183,15 +247,19 @@ class ProcessSupervisor:
deadline = time.monotonic() + self._readiness_timeout_s
while time.monotonic() < deadline:
crashed = self.collect_crash_reports()
if crashed:
raise RuntimeError("; ".join(crashed))
exit_reports = self.collect_exit_reports()
unexpected_reports = [report for report in exit_reports if not report.expected_clean_exit]
if unexpected_reports:
raise RuntimeError("; ".join(report.format() for report in unexpected_reports))
if all(self._is_alive(process_name) for process_name in required_processes):
return
time.sleep(0.05)
names = ", ".join(required_processes)
raise RuntimeError(f"Timed out waiting for processes to start: {names}")
raise RuntimeError(
f"Timed out waiting for processes to start: {names}. "
f"Alive processes: {self.pids() or 'none'}"
)
def pids(self) -> dict[str, int]:
"""Return PID mapping for currently alive managed processes."""