refactor(backend): clean up package re-exports, settings model properties, and unused signals

This commit is contained in:
jack
2026-07-09 12:49:33 -07:00
parent ca158a7946
commit ac4448ed44
34 changed files with 372 additions and 337 deletions
+18
View File
@@ -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/
+16 -2
View File
@@ -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
![ISenseCloud UI displaying successful connection on COM6](image-1.png)
*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`**.
![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png)
#### 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`):
![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png)
#### 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).
![ISenseCloud UI displaying successful connection on COM6](image-1.png)
-26
View File
@@ -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)
-2
View File
@@ -1,2 +0,0 @@
# ===== Crypto Sub-package =====
from pygui.backend.crypto.crypto_helper import * # noqa: F403
-14
View File
@@ -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",
]
+11
View File
@@ -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
+2 -2
View File
@@ -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)
+6 -18
View File
@@ -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
+17 -5
View File
@@ -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 -9
View File
@@ -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)
+2 -5
View File
@@ -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:
+78 -82
View File
@@ -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()
-15
View File
@@ -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",
]
+1 -1
View File
@@ -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 [
-19
View File
@@ -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",
]
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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]
-12
View File
@@ -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",
]
+2 -1
View File
@@ -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)
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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"
+5 -5
View File
@@ -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
+28
View File
@@ -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)
+61
View File
@@ -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
+7 -4
View File
@@ -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()