diff --git a/.gitignore b/.gitignore index 65151d9..df65550 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 481189e..7002b8f 100644 --- a/README.md +++ b/README.md @@ -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,19 +64,21 @@ 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 -The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests. +The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests. 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 @@ -186,18 +196,21 @@ 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. +- **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. ### 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) diff --git a/src/pygui/backend/__init__.py b/src/pygui/backend/__init__.py index 7263803..e69de29 100644 --- a/src/pygui/backend/__init__.py +++ b/src/pygui/backend/__init__.py @@ -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 diff --git a/src/pygui/backend/controllers/__init__.py b/src/pygui/backend/controllers/__init__.py index 751a7ad..e69de29 100644 --- a/src/pygui/backend/controllers/__init__.py +++ b/src/pygui/backend/controllers/__init__.py @@ -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"] diff --git a/src/pygui/backend/controllers/session_controller.py b/src/pygui/backend/controllers/session_controller.py index b18b90b..e9c14b1 100644 --- a/src/pygui/backend/controllers/session_controller.py +++ b/src/pygui/backend/controllers/session_controller.py @@ -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) @@ -475,7 +456,7 @@ class SessionController(QObject): """Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment.""" if not self._compare_recs_a or not self._compare_recs_b: return [] - + idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1) if idx_a not in self._compare_alignment_map: # DTW path only covers indices up to the shorter run's length — @@ -485,7 +466,7 @@ class SessionController(QObject): values_a = self._compare_recs_a[idx_a].values values_b = self._compare_recs_b[idx_b].values - + n = min(len(values_a), len(values_b)) return [round(values_b[i] - values_a[i], 3) for i in range(n)] diff --git a/src/pygui/backend/crypto/__init__.py b/src/pygui/backend/crypto/__init__.py index 30cbc74..e69de29 100644 --- a/src/pygui/backend/crypto/__init__.py +++ b/src/pygui/backend/crypto/__init__.py @@ -1,2 +0,0 @@ -# ===== Crypto Sub-package ===== -from pygui.backend.crypto.crypto_helper import * # noqa: F403 diff --git a/src/pygui/backend/data/__init__.py b/src/pygui/backend/data/__init__.py index cb7dcd1..e69de29 100644 --- a/src/pygui/backend/data/__init__.py +++ b/src/pygui/backend/data/__init__.py @@ -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", -] diff --git a/src/pygui/backend/data/constants.py b/src/pygui/backend/data/constants.py index 3d28cfd..024bc37 100644 --- a/src/pygui/backend/data/constants.py +++ b/src/pygui/backend/data/constants.py @@ -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 diff --git a/src/pygui/backend/data/csv_file_metadata.py b/src/pygui/backend/data/csv_file_metadata.py index 6d48963..92450e0 100644 --- a/src/pygui/backend/data/csv_file_metadata.py +++ b/src/pygui/backend/data/csv_file_metadata.py @@ -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) diff --git a/src/pygui/backend/data/csv_recorder.py b/src/pygui/backend/data/csv_recorder.py index 51784c9..ab9fee1 100644 --- a/src/pygui/backend/data/csv_recorder.py +++ b/src/pygui/backend/data/csv_recorder.py @@ -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 + 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") @@ -32,7 +31,7 @@ class CsvRecorder: file_handle.flush() self._file_handle, self._path = file_handle, path - def write(self, frame: Frame) -> None: + def write(self, frame: Frame) -> None: if self._file_handle is None: return row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values] @@ -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 - diff --git a/src/pygui/backend/data/data_records.py b/src/pygui/backend/data/data_records.py index 69ce2a0..332cfeb 100644 --- a/src/pygui/backend/data/data_records.py +++ b/src/pygui/backend/data/data_records.py @@ -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 + nums = [float(c) for c in cols] records.append(DataRecord(time=nums[0], values=nums[1:])) return records diff --git a/src/pygui/backend/data/file_browser.py b/src/pygui/backend/data/file_browser.py index ecb2cb5..c38dbb7 100644 --- a/src/pygui/backend/data/file_browser.py +++ b/src/pygui/backend/data/file_browser.py @@ -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) diff --git a/src/pygui/backend/data/local_settings.py b/src/pygui/backend/data/local_settings.py index b7e35ec..d676fd8 100644 --- a/src/pygui/backend/data/local_settings.py +++ b/src/pygui/backend/data/local_settings.py @@ -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: diff --git a/src/pygui/backend/data/local_settings_model.py b/src/pygui/backend/data/local_settings_model.py index 50a035b..fceff60 100644 --- a/src/pygui/backend/data/local_settings_model.py +++ b/src/pygui/backend/data/local_settings_model.py @@ -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() diff --git a/src/pygui/backend/license/__init__.py b/src/pygui/backend/license/__init__.py index 7eea9a0..e69de29 100644 --- a/src/pygui/backend/license/__init__.py +++ b/src/pygui/backend/license/__init__.py @@ -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", -] diff --git a/src/pygui/backend/license/license_model.py b/src/pygui/backend/license/license_model.py index c659b48..c2fd12e 100644 --- a/src/pygui/backend/license/license_model.py +++ b/src/pygui/backend/license/license_model.py @@ -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 [ diff --git a/src/pygui/backend/models/__init__.py b/src/pygui/backend/models/__init__.py index 6a7ae40..e69de29 100644 --- a/src/pygui/backend/models/__init__.py +++ b/src/pygui/backend/models/__init__.py @@ -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", -] diff --git a/src/pygui/backend/models/data_model.py b/src/pygui/backend/models/data_model.py index 6e3cf07..d4ef852 100644 --- a/src/pygui/backend/models/data_model.py +++ b/src/pygui/backend/models/data_model.py @@ -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 diff --git a/src/pygui/backend/models/frame_player.py b/src/pygui/backend/models/frame_player.py index acb7752..8a86be0 100644 --- a/src/pygui/backend/models/frame_player.py +++ b/src/pygui/backend/models/frame_player.py @@ -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)] diff --git a/src/pygui/backend/visualization/__init__.py b/src/pygui/backend/visualization/__init__.py index 722f33d..e69de29 100644 --- a/src/pygui/backend/visualization/__init__.py +++ b/src/pygui/backend/visualization/__init__.py @@ -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", -] diff --git a/src/pygui/backend/visualization/chart_geometry.py b/src/pygui/backend/visualization/chart_geometry.py new file mode 100644 index 0000000..c7ee339 --- /dev/null +++ b/src/pygui/backend/visualization/chart_geometry.py @@ -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 diff --git a/src/pygui/backend/visualization/graph_quick_item.py b/src/pygui/backend/visualization/graph_quick_item.py index 993286b..67db9bf 100644 --- a/src/pygui/backend/visualization/graph_quick_item.py +++ b/src/pygui/backend/visualization/graph_quick_item.py @@ -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: diff --git a/src/pygui/backend/visualization/graph_view.py b/src/pygui/backend/visualization/graph_view.py index a09aa30..9de3945 100644 --- a/src/pygui/backend/visualization/graph_view.py +++ b/src/pygui/backend/visualization/graph_view.py @@ -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") diff --git a/src/pygui/backend/visualization/rbf_heatmap.py b/src/pygui/backend/visualization/rbf_heatmap.py index da59ead..98147ac 100644 --- a/src/pygui/backend/visualization/rbf_heatmap.py +++ b/src/pygui/backend/visualization/rbf_heatmap.py @@ -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: diff --git a/src/pygui/backend/visualization/trend_chart_item.py b/src/pygui/backend/visualization/trend_chart_item.py index 0d5e5a6..d164538 100644 --- a/src/pygui/backend/visualization/trend_chart_item.py +++ b/src/pygui/backend/visualization/trend_chart_item.py @@ -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) diff --git a/src/pygui/backend/visualization/wafer_map_item.py b/src/pygui/backend/visualization/wafer_map_item.py index b47074d..f624a3e 100644 --- a/src/pygui/backend/visualization/wafer_map_item.py +++ b/src/pygui/backend/visualization/wafer_map_item.py @@ -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] diff --git a/src/pygui/backend/wafer/__init__.py b/src/pygui/backend/wafer/__init__.py index 0c098d5..e69de29 100644 --- a/src/pygui/backend/wafer/__init__.py +++ b/src/pygui/backend/wafer/__init__.py @@ -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", -] diff --git a/src/pygui/backend/wafer/wafer_layouts.py b/src/pygui/backend/wafer/wafer_layouts.py index e6eb272..fd1848d 100644 --- a/src/pygui/backend/wafer/wafer_layouts.py +++ b/src/pygui/backend/wafer/wafer_layouts.py @@ -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) diff --git a/src/pygui/backend/wafer/zwafer_parser.py b/src/pygui/backend/wafer/zwafer_parser.py index 99d6d4b..d879a80 100644 --- a/src/pygui/backend/wafer/zwafer_parser.py +++ b/src/pygui/backend/wafer/zwafer_parser.py @@ -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): diff --git a/src/pygui/serialcomm/data_parser.py b/src/pygui/serialcomm/data_parser.py index 3d37e79..bd6bd5e 100644 --- a/src/pygui/serialcomm/data_parser.py +++ b/src/pygui/serialcomm/data_parser.py @@ -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" diff --git a/src/pygui/serialcomm/stream_reader.py b/src/pygui/serialcomm/stream_reader.py index 198351d..280e674 100644 --- a/src/pygui/serialcomm/stream_reader.py +++ b/src/pygui/serialcomm/stream_reader.py @@ -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 diff --git a/tests/test_chart_geometry.py b/tests/test_chart_geometry.py new file mode 100644 index 0000000..ed77d7c --- /dev/null +++ b/tests/test_chart_geometry.py @@ -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) diff --git a/tests/test_local_settings_model.py b/tests/test_local_settings_model.py new file mode 100644 index 0000000..324a02a --- /dev/null +++ b/tests/test_local_settings_model.py @@ -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 diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index 4f24b31..9ce3471 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -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()