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 *.spec
*.icns *.icns
*.ico *.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/
+18 -4
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. - **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
### App Showcase ### App Showcase
![ISenseCloud UI displaying successful connection on COM6](image-1.png) ![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.* *Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
> [!NOTE] > [!NOTE]
> **Take Screenshots for Visual Placeholders:** > **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. > - **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. > - **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: From the project root:
### Option A: Using `uv` (Recommended) ### Option A: Using `uv` (Recommended)
If you have [uv](https://docs.astral.sh/uv/) installed: If you have [uv](https://docs.astral.sh/uv/) installed:
```bash ```bash
uv sync uv sync
``` ```
### Option B: Using `pip` ### Option B: Using `pip`
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows 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 ## Run
### Launch PySide6 QML App ### Launch PySide6 QML App
```bash ```bash
make run make run
``` ```
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`. This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
## Development ## 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: A `Makefile` is provided to simplify common development and verification tasks:
| Command | Description | | Command | Description |
|---------|-------------| | --------- | ------------- |
| `make install` | Sync dependencies and set up the local `.venv` using `uv` | | `make install` | Sync dependencies and set up the local `.venv` using `uv` |
| `make run` | Launch the ISenseCloud application | | `make run` | Launch the ISenseCloud application |
| `make test` | Run the `pytest` test suite | | `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 Configuration
Ruff is configured in `pyproject.toml` to enforce: Ruff is configured in `pyproject.toml` to enforce:
- **E/W**: Pycodestyle errors and warnings - **E/W**: Pycodestyle errors and warnings
- **F**: Pyflakes linter rules - **F**: Pyflakes linter rules
- **I**: Import sorting (isort parity) - **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: - If the app does not start, verify the virtual environment is active and dependencies are installed:
*Using `uv`:* *Using `uv`:*
```bash ```bash
uv sync uv sync
``` ```
*Using `pip`:* *Using `pip`:*
```bash ```bash
source .venv/bin/activate # or .venv\Scripts\activate on Windows source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt 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. - If no window appears, ensure you are running in a desktop session with GUI access.
- **Windows COM Port Connection Issues:** - **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-by-Step Windows Simulator Connection Guide
#### Step 1: Create the Virtual Serial Port Bridge #### 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`**. 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) ![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png)
#### Step 2: Configure and Start the Wafer Simulator #### Step 2: Configure and Start the Wafer Simulator
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`): Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
1. Set the **Serial Port** to **`COM5`**. 1. Set the **Serial Port** to **`COM5`**.
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox. 2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`). 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) ![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png)
#### Step 3: Run the UI and Connect #### 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). 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) ![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.frame_player import FramePlayer, frames_from_wafer_data
from pygui.backend.models.replay_stats import ReplayStatsTracker from pygui.backend.models.replay_stats import ReplayStatsTracker
from pygui.backend.models.sensor_editor import SensorEditor 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.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
@@ -37,7 +37,6 @@ class SessionController(QObject):
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str) loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph # trend: per-frame avg for live graph
trendData = Signal(str) # JSON array of floats trendData = Signal(str) # JSON array of floats
@@ -50,8 +49,7 @@ class SessionController(QObject):
_liveFrame = Signal(object) # Frame _liveFrame = Signal(object) # Frame
_liveError = Signal() # worker-thread error ping _liveError = Signal() # worker-thread error ping
def __init__(self, parent: QObject | None = None, def __init__(self, parent: QObject | None = None) -> None:
settings: dict | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._mode = MODE_REVIEW self._mode = MODE_REVIEW
self._model = SessionModel() self._model = SessionModel()
@@ -59,7 +57,7 @@ class SessionController(QObject):
self._reader: Optional[StreamReader] = None self._reader: Optional[StreamReader] = None
self._recorder = CsvRecorder() self._recorder = CsvRecorder()
self._sensors: list[Sensor] = [] self._sensors: list[Sensor] = []
self._last = None # last SessionUpdate self._last: Optional[SessionUpdate] = None
self._elapsed = 0.0 self._elapsed = 0.0
self._trend_buffer: list[float] = [] # rolling window self._trend_buffer: list[float] = [] # rolling window
self._trend_timestamps: list[float] = [] # monotonic timestamps self._trend_timestamps: list[float] = [] # monotonic timestamps
@@ -96,21 +94,6 @@ class SessionController(QObject):
self._compare_recs_b: list = [] self._compare_recs_b: list = []
self._compare_alignment_map: dict[int, int] = {} 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 ---- # ---- properties QML binds to ----
@Property(str, notify=modeChanged) @Property(str, notify=modeChanged)
def mode(self) -> str: return self._mode def mode(self) -> str: return self._mode
@@ -150,7 +133,7 @@ class SessionController(QObject):
"value": round(v, 2), "band": band, "index": i}) "value": round(v, 2), "band": band, "index": i})
return out return out
@Property("QVariantList", notify=sensorsChanged) @Property("QVariantList", notify=sensorsChanged) # type: ignore[arg-type]
def sensorLayout(self) -> list: def sensorLayout(self) -> list:
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors.""" """[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
return [ return [
@@ -175,14 +158,14 @@ class SessionController(QObject):
"""Wafer size in mm.""" """Wafer size in mm."""
return getattr(self._sensors, "size", 300.0) return getattr(self._sensors, "size", 300.0)
@Property("QVariantList", notify=frameUpdated) @Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
def sensorValues(self) -> list: def sensorValues(self) -> list:
"""[float] in sensor order.""" """[float] in sensor order."""
if not self._last: if not self._last:
return [] return []
return [round(v, 3) for v in self._last.values] 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: def sensorBands(self) -> list:
"""['in_range'|'high'|'low'] in sensor order.""" """['in_range'|'high'|'low'] in sensor order."""
if not self._last: if not self._last:
@@ -212,7 +195,7 @@ class SessionController(QObject):
@Property(str, notify=loadedFileChanged) @Property(str, notify=loadedFileChanged)
def loadedFile(self) -> str: return self._loaded_file 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]: def overriddenSensors(self) -> list[int]:
"""Indices of sensors that currently have a replacement or offset.""" """Indices of sensors that currently have a replacement or offset."""
return self._sensor_editor.active_indices() return self._sensor_editor.active_indices()
@@ -221,7 +204,7 @@ class SessionController(QObject):
def clusterAveragingEnabled(self) -> bool: def clusterAveragingEnabled(self) -> bool:
return self._cluster_averaging_enabled return self._cluster_averaging_enabled
@clusterAveragingEnabled.setter @clusterAveragingEnabled.setter # type: ignore[no-redef]
def clusterAveragingEnabled(self, value: bool) -> None: def clusterAveragingEnabled(self, value: bool) -> None:
if self._cluster_averaging_enabled == value: if self._cluster_averaging_enabled == value:
return return
@@ -319,7 +302,6 @@ class SessionController(QObject):
for frame in self._player._frames: for frame in self._player._frames:
self._stats_tracker.track(frame.seq, frame.values) self._stats_tracker.track(frame.seq, frame.values)
self.trendData.emit(json.dumps(self._stats_tracker.get_trend())) self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit()
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
@@ -331,7 +313,6 @@ class SessionController(QObject):
self._loaded_file = "" self._loaded_file = ""
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self.settingsChanged.emit()
# ---- comparison: DTW between two CSV files ---- # ---- comparison: DTW between two CSV files ----
@Slot(str, str) @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.""" """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: if not self._compare_recs_a or not self._compare_recs_b:
return [] return []
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1) idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
if idx_a not in self._compare_alignment_map: if idx_a not in self._compare_alignment_map:
# DTW path only covers indices up to the shorter run's length — # 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_a = self._compare_recs_a[idx_a].values
values_b = self._compare_recs_b[idx_b].values values_b = self._compare_recs_b[idx_b].values
n = min(len(values_a), len(values_b)) n = min(len(values_a), len(values_b))
return [round(values_b[i] - values_a[i], 3) for i in range(n)] return [round(values_b[i] - values_a[i], 3) for i in range(n)]
-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 ===== # ===== Backend Data Constants =====
from pathlib import Path
from PySide6.QtCore import QStandardPaths
DEFAULT_DATA_DIR_NAME = "isc_data" 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 the first character of the wafer string, or empty if none"""
return self.wafer[0] if self.wafer else "" return self.wafer[0] if self.wafer else ""
def get_date(self) -> datetime: def get_date(self) -> datetime | None:
"""Parsifes the date string. Returns current time if parsing fails""" """Parsifes the date string. Returns None if parsing fails"""
date_format = "%Y-%m-%d %H:%M:%S" date_format = "%Y-%m-%d %H:%M:%S"
try: try:
return datetime.strptime(self.date, date_format) return datetime.strptime(self.date, date_format)
+8 -20
View File
@@ -1,4 +1,5 @@
"""Append live frames""" """Append live frames"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
@@ -12,17 +13,15 @@ from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder: class CsvRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None self._path: Optional[str] = None
@property @property
def is_recording(self) -> bool: 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) 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"Wafer ID={serial}\n")
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\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") file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
@@ -32,7 +31,7 @@ class CsvRecorder:
file_handle.flush() file_handle.flush()
self._file_handle, self._path = file_handle, path 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: if self._file_handle is None:
return return
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values] row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
@@ -65,21 +64,11 @@ class CsvRecorder:
Returns: Returns:
True on success (at least one frame written). 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: if start_frame < 0 or end_frame < start_frame:
return False 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: with open(source_path, "r", encoding="utf-8") as f:
lines = f.readlines() lines = f.readlines()
@@ -99,7 +88,7 @@ class CsvRecorder:
# official format: row 0 is the sensor-name header # official format: row 0 is the sensor-name header
out.append(line) out.append(line)
continue continue
if not stripped or not _is_data_row(stripped): if not stripped or not is_data_row(stripped):
continue continue
frame_idx += 1 frame_idx += 1
if start_frame <= frame_idx <= end_frame: if start_frame <= frame_idx <= end_frame:
@@ -112,4 +101,3 @@ class CsvRecorder:
with open(file_path, "w", encoding="utf-8", newline="") as f: with open(file_path, "w", encoding="utf-8", newline="") as f:
f.writelines(out) f.writelines(out)
return True return True
+18 -6
View File
@@ -1,4 +1,5 @@
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV.""" """Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@@ -6,6 +7,20 @@ from pathlib import Path
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor 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]: def read_data_records(file_path: str) -> list[DataRecord]:
records: list[DataRecord] = [] records: list[DataRecord] = []
in_data = False in_data = False
@@ -18,13 +33,10 @@ def read_data_records(file_path: str) -> list[DataRecord]:
if line.split(",")[0].lower() == "data": if line.split(",")[0].lower() == "data":
in_data = True in_data = True
continue continue
if not is_data_row(line):
continue
cols = [c.strip() for c in line.split(",") if c.strip() != ""] cols = [c.strip() for c in line.split(",") if c.strip() != ""]
try: nums = [float(c) for c in cols]
nums = [float(c) for c in cols]
except ValueError:
continue
if not nums:
continue
records.append(DataRecord(time=nums[0], values=nums[1:])) records.append(DataRecord(time=nums[0], values=nums[1:]))
return records return records
+5 -9
View File
@@ -5,10 +5,10 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path 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 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.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -28,7 +28,7 @@ class FileBrowser(QObject):
self._refresh_files(show_empty_message=False) self._refresh_files(show_empty_message=False)
# ===== Exposed Properties ===== # ===== Exposed Properties =====
@Property("QVariantList", notify=filesChanged) @Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
def files(self) -> list[dict[str, object]]: def files(self) -> list[dict[str, object]]:
return [dict(row) for row in self._files] return [dict(row) for row in self._files]
@@ -42,7 +42,7 @@ class FileBrowser(QObject):
selected_dir = QFileDialog.getExistingDirectory( selected_dir = QFileDialog.getExistingDirectory(
None, None,
"Select CSV Folder", "Select CSV Folder",
self.currentDirectory, str(self._current_directory),
) )
if not selected_dir: if not selected_dir:
return return
@@ -235,11 +235,7 @@ class FileBrowser(QObject):
# ===== Internal Helpers ===== # ===== Internal Helpers =====
def _resolve_default_directory(self) -> Path: def _resolve_default_directory(self) -> Path:
documents_dir = QStandardPaths.writableLocation( return default_data_dir()
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
def _set_current_directory(self, directory: Path) -> None: def _set_current_directory(self, directory: Path) -> None:
normalized = Path(directory) normalized = Path(directory)
+2 -5
View File
@@ -13,8 +13,8 @@ class LocalSettings:
# Configuration settings # Configuration settings
self.chamber_id = "" self.chamber_id = ""
self.reverse_z_wafer = False self.reverse_z_wafer = False
self.master = {} # Dict[str, str] self.master: dict[str, str] = {}
self.wafer_data_size = {} # Dict[str, int] self.wafer_data_size: dict[str, int] = {}
self.debug = False self.debug = False
self.wafer_read_retries = 8 self.wafer_read_retries = 8
# Timeout in msec for reading from the wafer (default 2 min) # Timeout in msec for reading from the wafer (default 2 min)
@@ -32,9 +32,6 @@ class LocalSettings:
self.data_col_count = 0 self.data_col_count = 0
self.last_csv_path = "" self.last_csv_path = ""
# Session persistence
self.session_last_file = ""
# ===== File Path Helpers ===== # ===== File Path Helpers =====
@classmethod @classmethod
def _settings_path(cls, directory: str) -> Path: def _settings_path(cls, directory: str) -> Path:
+78 -82
View File
@@ -2,17 +2,26 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from pathlib import Path 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 from pygui.backend.data.local_settings import LocalSettings
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z") MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
class LocalSettingsModel(QObject): 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() chamberIdChanged = Signal()
reverseZWaferChanged = Signal() reverseZWaferChanged = Signal()
debugModeChanged = Signal() debugModeChanged = Signal()
@@ -26,18 +35,35 @@ class LocalSettingsModel(QObject):
saveStatusChanged = Signal() saveStatusChanged = Signal()
lastSavedAtChanged = 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) 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._defaults = self._new_defaults()
self._chamber_id = self._defaults["chamberId"] for draft_attr, default_value in self._defaults.items():
self._reverse_z_wafer = self._defaults["reverseZWafer"] setattr(self, draft_attr, deepcopy(default_value))
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"])
self._is_dirty = False self._is_dirty = False
self._is_valid = True self._is_valid = True
@@ -47,34 +73,21 @@ class LocalSettingsModel(QObject):
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._recompute_derived() 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]: def _new_defaults(self) -> dict[str, Any]:
return { return {
"chamberId": "2", "_chamber_id": "2",
"reverseZWafer": False, "_reverse_z_wafer": False,
"debugMode": False, "_debug_mode": False,
"waferReadTimeout": 120000, "_wafer_read_timeout": 120000,
"waferDetectTimeout": 5000, "_wafer_detect_timeout": 5000,
"waferRetries": 8, "_wafer_retries": 8,
"masters": {family: "" for family in MASTER_FAMILIES}, "_masters": {family: "" for family in MASTER_FAMILIES},
} }
def _snapshot(self) -> dict[str, Any]: def _snapshot(self) -> dict[str, Any]:
return { snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
"chamberId": self._chamber_id, snap["_masters"] = deepcopy(self._masters)
"reverseZWafer": self._reverse_z_wafer, return snap
"debugMode": self._debug_mode,
"waferReadTimeout": self._wafer_read_timeout,
"waferDetectTimeout": self._wafer_detect_timeout,
"waferRetries": self._wafer_retries,
"masters": deepcopy(self._masters),
}
def _set_is_dirty(self, value: bool) -> None: def _set_is_dirty(self, value: bool) -> None:
if self._is_dirty != value: if self._is_dirty != value:
@@ -137,22 +150,19 @@ class LocalSettingsModel(QObject):
self.waferRetriesChanged.emit() self.waferRetriesChanged.emit()
self.mastersChanged.emit() self.mastersChanged.emit()
def _to_local_settings(self) -> LocalSettings: def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
settings = LocalSettings() masters = {family: "" for family in MASTER_FAMILIES}
settings.chamber_id = self._chamber_id for family, value in source.items():
settings.reverse_z_wafer = self._reverse_z_wafer normalized = str(family).strip().upper()
settings.debug = self._debug_mode if normalized in masters:
settings.wafer_read_timeout = self._wafer_read_timeout masters[normalized] = str(value).strip()
settings.wafer_detect_timeout = self._wafer_detect_timeout return masters
settings.wafer_read_retries = self._wafer_retries
settings.master = deepcopy(self._masters)
return settings
@Property(str, notify=chamberIdChanged) @Property(str, notify=chamberIdChanged)
def chamberId(self) -> str: def chamberId(self) -> str:
return self._chamber_id return self._chamber_id
@chamberId.setter @chamberId.setter # type: ignore[no-redef]
def chamberId(self, value: str) -> None: def chamberId(self, value: str) -> None:
next_value = str(value).strip() next_value = str(value).strip()
if self._chamber_id == next_value: if self._chamber_id == next_value:
@@ -165,7 +175,7 @@ class LocalSettingsModel(QObject):
def reverseZWafer(self) -> bool: def reverseZWafer(self) -> bool:
return self._reverse_z_wafer return self._reverse_z_wafer
@reverseZWafer.setter @reverseZWafer.setter # type: ignore[no-redef]
def reverseZWafer(self, value: bool) -> None: def reverseZWafer(self, value: bool) -> None:
if self._reverse_z_wafer == value: if self._reverse_z_wafer == value:
return return
@@ -177,7 +187,7 @@ class LocalSettingsModel(QObject):
def debugMode(self) -> bool: def debugMode(self) -> bool:
return self._debug_mode return self._debug_mode
@debugMode.setter @debugMode.setter # type: ignore[no-redef]
def debugMode(self, value: bool) -> None: def debugMode(self, value: bool) -> None:
if self._debug_mode == value: if self._debug_mode == value:
return return
@@ -189,7 +199,7 @@ class LocalSettingsModel(QObject):
def waferReadTimeout(self) -> int: def waferReadTimeout(self) -> int:
return self._wafer_read_timeout return self._wafer_read_timeout
@waferReadTimeout.setter @waferReadTimeout.setter # type: ignore[no-redef]
def waferReadTimeout(self, value: int) -> None: def waferReadTimeout(self, value: int) -> None:
if self._wafer_read_timeout == value: if self._wafer_read_timeout == value:
return return
@@ -201,7 +211,7 @@ class LocalSettingsModel(QObject):
def waferDetectTimeout(self) -> int: def waferDetectTimeout(self) -> int:
return self._wafer_detect_timeout return self._wafer_detect_timeout
@waferDetectTimeout.setter @waferDetectTimeout.setter # type: ignore[no-redef]
def waferDetectTimeout(self, value: int) -> None: def waferDetectTimeout(self, value: int) -> None:
if self._wafer_detect_timeout == value: if self._wafer_detect_timeout == value:
return return
@@ -213,7 +223,7 @@ class LocalSettingsModel(QObject):
def waferRetries(self) -> int: def waferRetries(self) -> int:
return self._wafer_retries return self._wafer_retries
@waferRetries.setter @waferRetries.setter # type: ignore[no-redef]
def waferRetries(self, value: int) -> None: def waferRetries(self, value: int) -> None:
if self._wafer_retries == value: if self._wafer_retries == value:
return return
@@ -251,22 +261,11 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def loadSettings(self) -> None: def loadSettings(self) -> None:
loaded = LocalSettings.read_settings(str(self._data_dir)) """Seed draft fields from the shared LocalSettings instance."""
self._chamber_id = str(loaded.chamber_id).strip() or str( for draft_attr, settings_attr, coerce in self._FIELD_MAP:
self._defaults["chamberId"] setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
) self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
self._reverse_z_wafer = bool(loaded.reverse_z_wafer) self._masters = self._normalized_masters(self._settings.master)
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
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._emit_all_changed() self._emit_all_changed()
@@ -275,13 +274,18 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def saveSettings(self) -> None: def saveSettings(self) -> None:
"""Write draft fields onto the shared LocalSettings instance and persist it."""
self._recompute_derived() self._recompute_derived()
if not self._is_valid: if not self._is_valid:
self._set_save_status("error: invalid settings") self._set_save_status("error: invalid settings")
return return
try: 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._saved_snapshot = self._snapshot()
self._recompute_derived() self._recompute_derived()
self._set_save_status("Saved") self._set_save_status("Saved")
@@ -293,13 +297,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def revertChanges(self) -> None: def revertChanges(self) -> None:
snap = self._saved_snapshot snap = self._saved_snapshot
self._chamber_id = str(snap["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(snap["reverseZWafer"]) setattr(self, draft_attr, coerce(snap[draft_attr]))
self._debug_mode = bool(snap["debugMode"]) self._masters = deepcopy(snap["_masters"])
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"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() self._recompute_derived()
@@ -307,13 +307,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def resetDefaults(self) -> None: def resetDefaults(self) -> None:
self._chamber_id = str(self._defaults["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"]) setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
self._debug_mode = bool(self._defaults["debugMode"]) self._masters = deepcopy(self._defaults["_masters"])
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"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() 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() self.refresh()
# ── Grid rows ───────────────────────────────────────────────────── # ── Grid rows ─────────────────────────────────────────────────────
@Property("QVariantList", notify=licensesChanged) @Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
def licenses(self) -> list: def licenses(self) -> list:
"""Rows for the About grid: serial, mfg date, level, license date.""" """Rows for the About grid: serial, mfg date, level, license date."""
return [ 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 # Column 0 = row index, columns 1..N = sensor values
return self._col_count + 1 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(): if not index.isValid():
return None return None
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
return None return None
def headerData( def headerData(
self, section: int, orientation: int, role: int = ... self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
) -> Any: ) -> Any:
if role != Qt.ItemDataRole.DisplayRole: if role != Qt.ItemDataRole.DisplayRole:
return None 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 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].)""" """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)] 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.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem 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_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
@@ -78,7 +80,7 @@ class GraphQuickItem(QQuickPaintedItem):
def seriesData(self) -> list: def seriesData(self) -> list:
return self._series_data return self._series_data
@seriesData.setter @seriesData.setter # type: ignore[no-redef]
def seriesData(self, val: list) -> None: def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])] self._series_data = [list(s) for s in (val or [])]
self._auto_range() self._auto_range()
@@ -89,7 +91,7 @@ class GraphQuickItem(QQuickPaintedItem):
def sensorNames(self) -> list: def sensorNames(self) -> list:
return self._sensor_names return self._sensor_names
@sensorNames.setter @sensorNames.setter # type: ignore[no-redef]
def sensorNames(self, val: list) -> None: def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or []) self._sensor_names = list(val or [])
self.sensorNamesChanged.emit() self.sensorNamesChanged.emit()
@@ -99,7 +101,7 @@ class GraphQuickItem(QQuickPaintedItem):
def title(self) -> str: def title(self) -> str:
return self._title return self._title
@title.setter @title.setter # type: ignore[no-redef]
def title(self, val: str) -> None: def title(self, val: str) -> None:
self._title = str(val or "") self._title = str(val or "")
self.titleChanged.emit() self.titleChanged.emit()
@@ -109,7 +111,7 @@ class GraphQuickItem(QQuickPaintedItem):
def xLabel(self) -> str: def xLabel(self) -> str:
return self._x_label return self._x_label
@xLabel.setter @xLabel.setter # type: ignore[no-redef]
def xLabel(self, val: str) -> None: def xLabel(self, val: str) -> None:
self._x_label = str(val or "") self._x_label = str(val or "")
self.xLabelChanged.emit() self.xLabelChanged.emit()
@@ -119,7 +121,7 @@ class GraphQuickItem(QQuickPaintedItem):
def yLabel(self) -> str: def yLabel(self) -> str:
return self._y_label return self._y_label
@yLabel.setter @yLabel.setter # type: ignore[no-redef]
def yLabel(self, val: str) -> None: def yLabel(self, val: str) -> None:
self._y_label = str(val or "") self._y_label = str(val or "")
self.yLabelChanged.emit() self.yLabelChanged.emit()
@@ -129,7 +131,7 @@ class GraphQuickItem(QQuickPaintedItem):
def showLegend(self) -> bool: def showLegend(self) -> bool:
return self._show_legend return self._show_legend
@showLegend.setter @showLegend.setter # type: ignore[no-redef]
def showLegend(self, val: bool) -> None: def showLegend(self, val: bool) -> None:
self._show_legend = bool(val) self._show_legend = bool(val)
self.colorsChanged.emit() self.colorsChanged.emit()
@@ -141,7 +143,7 @@ class GraphQuickItem(QQuickPaintedItem):
def backgroundColor(self) -> QColor: def backgroundColor(self) -> QColor:
return self._bg_color return self._bg_color
@backgroundColor.setter @backgroundColor.setter # type: ignore[no-redef]
def backgroundColor(self, c: QColor) -> None: def backgroundColor(self, c: QColor) -> None:
self._bg_color = c self._bg_color = c
self.update() self.update()
@@ -150,7 +152,7 @@ class GraphQuickItem(QQuickPaintedItem):
def gridColor(self) -> QColor: def gridColor(self) -> QColor:
return self._grid_color return self._grid_color
@gridColor.setter @gridColor.setter # type: ignore[no-redef]
def gridColor(self, c: QColor) -> None: def gridColor(self, c: QColor) -> None:
self._grid_color = c self._grid_color = c
self.update() self.update()
@@ -159,7 +161,7 @@ class GraphQuickItem(QQuickPaintedItem):
def axisColor(self) -> QColor: def axisColor(self) -> QColor:
return self._axis_color return self._axis_color
@axisColor.setter @axisColor.setter # type: ignore[no-redef]
def axisColor(self, c: QColor) -> None: def axisColor(self, c: QColor) -> None:
self._axis_color = c self._axis_color = c
self.update() self.update()
@@ -168,7 +170,7 @@ class GraphQuickItem(QQuickPaintedItem):
def textColor(self) -> QColor: def textColor(self) -> QColor:
return self._text_color return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: def textColor(self, c: QColor) -> None:
self._text_color = c self._text_color = c
self.update() self.update()
@@ -178,7 +180,7 @@ class GraphQuickItem(QQuickPaintedItem):
"""Return hex strings of the current series color palette.""" """Return hex strings of the current series color palette."""
return [c.name() for c in self._series_colors] return [c.name() for c in self._series_colors]
@seriesColors.setter @seriesColors.setter # type: ignore[no-redef]
def seriesColors(self, hex_list: list) -> None: def seriesColors(self, hex_list: list) -> None:
colors = [] colors = []
for h in (hex_list or []): for h in (hex_list or []):
@@ -275,8 +277,7 @@ class GraphQuickItem(QQuickPaintedItem):
for i in range(n_y_ticks): for i in range(n_y_ticks):
y_val = self._min_y + i * y_step 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 = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
y_px = plot_top + y_frac * plot_h
# Grid line # Grid line
painter.setPen(grid_pen) painter.setPen(grid_pen)
@@ -325,8 +326,7 @@ class GraphQuickItem(QQuickPaintedItem):
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1) x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks): for i in range(n_x_ticks):
idx = int(round(i * x_tick_step)) idx = int(round(i * x_tick_step))
x_frac = idx / (num_points - 1) x_px = index_to_pixel(idx, num_points, plot_left, plot_w)
x_px = plot_left + x_frac * plot_w
painter.setPen(axis_pen) painter.setPen(axis_pen)
painter.drawText( painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14), int(x_px - 12), int(plot_top + plot_h + 14),
@@ -358,10 +358,8 @@ class GraphQuickItem(QQuickPaintedItem):
v = float(val) v = float(val)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
x_frac = i / (max_len - 1) if max_len > 1 else 0.5 x_px = index_to_pixel(i, max_len, plot_left, plot_w)
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5 y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
x_px = plot_left + x_frac * plot_w
y_px = plot_top + y_frac * plot_h
pts.append((x_px, y_px)) pts.append((x_px, y_px))
if len(pts) < 2: if len(pts) < 2:
@@ -23,6 +23,9 @@ class GraphView(QObject):
each sensor as a separate line series. each sensor as a separate line series.
""" """
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
_trend_line: Any
# ---- signals ---- # ---- signals ----
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list} dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
@@ -227,7 +230,7 @@ class GraphView(QObject):
index_a, index_b = path[i] index_a, index_b = path[i]
if index_a < len(a) and index_b < len(b): if index_a < len(a) and index_b < len(b):
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1, 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.addItem(line)
self._plot_widget.setTitle("DTW Comparison") self._plot_widget.setTitle("DTW Comparison")
@@ -73,7 +73,8 @@ def interpolate_field(
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2 dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
field = np.where(dist <= 1.0, field, np.nan) 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: 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 resampling across a NaN/zero-filled hole rings at the boundary, and any
mask derived from the coarse grid keeps its staircase when upscaled. 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: 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.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
@@ -60,7 +62,7 @@ class TrendChartItem(QQuickPaintedItem):
def data(self) -> list[float]: def data(self) -> list[float]:
return self._data return self._data
@data.setter @data.setter # type: ignore[no-redef]
def data(self, val) -> None: def data(self, val) -> None:
coerced = self._coerce_floats(val) coerced = self._coerce_floats(val)
if coerced == self._data: if coerced == self._data:
@@ -82,7 +84,7 @@ class TrendChartItem(QQuickPaintedItem):
def lineColor(self) -> QColor: def lineColor(self) -> QColor:
return self._line_color return self._line_color
@lineColor.setter @lineColor.setter # type: ignore[no-redef]
def lineColor(self, c: QColor) -> None: def lineColor(self, c: QColor) -> None:
if c == self._line_color: if c == self._line_color:
return return
@@ -94,7 +96,7 @@ class TrendChartItem(QQuickPaintedItem):
def gridColor(self) -> QColor: def gridColor(self) -> QColor:
return self._grid_color return self._grid_color
@gridColor.setter @gridColor.setter # type: ignore[no-redef]
def gridColor(self, c: QColor) -> None: def gridColor(self, c: QColor) -> None:
if c == self._grid_color: if c == self._grid_color:
return return
@@ -106,7 +108,7 @@ class TrendChartItem(QQuickPaintedItem):
def textColor(self) -> QColor: def textColor(self) -> QColor:
return self._text_color return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: def textColor(self, c: QColor) -> None:
if c == self._text_color: if c == self._text_color:
return return
@@ -118,7 +120,7 @@ class TrendChartItem(QQuickPaintedItem):
def padding(self) -> int: def padding(self) -> int:
return self._padding return self._padding
@padding.setter @padding.setter # type: ignore[no-redef]
def padding(self, val: int) -> None: def padding(self, val: int) -> None:
v = max(0, int(val)) v = max(0, int(val))
if v == self._padding: if v == self._padding:
@@ -201,15 +203,10 @@ class TrendChartItem(QQuickPaintedItem):
return math.floor(lo / step) * step, math.ceil(hi / step) * step return math.floor(lo / step) * step, math.ceil(hi / step) * step
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float: def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1: return index_to_pixel(i, n, plot_rect.left(), plot_rect.width())
return plot_rect.left()
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float: 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 value_to_pixel(v, y_min, y_max, plot_rect.top(), plot_rect.height())
return plot_rect.center().y()
t = (v - y_min) / (y_max - y_min)
return plot_rect.bottom() - t * plot_rect.height()
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None: def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
pen = QPen(self._grid_color) pen = QPen(self._grid_color)
@@ -107,7 +107,7 @@ class WaferMapItem(QQuickPaintedItem):
def sensors(self) -> list: def sensors(self) -> list:
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors] 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: def sensors(self, val: list) -> None:
self._sensors = [ self._sensors = [
Sensor( Sensor(
@@ -127,7 +127,7 @@ class WaferMapItem(QQuickPaintedItem):
def values(self) -> list: def values(self) -> list:
return self._values return self._values
@values.setter @values.setter # type: ignore[no-redef]
def values(self, val: list) -> None: def values(self, val: list) -> None:
self._values = list(val or []) self._values = list(val or [])
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -138,7 +138,7 @@ class WaferMapItem(QQuickPaintedItem):
def bands(self) -> list: def bands(self) -> list:
return self._bands return self._bands
@bands.setter @bands.setter # type: ignore[no-redef]
def bands(self, val: list) -> None: def bands(self, val: list) -> None:
self._bands = list(val or []) self._bands = list(val or [])
self.bandsChanged.emit() self.bandsChanged.emit()
@@ -148,7 +148,7 @@ class WaferMapItem(QQuickPaintedItem):
def target(self) -> float: def target(self) -> float:
return self._target return self._target
@target.setter @target.setter # type: ignore[no-redef]
def target(self, val: float) -> None: def target(self, val: float) -> None:
self._target = float(val) self._target = float(val)
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -159,7 +159,7 @@ class WaferMapItem(QQuickPaintedItem):
def margin(self) -> float: def margin(self) -> float:
return self._margin return self._margin
@margin.setter @margin.setter # type: ignore[no-redef]
def margin(self, val: float) -> None: def margin(self, val: float) -> None:
self._margin = float(val) self._margin = float(val)
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -170,7 +170,7 @@ class WaferMapItem(QQuickPaintedItem):
def blend(self) -> float: def blend(self) -> float:
return self._blend return self._blend
@blend.setter @blend.setter # type: ignore[no-redef]
def blend(self, val: float) -> None: def blend(self, val: float) -> None:
self._blend = max(0.0, min(1.0, float(val))) self._blend = max(0.0, min(1.0, float(val)))
if self._blend > 0 and self._heatmap is None: if self._blend > 0 and self._heatmap is None:
@@ -182,7 +182,7 @@ class WaferMapItem(QQuickPaintedItem):
def showLabels(self) -> bool: def showLabels(self) -> bool:
return self._show_labels return self._show_labels
@showLabels.setter @showLabels.setter # type: ignore[no-redef]
def showLabels(self, val: bool) -> None: def showLabels(self, val: bool) -> None:
self._show_labels = bool(val) self._show_labels = bool(val)
self.showLabelsChanged.emit() self.showLabelsChanged.emit()
@@ -192,7 +192,7 @@ class WaferMapItem(QQuickPaintedItem):
def shape(self) -> str: def shape(self) -> str:
return self._shape return self._shape
@shape.setter @shape.setter # type: ignore[no-redef]
def shape(self, val: str) -> None: def shape(self, val: str) -> None:
self._shape = str(val).lower() self._shape = str(val).lower()
self._rebuild() self._rebuild()
@@ -202,7 +202,7 @@ class WaferMapItem(QQuickPaintedItem):
def size(self) -> float: def size(self) -> float:
return self._size return self._size
@size.setter @size.setter # type: ignore[no-redef]
def size(self, val: float) -> None: def size(self, val: float) -> None:
self._size = float(val) self._size = float(val)
self._rebuild() self._rebuild()
@@ -212,7 +212,7 @@ class WaferMapItem(QQuickPaintedItem):
def thicknessData(self) -> list: def thicknessData(self) -> list:
return self._thickness_data return self._thickness_data
@thicknessData.setter @thicknessData.setter # type: ignore[no-redef]
def thicknessData(self, val:list) -> None: def thicknessData(self, val:list) -> None:
self._thickness_data = list(val or []) self._thickness_data = list(val or [])
self._rebuild_thickness() self._rebuild_thickness()
@@ -223,7 +223,7 @@ class WaferMapItem(QQuickPaintedItem):
def showThickness(self) -> bool: def showThickness(self) -> bool:
return self._show_thickness return self._show_thickness
@showThickness.setter @showThickness.setter # type: ignore[no-redef]
def showThickness(self, val: bool) -> None: def showThickness(self, val: bool) -> None:
self._show_thickness = bool(val) self._show_thickness = bool(val)
self.showThicknessChanged.emit() self.showThicknessChanged.emit()
@@ -233,7 +233,7 @@ class WaferMapItem(QQuickPaintedItem):
def hoveredIndex(self) -> int: def hoveredIndex(self) -> int:
return self._hovered_index return self._hovered_index
@hoveredIndex.setter @hoveredIndex.setter # type: ignore[no-redef]
def hoveredIndex(self, val: int) -> None: def hoveredIndex(self, val: int) -> None:
val = int(val) val = int(val)
if val == self._hovered_index: if val == self._hovered_index:
@@ -246,7 +246,7 @@ class WaferMapItem(QQuickPaintedItem):
def hoverScale(self) -> float: def hoverScale(self) -> float:
return self._hover_scale return self._hover_scale
@hoverScale.setter @hoverScale.setter # type: ignore[no-redef]
def hoverScale(self, val: float) -> None: def hoverScale(self, val: float) -> None:
self._hover_scale = float(val) self._hover_scale = float(val)
self.hoverScaleChanged.emit() self.hoverScaleChanged.emit()
@@ -255,32 +255,32 @@ class WaferMapItem(QQuickPaintedItem):
# Colour properties — QML can bind these to Theme tokens # Colour properties — QML can bind these to Theme tokens
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def ringColor(self) -> QColor: return self._ring_color 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() def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def axisColor(self) -> QColor: return self._axis_color 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() def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def lowColor(self) -> QColor: return self._low_color 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() def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def inRangeColor(self) -> QColor: return self._in_range_color 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() def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def highColor(self) -> QColor: return self._high_color 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() def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def textColor(self) -> QColor: return self._text_color 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() def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
# ── slots ───────────────────────────────────────────────────────────── # ── slots ─────────────────────────────────────────────────────────────
@@ -314,7 +314,7 @@ class WaferMapItem(QQuickPaintedItem):
return False return False
try: try:
result = self.grabToImage() result = self.grabToImage()
img = result.image() img = result.image() # type: ignore[attr-defined]
img.save(file_path, "PNG") img.save(file_path, "PNG")
return True return True
except Exception as e: except Exception as e:
@@ -702,11 +702,11 @@ class WaferMapItem(QQuickPaintedItem):
painter.setPen(QPen(self._text_color)) painter.setPen(QPen(self._text_color))
y1 = ly + id_ascent y1 = ly + id_ascent
if side in ("top", "bottom"): 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": 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: else:
painter.drawText(lx, y1, id_text) painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
# Draw Temperature (second line) # Draw Temperature (second line)
if has_temp: if has_temp:
@@ -714,8 +714,8 @@ class WaferMapItem(QQuickPaintedItem):
painter.setPen(QPen(color)) painter.setPen(QPen(color))
y2 = ly + id_line_h + temp_ascent y2 = ly + id_line_h + temp_ascent
if side in ("top", "bottom"): 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": 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: 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: def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as f: 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) # 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], y_coords: Optional[list],
) -> None: ) -> None:
"""Build sensor list from layout arrays.""" """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.") raise ValueError("Sensor layout section is incomplete or missing.")
if len(labels) != len(x_coords) or len(labels) != len(y_coords): 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 logging
import os import os
from datetime import datetime
from typing import Optional from typing import Optional
from pygui.backend.wafer.family_spec import sensor_count_for from pygui.backend.wafer.family_spec import sensor_count_for
@@ -266,7 +267,6 @@ def save_to_csv(
try: try:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
# Build filename: P00001-20260505_133045.csv # Build filename: P00001-20260505_133045.csv
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{serial_number}-{timestamp}.csv" 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 Reads up to 256 bytes in one shot. If nothing is immediately
available, yields briefly to avoid a busy spin. 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: if not chunk:
time.sleep(0.005) time.sleep(0.005)
return chunk return chunk
@@ -106,7 +106,7 @@ class StreamReader:
except Exception: except Exception:
pass 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") log.info("StreamReader: starting binary stream parsing")
buf = bytearray(initial_bytes) buf = bytearray(initial_bytes)
resync_attempts = 0 resync_attempts = 0
@@ -149,7 +149,7 @@ class StreamReader:
payload = buf[7:7 + payload_len] payload = buf[7:7 + payload_len]
try: try:
frame = self._parse(payload, seq) frame = self._parse(payload, seq) # type: ignore[arg-type]
self._on_frame(frame) self._on_frame(frame)
except Exception as exc: except Exception as exc:
self.error_count += 1 self.error_count += 1
@@ -238,7 +238,7 @@ class StreamReader:
for hex_val in valid_hex_words: for hex_val in valid_hex_words:
try: try:
swapped = hex_val[2:4] + hex_val[0:2] 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) values.append(t)
except Exception: except Exception:
values.append(0.0) values.append(0.0)
@@ -262,7 +262,7 @@ class StreamReader:
line = line_bytes.decode('utf-8', errors='ignore').strip() line = line_bytes.decode('utf-8', errors='ignore').strip()
if line: if line:
try: try:
frame = self._parse(line, seq) frame = self._parse(line, seq) # type: ignore[arg-type]
if frame: if frame:
self._on_frame(frame) self._on_frame(frame)
seq += 1 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 import pytest
from unittest.mock import MagicMock, patch
from pygui.backend.controllers.session_controller import SessionController from pygui.backend.controllers.session_controller import SessionController
@pytest.fixture @pytest.fixture
def controller(): def controller():
return SessionController() return SessionController()
@@ -9,7 +12,7 @@ def controller():
def test_initial_default(controller): def test_initial_default(controller):
assert controller.mode == "review" assert controller.mode == "review"
assert controller.state == "idle" assert controller.state == "idle"
assert controller.recording == False assert not controller.recording
def test_playback_flow(controller): def test_playback_flow(controller):
controller.loadFile("tests/fixtures/sample_stream.csv") controller.loadFile("tests/fixtures/sample_stream.csv")
@@ -119,10 +122,10 @@ Item {
def test_recording_toggle(controller, tmp_path): def test_recording_toggle(controller, tmp_path):
csv_path = str(tmp_path / "rec" / "live_test.csv") csv_path = str(tmp_path / "rec" / "live_test.csv")
controller.startRecording(csv_path, "SN123") controller.startRecording(csv_path, "SN123")
assert controller.recording == True assert controller.recording
controller.stopRecording() controller.stopRecording()
assert controller.recording == False assert not controller.recording
# Header written with wafer serial # Header written with wafer serial
content = open(csv_path).read() content = open(csv_path).read()