refactor(backend): clean up package re-exports, settings model properties, and unused signals
This commit is contained in:
+18
@@ -40,3 +40,21 @@ dist/
|
||||
*.spec
|
||||
*.icns
|
||||
*.ico
|
||||
|
||||
# Runtime scratch data
|
||||
tmp/
|
||||
|
||||
# Superpowers brainstorming visual companion
|
||||
.superpowers/
|
||||
.llm-wiki/
|
||||
.agents/
|
||||
.pi/
|
||||
|
||||
# Planning / personal docs (root level)
|
||||
CLAUDE.md
|
||||
LEARNINGS.md
|
||||
MIGRATION.md
|
||||
AGENTS.md
|
||||
CONVENTIONS.md
|
||||
specs/
|
||||
graphify-out/
|
||||
|
||||
@@ -14,11 +14,13 @@ The `ISenseCloud` application provides a real-time monitor and analysis dashboar
|
||||
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
|
||||
|
||||
### App Showcase
|
||||
|
||||

|
||||
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
|
||||
|
||||
> [!NOTE]
|
||||
> **Take Screenshots for Visual Placeholders:**
|
||||
>
|
||||
> - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here.
|
||||
> - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here.
|
||||
|
||||
@@ -42,12 +44,15 @@ During the refactoring from the flat-layout prototype (`SettingTab` branch) to t
|
||||
From the project root:
|
||||
|
||||
### Option A: Using `uv` (Recommended)
|
||||
|
||||
If you have [uv](https://docs.astral.sh/uv/) installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Option B: Using `pip`
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||
@@ -59,9 +64,11 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
|
||||
## Run
|
||||
|
||||
### Launch PySide6 QML App
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
||||
|
||||
## Development
|
||||
@@ -71,7 +78,7 @@ The project uses `uv` for dependency management and tooling, `Ruff` for linting
|
||||
A `Makefile` is provided to simplify common development and verification tasks:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| --------- | ------------- |
|
||||
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
|
||||
| `make run` | Launch the ISenseCloud application |
|
||||
| `make test` | Run the `pytest` test suite |
|
||||
@@ -83,6 +90,7 @@ A `Makefile` is provided to simplify common development and verification tasks:
|
||||
### Ruff Configuration
|
||||
|
||||
Ruff is configured in `pyproject.toml` to enforce:
|
||||
|
||||
- **E/W**: Pycodestyle errors and warnings
|
||||
- **F**: Pyflakes linter rules
|
||||
- **I**: Import sorting (isort parity)
|
||||
@@ -174,11 +182,13 @@ Window {
|
||||
- If the app does not start, verify the virtual environment is active and dependencies are installed:
|
||||
|
||||
*Using `uv`:*
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
*Using `pip`:*
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||
pip install -r requirements.txt
|
||||
@@ -187,17 +197,20 @@ Window {
|
||||
- If no window appears, ensure you are running in a desktop session with GUI access.
|
||||
|
||||
- **Windows COM Port Connection Issues:**
|
||||
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||
- Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||
|
||||
### Step-by-Step Windows Simulator Connection Guide
|
||||
|
||||
#### Step 1: Create the Virtual Serial Port Bridge
|
||||
|
||||
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
||||
|
||||

|
||||
|
||||
#### Step 2: Configure and Start the Wafer Simulator
|
||||
|
||||
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
||||
|
||||
1. Set the **Serial Port** to **`COM5`**.
|
||||
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
|
||||
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
|
||||
@@ -206,6 +219,7 @@ Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
||||

|
||||
|
||||
#### Step 3: Run the UI and Connect
|
||||
|
||||
Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green).
|
||||
|
||||

|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# ===== Backend Package =====
|
||||
# Backward-compatible re-exports so existing callers keep working
|
||||
# while imports are migrated to the new sub-package paths.
|
||||
|
||||
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
|
||||
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
|
||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
|
||||
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
|
||||
from pygui.backend.data.data_records import read_data_records # noqa: F401
|
||||
from pygui.backend.data.file_browser import FileBrowser # noqa: F401
|
||||
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
|
||||
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
|
||||
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
|
||||
from pygui.backend.models.frame import Frame # noqa: F401
|
||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
|
||||
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
|
||||
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
|
||||
from pygui.backend.models.session_model import SessionModel # noqa: F401
|
||||
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
|
||||
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
|
||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
|
||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout # noqa: F401
|
||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData # noqa: F401
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# ===== Controllers Sub-package =====
|
||||
from pygui.backend.controllers.device_controller import DeviceController
|
||||
from pygui.backend.controllers.session_controller import SessionController
|
||||
|
||||
__all__ = ["DeviceController", "SessionController"]
|
||||
|
||||
@@ -14,7 +14,7 @@ from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
||||
from pygui.backend.models.replay_stats import ReplayStatsTracker
|
||||
from pygui.backend.models.sensor_editor import SensorEditor
|
||||
from pygui.backend.models.session_model import SessionModel
|
||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||
from pygui.backend.utils import slot_error_boundary
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
@@ -37,7 +37,6 @@ class SessionController(QObject):
|
||||
loadedFileChanged = Signal()
|
||||
loadFileError = Signal(str)
|
||||
clusterAveragingEnabledChanged = Signal()
|
||||
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||
# trend: per-frame avg for live graph
|
||||
trendData = Signal(str) # JSON array of floats
|
||||
@@ -50,8 +49,7 @@ class SessionController(QObject):
|
||||
_liveFrame = Signal(object) # Frame
|
||||
_liveError = Signal() # worker-thread error ping
|
||||
|
||||
def __init__(self, parent: QObject | None = None,
|
||||
settings: dict | None = None) -> None:
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._mode = MODE_REVIEW
|
||||
self._model = SessionModel()
|
||||
@@ -59,7 +57,7 @@ class SessionController(QObject):
|
||||
self._reader: Optional[StreamReader] = None
|
||||
self._recorder = CsvRecorder()
|
||||
self._sensors: list[Sensor] = []
|
||||
self._last = None # last SessionUpdate
|
||||
self._last: Optional[SessionUpdate] = None
|
||||
self._elapsed = 0.0
|
||||
self._trend_buffer: list[float] = [] # rolling window
|
||||
self._trend_timestamps: list[float] = [] # monotonic timestamps
|
||||
@@ -96,21 +94,6 @@ class SessionController(QObject):
|
||||
self._compare_recs_b: list = []
|
||||
self._compare_alignment_map: dict[int, int] = {}
|
||||
|
||||
# Restore persisted state if available
|
||||
self._load_settings(settings or {})
|
||||
|
||||
# ---- persisted settings ----
|
||||
|
||||
def _load_settings(self, settings: dict) -> None:
|
||||
"""Restore session state from persisted settings dict."""
|
||||
# Do NOT auto-restore the last session file — always start fresh.
|
||||
|
||||
def collect_settings(self) -> dict:
|
||||
"""Return a dict of session state to persist."""
|
||||
return {
|
||||
"session_last_file": self._loaded_file,
|
||||
}
|
||||
|
||||
# ---- properties QML binds to ----
|
||||
@Property(str, notify=modeChanged)
|
||||
def mode(self) -> str: return self._mode
|
||||
@@ -150,7 +133,7 @@ class SessionController(QObject):
|
||||
"value": round(v, 2), "band": band, "index": i})
|
||||
return out
|
||||
|
||||
@Property("QVariantList", notify=sensorsChanged)
|
||||
@Property("QVariantList", notify=sensorsChanged) # type: ignore[arg-type]
|
||||
def sensorLayout(self) -> list:
|
||||
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
|
||||
return [
|
||||
@@ -175,14 +158,14 @@ class SessionController(QObject):
|
||||
"""Wafer size in mm."""
|
||||
return getattr(self._sensors, "size", 300.0)
|
||||
|
||||
@Property("QVariantList", notify=frameUpdated)
|
||||
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||
def sensorValues(self) -> list:
|
||||
"""[float] in sensor order."""
|
||||
if not self._last:
|
||||
return []
|
||||
return [round(v, 3) for v in self._last.values]
|
||||
|
||||
@Property("QVariantList", notify=frameUpdated)
|
||||
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||
def sensorBands(self) -> list:
|
||||
"""['in_range'|'high'|'low'] in sensor order."""
|
||||
if not self._last:
|
||||
@@ -212,7 +195,7 @@ class SessionController(QObject):
|
||||
@Property(str, notify=loadedFileChanged)
|
||||
def loadedFile(self) -> str: return self._loaded_file
|
||||
|
||||
@Property("QVariantList", notify=frameUpdated)
|
||||
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||
def overriddenSensors(self) -> list[int]:
|
||||
"""Indices of sensors that currently have a replacement or offset."""
|
||||
return self._sensor_editor.active_indices()
|
||||
@@ -221,7 +204,7 @@ class SessionController(QObject):
|
||||
def clusterAveragingEnabled(self) -> bool:
|
||||
return self._cluster_averaging_enabled
|
||||
|
||||
@clusterAveragingEnabled.setter
|
||||
@clusterAveragingEnabled.setter # type: ignore[no-redef]
|
||||
def clusterAveragingEnabled(self, value: bool) -> None:
|
||||
if self._cluster_averaging_enabled == value:
|
||||
return
|
||||
@@ -319,7 +302,6 @@ class SessionController(QObject):
|
||||
for frame in self._player._frames:
|
||||
self._stats_tracker.track(frame.seq, frame.values)
|
||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||
self.settingsChanged.emit()
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
@@ -331,7 +313,6 @@ class SessionController(QObject):
|
||||
self._loaded_file = ""
|
||||
self.loadedFileChanged.emit()
|
||||
self.sensorsChanged.emit()
|
||||
self.settingsChanged.emit()
|
||||
|
||||
# ---- comparison: DTW between two CSV files ----
|
||||
@Slot(str, str)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# ===== Crypto Sub-package =====
|
||||
from pygui.backend.crypto.crypto_helper import * # noqa: F403
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# ===== Data Sub-package =====
|
||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||
from pygui.backend.data.file_browser import FileBrowser
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||
|
||||
__all__ = [
|
||||
"CsvRecorder", "CSVFileMetadata",
|
||||
"read_data_records", "is_official_csv", "read_official_csv",
|
||||
"FileBrowser",
|
||||
"LocalSettings", "LocalSettingsModel",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
# ===== Backend Data Constants =====
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QStandardPaths
|
||||
|
||||
DEFAULT_DATA_DIR_NAME = "isc_data"
|
||||
|
||||
|
||||
def default_data_dir() -> Path:
|
||||
"""Documents-folder isc_data directory used as the default app data location."""
|
||||
documents_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DocumentsLocation)
|
||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||
return base_dir / DEFAULT_DATA_DIR_NAME
|
||||
|
||||
@@ -19,8 +19,8 @@ class CSVFileMetadata:
|
||||
"""Return the first character of the wafer string, or empty if none"""
|
||||
return self.wafer[0] if self.wafer else ""
|
||||
|
||||
def get_date(self) -> datetime:
|
||||
"""Parsifes the date string. Returns current time if parsing fails"""
|
||||
def get_date(self) -> datetime | None:
|
||||
"""Parsifes the date string. Returns None if parsing fails"""
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
try:
|
||||
return datetime.strptime(self.date, date_format)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Append live frames"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -12,17 +13,15 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
||||
class CsvRecorder:
|
||||
def __init__(self) -> None:
|
||||
self._file_handle: Optional[TextIO] = None
|
||||
self._oath: Optional[str] = None
|
||||
|
||||
self._path: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
return self._file_handle is not None
|
||||
|
||||
|
||||
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
|
||||
def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None:
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handle = open(path, "w", encoding="utf-8", newline ="")
|
||||
file_handle = open(path, "w", encoding="utf-8", newline="")
|
||||
file_handle.write(f"Wafer ID={serial}\n")
|
||||
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
|
||||
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
|
||||
@@ -65,21 +64,11 @@ class CsvRecorder:
|
||||
Returns:
|
||||
True on success (at least one frame written).
|
||||
"""
|
||||
from pygui.backend.data.data_records import is_official_csv
|
||||
from pygui.backend.data.data_records import is_data_row, is_official_csv
|
||||
|
||||
if start_frame < 0 or end_frame < start_frame:
|
||||
return False
|
||||
|
||||
def _is_data_row(stripped: str) -> bool:
|
||||
cols = [c.strip() for c in stripped.split(",") if c.strip()]
|
||||
if not cols:
|
||||
return False
|
||||
try:
|
||||
[float(c) for c in cols]
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
with open(source_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
@@ -99,7 +88,7 @@ class CsvRecorder:
|
||||
# official format: row 0 is the sensor-name header
|
||||
out.append(line)
|
||||
continue
|
||||
if not stripped or not _is_data_row(stripped):
|
||||
if not stripped or not is_data_row(stripped):
|
||||
continue
|
||||
frame_idx += 1
|
||||
if start_frame <= frame_idx <= end_frame:
|
||||
@@ -112,4 +101,3 @@ class CsvRecorder:
|
||||
with open(file_path, "w", encoding="utf-8", newline="") as f:
|
||||
f.writelines(out)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
@@ -6,6 +7,20 @@ from pathlib import Path
|
||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
|
||||
|
||||
|
||||
def is_data_row(line: str) -> bool:
|
||||
"""True if *line* is a single CSV row of numeric values (no headers/metadata)."""
|
||||
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
||||
return bool(cols) and all(_is_float(c) for c in cols)
|
||||
|
||||
|
||||
def _is_float(s: str) -> bool:
|
||||
try:
|
||||
float(s)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_data_records(file_path: str) -> list[DataRecord]:
|
||||
records: list[DataRecord] = []
|
||||
in_data = False
|
||||
@@ -18,13 +33,10 @@ def read_data_records(file_path: str) -> list[DataRecord]:
|
||||
if line.split(",")[0].lower() == "data":
|
||||
in_data = True
|
||||
continue
|
||||
if not is_data_row(line):
|
||||
continue
|
||||
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
||||
try:
|
||||
nums = [float(c) for c in cols]
|
||||
except ValueError:
|
||||
continue
|
||||
if not nums:
|
||||
continue
|
||||
records.append(DataRecord(time=nums[0], values=nums[1:]))
|
||||
return records
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot
|
||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||
|
||||
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
|
||||
from pygui.backend.data.constants import default_data_dir
|
||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
@@ -28,7 +28,7 @@ class FileBrowser(QObject):
|
||||
self._refresh_files(show_empty_message=False)
|
||||
|
||||
# ===== Exposed Properties =====
|
||||
@Property("QVariantList", notify=filesChanged)
|
||||
@Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
|
||||
def files(self) -> list[dict[str, object]]:
|
||||
return [dict(row) for row in self._files]
|
||||
|
||||
@@ -42,7 +42,7 @@ class FileBrowser(QObject):
|
||||
selected_dir = QFileDialog.getExistingDirectory(
|
||||
None,
|
||||
"Select CSV Folder",
|
||||
self.currentDirectory,
|
||||
str(self._current_directory),
|
||||
)
|
||||
if not selected_dir:
|
||||
return
|
||||
@@ -235,11 +235,7 @@ class FileBrowser(QObject):
|
||||
|
||||
# ===== Internal Helpers =====
|
||||
def _resolve_default_directory(self) -> Path:
|
||||
documents_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.DocumentsLocation
|
||||
)
|
||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||
return base_dir / DEFAULT_DATA_DIR_NAME
|
||||
return default_data_dir()
|
||||
|
||||
def _set_current_directory(self, directory: Path) -> None:
|
||||
normalized = Path(directory)
|
||||
|
||||
@@ -13,8 +13,8 @@ class LocalSettings:
|
||||
# Configuration settings
|
||||
self.chamber_id = ""
|
||||
self.reverse_z_wafer = False
|
||||
self.master = {} # Dict[str, str]
|
||||
self.wafer_data_size = {} # Dict[str, int]
|
||||
self.master: dict[str, str] = {}
|
||||
self.wafer_data_size: dict[str, int] = {}
|
||||
self.debug = False
|
||||
self.wafer_read_retries = 8
|
||||
# Timeout in msec for reading from the wafer (default 2 min)
|
||||
@@ -32,9 +32,6 @@ class LocalSettings:
|
||||
self.data_col_count = 0
|
||||
self.last_csv_path = ""
|
||||
|
||||
# Session persistence
|
||||
self.session_last_file = ""
|
||||
|
||||
# ===== File Path Helpers =====
|
||||
@classmethod
|
||||
def _settings_path(cls, directory: str) -> Path:
|
||||
|
||||
@@ -2,17 +2,26 @@ from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot
|
||||
from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot
|
||||
|
||||
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
|
||||
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
||||
|
||||
|
||||
class LocalSettingsModel(QObject):
|
||||
# Draft attrs are seeded via setattr() in __init__ from _new_defaults();
|
||||
# declared here so mypy knows their types.
|
||||
_chamber_id: str
|
||||
_reverse_z_wafer: bool
|
||||
_debug_mode: bool
|
||||
_wafer_read_timeout: int
|
||||
_wafer_detect_timeout: int
|
||||
_wafer_retries: int
|
||||
_masters: dict[str, str]
|
||||
|
||||
chamberIdChanged = Signal()
|
||||
reverseZWaferChanged = Signal()
|
||||
debugModeChanged = Signal()
|
||||
@@ -26,18 +35,35 @@ class LocalSettingsModel(QObject):
|
||||
saveStatusChanged = Signal()
|
||||
lastSavedAtChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
# Draft attr (this class) <-> LocalSettings attr (the shared, persisted
|
||||
# instance also held by DeviceController) <-> coercer applied on both
|
||||
# load and revert/reset. One table drives load/save/revert/reset instead
|
||||
# of four hand-written field-by-field copies.
|
||||
_FIELD_MAP: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
|
||||
("_chamber_id", "chamber_id", str),
|
||||
("_reverse_z_wafer", "reverse_z_wafer", bool),
|
||||
("_debug_mode", "debug", bool),
|
||||
("_wafer_read_timeout", "wafer_read_timeout", int),
|
||||
("_wafer_detect_timeout", "wafer_detect_timeout", int),
|
||||
("_wafer_retries", "wafer_read_retries", int),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: LocalSettings,
|
||||
data_dir: Path | str,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._data_dir = self._resolve_data_dir()
|
||||
# Shared with DeviceController — saving here must mutate this same
|
||||
# instance, not a throwaway copy, or the device layer keeps using
|
||||
# stale config until restart.
|
||||
self._settings = settings
|
||||
self._data_dir = Path(data_dir)
|
||||
self._defaults = self._new_defaults()
|
||||
|
||||
self._chamber_id = self._defaults["chamberId"]
|
||||
self._reverse_z_wafer = self._defaults["reverseZWafer"]
|
||||
self._debug_mode = self._defaults["debugMode"]
|
||||
self._wafer_read_timeout = self._defaults["waferReadTimeout"]
|
||||
self._wafer_detect_timeout = self._defaults["waferDetectTimeout"]
|
||||
self._wafer_retries = self._defaults["waferRetries"]
|
||||
self._masters = deepcopy(self._defaults["masters"])
|
||||
for draft_attr, default_value in self._defaults.items():
|
||||
setattr(self, draft_attr, deepcopy(default_value))
|
||||
|
||||
self._is_dirty = False
|
||||
self._is_valid = True
|
||||
@@ -47,34 +73,21 @@ class LocalSettingsModel(QObject):
|
||||
self._saved_snapshot = self._snapshot()
|
||||
self._recompute_derived()
|
||||
|
||||
def _resolve_data_dir(self) -> Path:
|
||||
documents_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.DocumentsLocation
|
||||
)
|
||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||
return base_dir / DEFAULT_DATA_DIR_NAME
|
||||
|
||||
def _new_defaults(self) -> dict[str, Any]:
|
||||
return {
|
||||
"chamberId": "2",
|
||||
"reverseZWafer": False,
|
||||
"debugMode": False,
|
||||
"waferReadTimeout": 120000,
|
||||
"waferDetectTimeout": 5000,
|
||||
"waferRetries": 8,
|
||||
"masters": {family: "" for family in MASTER_FAMILIES},
|
||||
"_chamber_id": "2",
|
||||
"_reverse_z_wafer": False,
|
||||
"_debug_mode": False,
|
||||
"_wafer_read_timeout": 120000,
|
||||
"_wafer_detect_timeout": 5000,
|
||||
"_wafer_retries": 8,
|
||||
"_masters": {family: "" for family in MASTER_FAMILIES},
|
||||
}
|
||||
|
||||
def _snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"chamberId": self._chamber_id,
|
||||
"reverseZWafer": self._reverse_z_wafer,
|
||||
"debugMode": self._debug_mode,
|
||||
"waferReadTimeout": self._wafer_read_timeout,
|
||||
"waferDetectTimeout": self._wafer_detect_timeout,
|
||||
"waferRetries": self._wafer_retries,
|
||||
"masters": deepcopy(self._masters),
|
||||
}
|
||||
snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
|
||||
snap["_masters"] = deepcopy(self._masters)
|
||||
return snap
|
||||
|
||||
def _set_is_dirty(self, value: bool) -> None:
|
||||
if self._is_dirty != value:
|
||||
@@ -137,22 +150,19 @@ class LocalSettingsModel(QObject):
|
||||
self.waferRetriesChanged.emit()
|
||||
self.mastersChanged.emit()
|
||||
|
||||
def _to_local_settings(self) -> LocalSettings:
|
||||
settings = LocalSettings()
|
||||
settings.chamber_id = self._chamber_id
|
||||
settings.reverse_z_wafer = self._reverse_z_wafer
|
||||
settings.debug = self._debug_mode
|
||||
settings.wafer_read_timeout = self._wafer_read_timeout
|
||||
settings.wafer_detect_timeout = self._wafer_detect_timeout
|
||||
settings.wafer_read_retries = self._wafer_retries
|
||||
settings.master = deepcopy(self._masters)
|
||||
return settings
|
||||
def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
|
||||
masters = {family: "" for family in MASTER_FAMILIES}
|
||||
for family, value in source.items():
|
||||
normalized = str(family).strip().upper()
|
||||
if normalized in masters:
|
||||
masters[normalized] = str(value).strip()
|
||||
return masters
|
||||
|
||||
@Property(str, notify=chamberIdChanged)
|
||||
def chamberId(self) -> str:
|
||||
return self._chamber_id
|
||||
|
||||
@chamberId.setter
|
||||
@chamberId.setter # type: ignore[no-redef]
|
||||
def chamberId(self, value: str) -> None:
|
||||
next_value = str(value).strip()
|
||||
if self._chamber_id == next_value:
|
||||
@@ -165,7 +175,7 @@ class LocalSettingsModel(QObject):
|
||||
def reverseZWafer(self) -> bool:
|
||||
return self._reverse_z_wafer
|
||||
|
||||
@reverseZWafer.setter
|
||||
@reverseZWafer.setter # type: ignore[no-redef]
|
||||
def reverseZWafer(self, value: bool) -> None:
|
||||
if self._reverse_z_wafer == value:
|
||||
return
|
||||
@@ -177,7 +187,7 @@ class LocalSettingsModel(QObject):
|
||||
def debugMode(self) -> bool:
|
||||
return self._debug_mode
|
||||
|
||||
@debugMode.setter
|
||||
@debugMode.setter # type: ignore[no-redef]
|
||||
def debugMode(self, value: bool) -> None:
|
||||
if self._debug_mode == value:
|
||||
return
|
||||
@@ -189,7 +199,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferReadTimeout(self) -> int:
|
||||
return self._wafer_read_timeout
|
||||
|
||||
@waferReadTimeout.setter
|
||||
@waferReadTimeout.setter # type: ignore[no-redef]
|
||||
def waferReadTimeout(self, value: int) -> None:
|
||||
if self._wafer_read_timeout == value:
|
||||
return
|
||||
@@ -201,7 +211,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferDetectTimeout(self) -> int:
|
||||
return self._wafer_detect_timeout
|
||||
|
||||
@waferDetectTimeout.setter
|
||||
@waferDetectTimeout.setter # type: ignore[no-redef]
|
||||
def waferDetectTimeout(self, value: int) -> None:
|
||||
if self._wafer_detect_timeout == value:
|
||||
return
|
||||
@@ -213,7 +223,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferRetries(self) -> int:
|
||||
return self._wafer_retries
|
||||
|
||||
@waferRetries.setter
|
||||
@waferRetries.setter # type: ignore[no-redef]
|
||||
def waferRetries(self, value: int) -> None:
|
||||
if self._wafer_retries == value:
|
||||
return
|
||||
@@ -251,22 +261,11 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def loadSettings(self) -> None:
|
||||
loaded = LocalSettings.read_settings(str(self._data_dir))
|
||||
self._chamber_id = str(loaded.chamber_id).strip() or str(
|
||||
self._defaults["chamberId"]
|
||||
)
|
||||
self._reverse_z_wafer = bool(loaded.reverse_z_wafer)
|
||||
self._debug_mode = bool(loaded.debug)
|
||||
self._wafer_read_timeout = int(loaded.wafer_read_timeout)
|
||||
self._wafer_detect_timeout = int(loaded.wafer_detect_timeout)
|
||||
self._wafer_retries = int(loaded.wafer_read_retries)
|
||||
|
||||
masters = {family: "" for family in MASTER_FAMILIES}
|
||||
for family, value in loaded.master.items():
|
||||
normalized = str(family).strip().upper()
|
||||
if normalized in masters:
|
||||
masters[normalized] = str(value).strip()
|
||||
self._masters = masters
|
||||
"""Seed draft fields from the shared LocalSettings instance."""
|
||||
for draft_attr, settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
|
||||
self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
|
||||
self._masters = self._normalized_masters(self._settings.master)
|
||||
|
||||
self._saved_snapshot = self._snapshot()
|
||||
self._emit_all_changed()
|
||||
@@ -275,13 +274,18 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def saveSettings(self) -> None:
|
||||
"""Write draft fields onto the shared LocalSettings instance and persist it."""
|
||||
self._recompute_derived()
|
||||
if not self._is_valid:
|
||||
self._set_save_status("error: invalid settings")
|
||||
return
|
||||
|
||||
try:
|
||||
LocalSettings.save_settings(str(self._data_dir), self._to_local_settings())
|
||||
for draft_attr, settings_attr, _coerce in self._FIELD_MAP:
|
||||
setattr(self._settings, settings_attr, getattr(self, draft_attr))
|
||||
self._settings.master = deepcopy(self._masters)
|
||||
LocalSettings.save_settings(str(self._data_dir), self._settings)
|
||||
|
||||
self._saved_snapshot = self._snapshot()
|
||||
self._recompute_derived()
|
||||
self._set_save_status("Saved")
|
||||
@@ -293,13 +297,9 @@ class LocalSettingsModel(QObject):
|
||||
@Slot()
|
||||
def revertChanges(self) -> None:
|
||||
snap = self._saved_snapshot
|
||||
self._chamber_id = str(snap["chamberId"])
|
||||
self._reverse_z_wafer = bool(snap["reverseZWafer"])
|
||||
self._debug_mode = bool(snap["debugMode"])
|
||||
self._wafer_read_timeout = int(snap["waferReadTimeout"])
|
||||
self._wafer_detect_timeout = int(snap["waferDetectTimeout"])
|
||||
self._wafer_retries = int(snap["waferRetries"])
|
||||
self._masters = deepcopy(snap["masters"])
|
||||
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(snap[draft_attr]))
|
||||
self._masters = deepcopy(snap["_masters"])
|
||||
|
||||
self._emit_all_changed()
|
||||
self._recompute_derived()
|
||||
@@ -307,13 +307,9 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def resetDefaults(self) -> None:
|
||||
self._chamber_id = str(self._defaults["chamberId"])
|
||||
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"])
|
||||
self._debug_mode = bool(self._defaults["debugMode"])
|
||||
self._wafer_read_timeout = int(self._defaults["waferReadTimeout"])
|
||||
self._wafer_detect_timeout = int(self._defaults["waferDetectTimeout"])
|
||||
self._wafer_retries = int(self._defaults["waferRetries"])
|
||||
self._masters = deepcopy(self._defaults["masters"])
|
||||
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
|
||||
self._masters = deepcopy(self._defaults["_masters"])
|
||||
|
||||
self._emit_all_changed()
|
||||
self._recompute_derived()
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
from pygui.backend.license.license_manager import (
|
||||
LICENSE_FILENAME_RE,
|
||||
License,
|
||||
parse_license_blob,
|
||||
read_license_files,
|
||||
)
|
||||
from pygui.backend.license.license_model import LicenseModel
|
||||
|
||||
__all__ = [
|
||||
"LICENSE_FILENAME_RE",
|
||||
"License",
|
||||
"LicenseModel",
|
||||
"parse_license_blob",
|
||||
"read_license_files",
|
||||
]
|
||||
|
||||
@@ -50,7 +50,7 @@ class LicenseModel(QObject):
|
||||
self.refresh()
|
||||
|
||||
# ── Grid rows ─────────────────────────────────────────────────────
|
||||
@Property("QVariantList", notify=licensesChanged)
|
||||
@Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
|
||||
def licenses(self) -> list:
|
||||
"""Rows for the About grid: serial, mfg date, level, license date."""
|
||||
return [
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# ===== Models Sub-package =====
|
||||
from pygui.backend.models.data_model import TemperatureTableModel
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
||||
from pygui.backend.models.sensor_editor import SensorEditor
|
||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||
from pygui.backend.models.stability_detector import StabilityDetector
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
||||
|
||||
__all__ = [
|
||||
"Frame", "FramePlayer", "frames_from_wafer_data",
|
||||
"Stats", "compute_stats",
|
||||
"SessionModel", "SessionUpdate",
|
||||
"TemperatureTableModel",
|
||||
"SensorEditor",
|
||||
"ThresholdConfig", "classify", "resolve_bounds",
|
||||
"StabilityDetector",
|
||||
]
|
||||
|
||||
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
||||
# Column 0 = row index, columns 1..N = sensor values
|
||||
return self._col_count + 1
|
||||
|
||||
def data(self, index: Any, role: int = ...) -> Any:
|
||||
def data(self, index: Any, role: int = ...) -> Any: # type: ignore[assignment]
|
||||
if not index.isValid():
|
||||
return None
|
||||
|
||||
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
||||
return None
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: int, role: int = ...
|
||||
self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
|
||||
) -> Any:
|
||||
if role != Qt.ItemDataRole.DisplayRole:
|
||||
return None
|
||||
|
||||
@@ -4,7 +4,7 @@ from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.wafer.zwafer_models import ZWaferData
|
||||
|
||||
|
||||
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
|
||||
def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
|
||||
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
||||
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# ===== Visualization Sub-package =====
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
__all__ = [
|
||||
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
|
||||
"interpolate_field",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Pure coordinate-mapping math shared by the QQuickPaintedItem chart items
|
||||
(GraphQuickItem, TrendChartItem). No QPainter or Qt-widget dependency, so
|
||||
it's testable without a GUI — unlike the paint() methods that call it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def value_to_pixel(
|
||||
value: float,
|
||||
value_min: float,
|
||||
value_max: float,
|
||||
pixel_start: float,
|
||||
pixel_span: float,
|
||||
) -> float:
|
||||
"""Map a value onto a pixel range, inverted so larger values sit at
|
||||
smaller pixel coordinates (screen y grows downward).
|
||||
|
||||
Also used for evenly-spaced tick indices: pass the tick index as
|
||||
`value` and `(tick_count - 1)` as `value_max` with `value_min=0`.
|
||||
"""
|
||||
value_range = value_max - value_min
|
||||
if value_range < 1e-9:
|
||||
return pixel_start + pixel_span / 2
|
||||
fraction = 1.0 - (value - value_min) / value_range
|
||||
return pixel_start + fraction * pixel_span
|
||||
|
||||
|
||||
def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: float) -> float:
|
||||
"""Map a 0-based sample index onto a pixel range, left to right."""
|
||||
if count <= 1:
|
||||
return pixel_start + pixel_span / 2
|
||||
fraction = index / (count - 1)
|
||||
return pixel_start + fraction * pixel_span
|
||||
@@ -26,6 +26,8 @@ from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||
from PySide6.QtQml import QmlElement
|
||||
from PySide6.QtQuick import QQuickPaintedItem
|
||||
|
||||
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
||||
|
||||
QML_IMPORT_NAME = "ISC.Wafer"
|
||||
QML_IMPORT_MAJOR_VERSION = 1
|
||||
|
||||
@@ -78,7 +80,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def seriesData(self) -> list:
|
||||
return self._series_data
|
||||
|
||||
@seriesData.setter
|
||||
@seriesData.setter # type: ignore[no-redef]
|
||||
def seriesData(self, val: list) -> None:
|
||||
self._series_data = [list(s) for s in (val or [])]
|
||||
self._auto_range()
|
||||
@@ -89,7 +91,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def sensorNames(self) -> list:
|
||||
return self._sensor_names
|
||||
|
||||
@sensorNames.setter
|
||||
@sensorNames.setter # type: ignore[no-redef]
|
||||
def sensorNames(self, val: list) -> None:
|
||||
self._sensor_names = list(val or [])
|
||||
self.sensorNamesChanged.emit()
|
||||
@@ -99,7 +101,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def title(self) -> str:
|
||||
return self._title
|
||||
|
||||
@title.setter
|
||||
@title.setter # type: ignore[no-redef]
|
||||
def title(self, val: str) -> None:
|
||||
self._title = str(val or "")
|
||||
self.titleChanged.emit()
|
||||
@@ -109,7 +111,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def xLabel(self) -> str:
|
||||
return self._x_label
|
||||
|
||||
@xLabel.setter
|
||||
@xLabel.setter # type: ignore[no-redef]
|
||||
def xLabel(self, val: str) -> None:
|
||||
self._x_label = str(val or "")
|
||||
self.xLabelChanged.emit()
|
||||
@@ -119,7 +121,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def yLabel(self) -> str:
|
||||
return self._y_label
|
||||
|
||||
@yLabel.setter
|
||||
@yLabel.setter # type: ignore[no-redef]
|
||||
def yLabel(self, val: str) -> None:
|
||||
self._y_label = str(val or "")
|
||||
self.yLabelChanged.emit()
|
||||
@@ -129,7 +131,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def showLegend(self) -> bool:
|
||||
return self._show_legend
|
||||
|
||||
@showLegend.setter
|
||||
@showLegend.setter # type: ignore[no-redef]
|
||||
def showLegend(self, val: bool) -> None:
|
||||
self._show_legend = bool(val)
|
||||
self.colorsChanged.emit()
|
||||
@@ -141,7 +143,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def backgroundColor(self) -> QColor:
|
||||
return self._bg_color
|
||||
|
||||
@backgroundColor.setter
|
||||
@backgroundColor.setter # type: ignore[no-redef]
|
||||
def backgroundColor(self, c: QColor) -> None:
|
||||
self._bg_color = c
|
||||
self.update()
|
||||
@@ -150,7 +152,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def gridColor(self) -> QColor:
|
||||
return self._grid_color
|
||||
|
||||
@gridColor.setter
|
||||
@gridColor.setter # type: ignore[no-redef]
|
||||
def gridColor(self, c: QColor) -> None:
|
||||
self._grid_color = c
|
||||
self.update()
|
||||
@@ -159,7 +161,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def axisColor(self) -> QColor:
|
||||
return self._axis_color
|
||||
|
||||
@axisColor.setter
|
||||
@axisColor.setter # type: ignore[no-redef]
|
||||
def axisColor(self, c: QColor) -> None:
|
||||
self._axis_color = c
|
||||
self.update()
|
||||
@@ -168,7 +170,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def textColor(self) -> QColor:
|
||||
return self._text_color
|
||||
|
||||
@textColor.setter
|
||||
@textColor.setter # type: ignore[no-redef]
|
||||
def textColor(self, c: QColor) -> None:
|
||||
self._text_color = c
|
||||
self.update()
|
||||
@@ -178,7 +180,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
"""Return hex strings of the current series color palette."""
|
||||
return [c.name() for c in self._series_colors]
|
||||
|
||||
@seriesColors.setter
|
||||
@seriesColors.setter # type: ignore[no-redef]
|
||||
def seriesColors(self, hex_list: list) -> None:
|
||||
colors = []
|
||||
for h in (hex_list or []):
|
||||
@@ -275,8 +277,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
|
||||
for i in range(n_y_ticks):
|
||||
y_val = self._min_y + i * y_step
|
||||
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
|
||||
y_px = plot_top + y_frac * plot_h
|
||||
y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
|
||||
|
||||
# Grid line
|
||||
painter.setPen(grid_pen)
|
||||
@@ -325,8 +326,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
||||
for i in range(n_x_ticks):
|
||||
idx = int(round(i * x_tick_step))
|
||||
x_frac = idx / (num_points - 1)
|
||||
x_px = plot_left + x_frac * plot_w
|
||||
x_px = index_to_pixel(idx, num_points, plot_left, plot_w)
|
||||
painter.setPen(axis_pen)
|
||||
painter.drawText(
|
||||
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||
@@ -358,10 +358,8 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
v = float(val)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
|
||||
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
|
||||
x_px = plot_left + x_frac * plot_w
|
||||
y_px = plot_top + y_frac * plot_h
|
||||
x_px = index_to_pixel(i, max_len, plot_left, plot_w)
|
||||
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
||||
pts.append((x_px, y_px))
|
||||
|
||||
if len(pts) < 2:
|
||||
|
||||
@@ -23,6 +23,9 @@ class GraphView(QObject):
|
||||
each sensor as a separate line series.
|
||||
"""
|
||||
|
||||
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
|
||||
_trend_line: Any
|
||||
|
||||
# ---- signals ----
|
||||
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
||||
|
||||
@@ -227,7 +230,7 @@ class GraphView(QObject):
|
||||
index_a, index_b = path[i]
|
||||
if index_a < len(a) and index_b < len(b):
|
||||
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||
style=Qt.DashLine))
|
||||
style=Qt.PenStyle.DashLine))
|
||||
self._plot_widget.addItem(line)
|
||||
|
||||
self._plot_widget.setTitle("DTW Comparison")
|
||||
|
||||
@@ -73,7 +73,8 @@ def interpolate_field(
|
||||
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
|
||||
field = np.where(dist <= 1.0, field, np.nan)
|
||||
|
||||
return field.astype(np.float64)
|
||||
result: np.ndarray = field.astype(np.float64)
|
||||
return result
|
||||
|
||||
|
||||
def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
|
||||
@@ -84,7 +85,8 @@ def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
|
||||
resampling across a NaN/zero-filled hole rings at the boundary, and any
|
||||
mask derived from the coarse grid keeps its staircase when upscaled.
|
||||
"""
|
||||
return _ndi_zoom(field, factor, order=3)
|
||||
refined: np.ndarray = _ndi_zoom(field, factor, order=3)
|
||||
return refined
|
||||
|
||||
|
||||
def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray:
|
||||
|
||||
@@ -20,6 +20,8 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
||||
from PySide6.QtQml import QmlElement
|
||||
from PySide6.QtQuick import QQuickPaintedItem
|
||||
|
||||
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
QML_IMPORT_NAME = "ISC.Wafer"
|
||||
@@ -60,7 +62,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def data(self) -> list[float]:
|
||||
return self._data
|
||||
|
||||
@data.setter
|
||||
@data.setter # type: ignore[no-redef]
|
||||
def data(self, val) -> None:
|
||||
coerced = self._coerce_floats(val)
|
||||
if coerced == self._data:
|
||||
@@ -82,7 +84,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def lineColor(self) -> QColor:
|
||||
return self._line_color
|
||||
|
||||
@lineColor.setter
|
||||
@lineColor.setter # type: ignore[no-redef]
|
||||
def lineColor(self, c: QColor) -> None:
|
||||
if c == self._line_color:
|
||||
return
|
||||
@@ -94,7 +96,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def gridColor(self) -> QColor:
|
||||
return self._grid_color
|
||||
|
||||
@gridColor.setter
|
||||
@gridColor.setter # type: ignore[no-redef]
|
||||
def gridColor(self, c: QColor) -> None:
|
||||
if c == self._grid_color:
|
||||
return
|
||||
@@ -106,7 +108,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def textColor(self) -> QColor:
|
||||
return self._text_color
|
||||
|
||||
@textColor.setter
|
||||
@textColor.setter # type: ignore[no-redef]
|
||||
def textColor(self, c: QColor) -> None:
|
||||
if c == self._text_color:
|
||||
return
|
||||
@@ -118,7 +120,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def padding(self) -> int:
|
||||
return self._padding
|
||||
|
||||
@padding.setter
|
||||
@padding.setter # type: ignore[no-redef]
|
||||
def padding(self, val: int) -> None:
|
||||
v = max(0, int(val))
|
||||
if v == self._padding:
|
||||
@@ -201,15 +203,10 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
return math.floor(lo / step) * step, math.ceil(hi / step) * step
|
||||
|
||||
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
||||
if n <= 1:
|
||||
return plot_rect.left()
|
||||
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
||||
return index_to_pixel(i, n, plot_rect.left(), plot_rect.width())
|
||||
|
||||
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
||||
if y_max - y_min < 1e-9:
|
||||
return plot_rect.center().y()
|
||||
t = (v - y_min) / (y_max - y_min)
|
||||
return plot_rect.bottom() - t * plot_rect.height()
|
||||
return value_to_pixel(v, y_min, y_max, plot_rect.top(), plot_rect.height())
|
||||
|
||||
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||
pen = QPen(self._grid_color)
|
||||
|
||||
@@ -107,7 +107,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def sensors(self) -> list:
|
||||
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
||||
|
||||
@sensors.setter
|
||||
@sensors.setter # type: ignore[no-redef]
|
||||
def sensors(self, val: list) -> None:
|
||||
self._sensors = [
|
||||
Sensor(
|
||||
@@ -127,7 +127,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def values(self) -> list:
|
||||
return self._values
|
||||
|
||||
@values.setter
|
||||
@values.setter # type: ignore[no-redef]
|
||||
def values(self, val: list) -> None:
|
||||
self._values = list(val or [])
|
||||
self._rebuild_heatmap()
|
||||
@@ -138,7 +138,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def bands(self) -> list:
|
||||
return self._bands
|
||||
|
||||
@bands.setter
|
||||
@bands.setter # type: ignore[no-redef]
|
||||
def bands(self, val: list) -> None:
|
||||
self._bands = list(val or [])
|
||||
self.bandsChanged.emit()
|
||||
@@ -148,7 +148,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def target(self) -> float:
|
||||
return self._target
|
||||
|
||||
@target.setter
|
||||
@target.setter # type: ignore[no-redef]
|
||||
def target(self, val: float) -> None:
|
||||
self._target = float(val)
|
||||
self._rebuild_heatmap()
|
||||
@@ -159,7 +159,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def margin(self) -> float:
|
||||
return self._margin
|
||||
|
||||
@margin.setter
|
||||
@margin.setter # type: ignore[no-redef]
|
||||
def margin(self, val: float) -> None:
|
||||
self._margin = float(val)
|
||||
self._rebuild_heatmap()
|
||||
@@ -170,7 +170,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def blend(self) -> float:
|
||||
return self._blend
|
||||
|
||||
@blend.setter
|
||||
@blend.setter # type: ignore[no-redef]
|
||||
def blend(self, val: float) -> None:
|
||||
self._blend = max(0.0, min(1.0, float(val)))
|
||||
if self._blend > 0 and self._heatmap is None:
|
||||
@@ -182,7 +182,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def showLabels(self) -> bool:
|
||||
return self._show_labels
|
||||
|
||||
@showLabels.setter
|
||||
@showLabels.setter # type: ignore[no-redef]
|
||||
def showLabels(self, val: bool) -> None:
|
||||
self._show_labels = bool(val)
|
||||
self.showLabelsChanged.emit()
|
||||
@@ -192,7 +192,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def shape(self) -> str:
|
||||
return self._shape
|
||||
|
||||
@shape.setter
|
||||
@shape.setter # type: ignore[no-redef]
|
||||
def shape(self, val: str) -> None:
|
||||
self._shape = str(val).lower()
|
||||
self._rebuild()
|
||||
@@ -202,7 +202,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def size(self) -> float:
|
||||
return self._size
|
||||
|
||||
@size.setter
|
||||
@size.setter # type: ignore[no-redef]
|
||||
def size(self, val: float) -> None:
|
||||
self._size = float(val)
|
||||
self._rebuild()
|
||||
@@ -212,7 +212,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def thicknessData(self) -> list:
|
||||
return self._thickness_data
|
||||
|
||||
@thicknessData.setter
|
||||
@thicknessData.setter # type: ignore[no-redef]
|
||||
def thicknessData(self, val:list) -> None:
|
||||
self._thickness_data = list(val or [])
|
||||
self._rebuild_thickness()
|
||||
@@ -223,7 +223,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def showThickness(self) -> bool:
|
||||
return self._show_thickness
|
||||
|
||||
@showThickness.setter
|
||||
@showThickness.setter # type: ignore[no-redef]
|
||||
def showThickness(self, val: bool) -> None:
|
||||
self._show_thickness = bool(val)
|
||||
self.showThicknessChanged.emit()
|
||||
@@ -233,7 +233,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def hoveredIndex(self) -> int:
|
||||
return self._hovered_index
|
||||
|
||||
@hoveredIndex.setter
|
||||
@hoveredIndex.setter # type: ignore[no-redef]
|
||||
def hoveredIndex(self, val: int) -> None:
|
||||
val = int(val)
|
||||
if val == self._hovered_index:
|
||||
@@ -246,7 +246,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def hoverScale(self) -> float:
|
||||
return self._hover_scale
|
||||
|
||||
@hoverScale.setter
|
||||
@hoverScale.setter # type: ignore[no-redef]
|
||||
def hoverScale(self, val: float) -> None:
|
||||
self._hover_scale = float(val)
|
||||
self.hoverScaleChanged.emit()
|
||||
@@ -255,32 +255,32 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
# Colour properties — QML can bind these to Theme tokens
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
def ringColor(self) -> QColor: return self._ring_color
|
||||
@ringColor.setter
|
||||
@ringColor.setter # type: ignore[no-redef]
|
||||
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
def axisColor(self) -> QColor: return self._axis_color
|
||||
@axisColor.setter
|
||||
@axisColor.setter # type: ignore[no-redef]
|
||||
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
def lowColor(self) -> QColor: return self._low_color
|
||||
@lowColor.setter
|
||||
@lowColor.setter # type: ignore[no-redef]
|
||||
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
def inRangeColor(self) -> QColor: return self._in_range_color
|
||||
@inRangeColor.setter
|
||||
@inRangeColor.setter # type: ignore[no-redef]
|
||||
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
def highColor(self) -> QColor: return self._high_color
|
||||
@highColor.setter
|
||||
@highColor.setter # type: ignore[no-redef]
|
||||
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
def textColor(self) -> QColor: return self._text_color
|
||||
@textColor.setter
|
||||
@textColor.setter # type: ignore[no-redef]
|
||||
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
|
||||
|
||||
# ── slots ─────────────────────────────────────────────────────────────
|
||||
@@ -314,7 +314,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
return False
|
||||
try:
|
||||
result = self.grabToImage()
|
||||
img = result.image()
|
||||
img = result.image() # type: ignore[attr-defined]
|
||||
img.save(file_path, "PNG")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -702,11 +702,11 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
painter.setPen(QPen(self._text_color))
|
||||
y1 = ly + id_ascent
|
||||
if side in ("top", "bottom"):
|
||||
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)
|
||||
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) # type: ignore[arg-type]
|
||||
elif side == "left":
|
||||
painter.drawText(lx + (text_w - id_w), y1, id_text)
|
||||
painter.drawText(lx + (text_w - id_w), y1, id_text) # type: ignore[arg-type]
|
||||
else:
|
||||
painter.drawText(lx, y1, id_text)
|
||||
painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
|
||||
|
||||
# Draw Temperature (second line)
|
||||
if has_temp:
|
||||
@@ -714,8 +714,8 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
painter.setPen(QPen(color))
|
||||
y2 = ly + id_line_h + temp_ascent
|
||||
if side in ("top", "bottom"):
|
||||
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)
|
||||
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) # type: ignore[arg-type]
|
||||
elif side == "left":
|
||||
painter.drawText(lx + (text_w - temp_w), y2, temp_text)
|
||||
painter.drawText(lx + (text_w - temp_w), y2, temp_text) # type: ignore[arg-type]
|
||||
else:
|
||||
painter.drawText(lx, y2, temp_text)
|
||||
painter.drawText(lx, y2, temp_text) # type: ignore[arg-type]
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# ===== Wafer Sub-package =====
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
__all__ = [
|
||||
"ZWaferData", "Sensor", "DataRecord",
|
||||
"ZWaferParser",
|
||||
"load_layout", "available_families",
|
||||
"sensor_count_for",
|
||||
]
|
||||
|
||||
@@ -66,7 +66,8 @@ def _family_name(raw_name: str) -> str:
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
loaded: dict = yaml.safe_load(f)
|
||||
return loaded
|
||||
|
||||
|
||||
# TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser)
|
||||
|
||||
@@ -115,7 +115,7 @@ class ZWaferParser:
|
||||
y_coords: Optional[list],
|
||||
) -> None:
|
||||
"""Build sensor list from layout arrays."""
|
||||
if not all([labels, x_coords, y_coords]):
|
||||
if not (labels and x_coords and y_coords):
|
||||
raise ValueError("Sensor layout section is incomplete or missing.")
|
||||
|
||||
if len(labels) != len(x_coords) or len(labels) != len(y_coords):
|
||||
|
||||
@@ -10,6 +10,7 @@ Mirrors the C# Form1.cs binary parsing pipeline:
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
@@ -266,7 +267,6 @@ def save_to_csv(
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# Build filename: P00001-20260505_133045.csv
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{serial_number}-{timestamp}.csv"
|
||||
|
||||
@@ -45,7 +45,7 @@ class StreamReader:
|
||||
Reads up to 256 bytes in one shot. If nothing is immediately
|
||||
available, yields briefly to avoid a busy spin.
|
||||
"""
|
||||
chunk = self._transport.read(max(min_bytes, 256))
|
||||
chunk: bytes = self._transport.read(max(min_bytes, 256))
|
||||
if not chunk:
|
||||
time.sleep(0.005)
|
||||
return chunk
|
||||
@@ -106,7 +106,7 @@ class StreamReader:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run_binary(self, initial_bytes: bytes) -> None:
|
||||
def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
|
||||
log.info("StreamReader: starting binary stream parsing")
|
||||
buf = bytearray(initial_bytes)
|
||||
resync_attempts = 0
|
||||
@@ -149,7 +149,7 @@ class StreamReader:
|
||||
payload = buf[7:7 + payload_len]
|
||||
|
||||
try:
|
||||
frame = self._parse(payload, seq)
|
||||
frame = self._parse(payload, seq) # type: ignore[arg-type]
|
||||
self._on_frame(frame)
|
||||
except Exception as exc:
|
||||
self.error_count += 1
|
||||
@@ -238,7 +238,7 @@ class StreamReader:
|
||||
for hex_val in valid_hex_words:
|
||||
try:
|
||||
swapped = hex_val[2:4] + hex_val[0:2]
|
||||
t = _convert_hex_to_temp(swapped, self._family_code)
|
||||
t = _convert_hex_to_temp(swapped, self._family_code) # type: ignore[arg-type]
|
||||
values.append(t)
|
||||
except Exception:
|
||||
values.append(0.0)
|
||||
@@ -262,7 +262,7 @@ class StreamReader:
|
||||
line = line_bytes.decode('utf-8', errors='ignore').strip()
|
||||
if line:
|
||||
try:
|
||||
frame = self._parse(line, seq)
|
||||
frame = self._parse(line, seq) # type: ignore[arg-type]
|
||||
if frame:
|
||||
self._on_frame(frame)
|
||||
seq += 1
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
|
||||
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
||||
|
||||
|
||||
def test_value_to_pixel_min_maps_to_bottom():
|
||||
assert value_to_pixel(0.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(210.0)
|
||||
|
||||
|
||||
def test_value_to_pixel_max_maps_to_top():
|
||||
assert value_to_pixel(100.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_value_to_pixel_midpoint():
|
||||
assert value_to_pixel(50.0, 0.0, 100.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_value_to_pixel_degenerate_range_centers():
|
||||
assert value_to_pixel(5.0, 5.0, 5.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_index_to_pixel_first_and_last():
|
||||
assert index_to_pixel(0, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(0.0)
|
||||
assert index_to_pixel(4, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_index_to_pixel_single_point_centers():
|
||||
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared_settings():
|
||||
return LocalSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model(qapp, tmp_path, shared_settings):
|
||||
return LocalSettingsModel(shared_settings, tmp_path)
|
||||
|
||||
|
||||
def test_save_mutates_the_shared_instance_in_place(model, shared_settings):
|
||||
# This is the bug candidate 1 fixes: DeviceController holds the same
|
||||
# LocalSettings object, so a save here must be visible to it without
|
||||
# a reload — no throwaway copy, no stale read until restart.
|
||||
model.chamberId = "7"
|
||||
model.waferReadTimeout = 45000
|
||||
model.saveSettings()
|
||||
|
||||
assert shared_settings.chamber_id == "7"
|
||||
assert shared_settings.wafer_read_timeout == 45000
|
||||
|
||||
|
||||
def test_save_persists_to_disk_and_reload_round_trips(tmp_path, qapp, shared_settings):
|
||||
model = LocalSettingsModel(shared_settings, tmp_path)
|
||||
model.chamberId = "9"
|
||||
model.waferRetries = 3
|
||||
model.saveSettings()
|
||||
|
||||
reloaded_settings = LocalSettings.read_settings(str(tmp_path))
|
||||
other_model = LocalSettingsModel(reloaded_settings, tmp_path)
|
||||
other_model.loadSettings()
|
||||
|
||||
assert other_model.chamberId == "9"
|
||||
assert other_model.waferRetries == 3
|
||||
|
||||
|
||||
def test_revert_restores_last_saved_values(model):
|
||||
model.chamberId = "2"
|
||||
model.saveSettings()
|
||||
|
||||
model.chamberId = "unsaved-edit"
|
||||
assert model.isDirty is True
|
||||
|
||||
model.revertChanges()
|
||||
assert model.chamberId == "2"
|
||||
assert model.isDirty is False
|
||||
|
||||
|
||||
def test_invalid_chamber_id_blocks_save(model, shared_settings):
|
||||
shared_settings.chamber_id = "before"
|
||||
model.chamberId = ""
|
||||
model.saveSettings()
|
||||
|
||||
assert model.saveStatus.startswith("error:")
|
||||
assert shared_settings.chamber_id == "before" # unsaved, shared instance untouched
|
||||
@@ -1,7 +1,10 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from pygui.backend.controllers.session_controller import SessionController
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller():
|
||||
return SessionController()
|
||||
@@ -9,7 +12,7 @@ def controller():
|
||||
def test_initial_default(controller):
|
||||
assert controller.mode == "review"
|
||||
assert controller.state == "idle"
|
||||
assert controller.recording == False
|
||||
assert not controller.recording
|
||||
|
||||
def test_playback_flow(controller):
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
@@ -119,10 +122,10 @@ Item {
|
||||
def test_recording_toggle(controller, tmp_path):
|
||||
csv_path = str(tmp_path / "rec" / "live_test.csv")
|
||||
controller.startRecording(csv_path, "SN123")
|
||||
assert controller.recording == True
|
||||
assert controller.recording
|
||||
|
||||
controller.stopRecording()
|
||||
assert controller.recording == False
|
||||
assert not controller.recording
|
||||
|
||||
# Header written with wafer serial
|
||||
content = open(csv_path).read()
|
||||
|
||||
Reference in New Issue
Block a user