Compare commits

..

3 Commits

Author SHA1 Message Date
jack 9cd3170e8a Replay Tabs:
- Add stability detection and threshold classification features with corresponding tests
2026-06-04 13:25:11 -07:00
jack 9779baa468 Restructure into src/ layout under pygui package
Move all application source under src/pygui/ and rewire imports,
build config, and QML module path to match.
- Relocate backend/, serialcomm/, and the ISC QML module into
  src/pygui/; convert main.py into pygui/__main__.py with a main()
  entry point (run via `python -m pygui` or the new `isc` script)
- Rewrite absolute imports: backend.* -> pygui.backend.*,
  serialcomm.* -> pygui.serialcomm.* (source + tests)
- Move app icons (isc.ico/icns) into packaging/
- Update README and ISC.qmlproject to the new paths
2026-06-03 11:41:45 -07:00
jack af170666e8 add sensor-band theme tokens 2026-06-03 11:15:21 -07:00
41 changed files with 341 additions and 41 deletions
+8 -2
View File
@@ -5,6 +5,9 @@ __pycache__/
*.pyd *.pyd
*.so *.so
*.a *.a
*.egg-info/
.pytest_cache/
.ruff_cache/
# Virtual environment # Virtual environment
.venv/ .venv/
@@ -14,10 +17,11 @@ env/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
.qtcreator/
*.swp *.swp
*.swo *.swo
*~ *~
docs
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -34,4 +38,6 @@ build/
dist/ dist/
*.spec *.spec
*.icns *.icns
*.ico *.ico
# Superpowers brainstorming visual companion
.superpowers/
+16
View File
@@ -0,0 +1,16 @@
import type QmlProjectModel 1.0
QmlProject {
name: "ISC"
version: "1.0"
mainFile: "src/pygui/ISC/Main.qml"
dependencies {
module: "QtQuick"
minimumVersion: "6.0.0"
}
importPaths: [
"src/pygui"
]
}
+19 -11
View File
@@ -16,34 +16,42 @@ python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install -e . # install the `pygui` package (src/ layout) in editable mode
``` ```
## Run ## Run
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
python main.py python -m pygui # or run the `isc` console script
``` ```
This launches the Qt window and loads the `ISC` QML module from `ISC/Main.qml`. This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
## Project Structure ## Project Structure
- `main.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. The application lives under a `src/` layout as the `pygui` package:
- `ISC/Main.qml`: top-level window definition.
- `ISC/HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs). - `src/pygui/__main__.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. Entry point for `python -m pygui`.
- `ISC/Theme.qml`: shared theme constants and dark/light mode tokens. - `src/pygui/backend/`: Qt-facing models and controllers (device, settings, file browser, wafer parsing).
- `ISC/qmldir`: QML module registration. - `src/pygui/serialcomm/`: serial port, device service, and data-parser layer.
- `src/pygui/ISC/`: the `ISC` QML module (UI).
- `Main.qml`: top-level window definition.
- `HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
- `Theme.qml`: shared theme constants and dark/light mode tokens.
- `qmldir`: QML module registration.
- `tests/`: pytest suite.
- `packaging/`: PyInstaller spec (`isc.spec`) and app icons.
## Window Configuration ## Window Configuration
Window dimensions and constraints are defined in `ISC/Main.qml`: Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
- **Default size**: 1400 × 820 pixels - **Default size**: 1400 × 820 pixels
- **Minimum size**: 1100 × 700 pixels - **Minimum size**: 1100 × 700 pixels
- **Title bar**: "ISenseCloud" - **Title bar**: "ISenseCloud"
To adjust the window, edit the `Window` block in `ISC/Main.qml`: To adjust the window, edit the `Window` block in `src/pygui/ISC/Main.qml`:
```qml ```qml
Window { Window {
@@ -58,8 +66,8 @@ Window {
## Customization ## Customization
- Toggle dark/light mode in `ISC/Theme.qml` via `isDarkMode`. - Toggle dark/light mode in `src/pygui/ISC/Theme.qml` via `isDarkMode`.
- Update sidebar and footer labels in `ISC/HomePage.qml` through `sideActions` and `bottomTabs`. - Update sidebar and footer labels in `src/pygui/ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
## Troubleshooting ## Troubleshooting
+47 -1
View File
@@ -1,3 +1,8 @@
# ===== Build System =====
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
# ===== Project Metadata ===== # ===== Project Metadata =====
[project] [project]
name = "pygui" name = "pygui"
@@ -6,9 +11,50 @@ version = "0.1.0"
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest"] dev = ["pytest"]
# ===== Console Entry Point =====
[project.scripts]
isc = "pygui.__main__:main"
# ===== Package Discovery (src layout) =====
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
# Ship the QML module alongside the Python package.
pygui = ["ISC/**/*.qml", "ISC/**/qmldir"]
# ===== PySide Build Inputs ===== # ===== PySide Build Inputs =====
[tool.pyside6-project] [tool.pyside6-project]
files = ["ISC/HomePage.qml", "ISC/Main.qml", "ISC/Tabs/DataTab.qml", "ISC/Tabs/GraphTab.qml", "ISC/Tabs/SelectFileDialog.qml", "ISC/Tabs/SettingsTab.qml", "ISC/Tabs/StatusTab.qml", "ISC/Tabs/qmldir", "ISC/Theme.qml", "ISC/qmldir", "backend/data_model.py", "backend/device_controller.py", "backend/graph_view.py", "backend/file_browser.py", "backend/local_settings.py", "backend/local_settings_model.py", "main.py", "serialcomm/__init__.py", "serialcomm/data_parser.py", "serialcomm/serial_port.py", "serialcomm/device_service.py"] files = [
"src/pygui/ISC/HomePage.qml",
"src/pygui/ISC/Main.qml",
"src/pygui/ISC/Theme.qml",
"src/pygui/ISC/qmldir",
"src/pygui/ISC/Tabs/DataTab.qml",
"src/pygui/ISC/Tabs/SelectFileDialog.qml",
"src/pygui/ISC/Tabs/SettingsTab.qml",
"src/pygui/ISC/Tabs/StatusTab.qml",
"src/pygui/ISC/Tabs/qmldir",
"src/pygui/__main__.py",
"src/pygui/backend/contour_models.py",
"src/pygui/backend/crypto_helper.py",
"src/pygui/backend/csv_file_metadata.py",
"src/pygui/backend/data_model.py",
"src/pygui/backend/data_segment.py",
"src/pygui/backend/device_controller.py",
"src/pygui/backend/file_browser.py",
"src/pygui/backend/frame.py",
"src/pygui/backend/graph_view.py",
"src/pygui/backend/local_settings.py",
"src/pygui/backend/local_settings_model.py",
"src/pygui/backend/marching_squares.py",
"src/pygui/backend/zwafer_models.py",
"src/pygui/backend/zwafer_parser.py",
"src/pygui/serialcomm/__init__.py",
"src/pygui/serialcomm/data_parser.py",
"src/pygui/serialcomm/device_service.py",
"src/pygui/serialcomm/serial_port.py",
]
[dependency-groups] [dependency-groups]
dev = [ dev = [
@@ -92,6 +92,13 @@ QtObject {
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18" readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18"
readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545" readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545"
// -- 10b. Sensor bands (wafer map dots)
readonly property color sensorInRange: statusSuccessColor
readonly property color sensorHigh: statusErrorColor
readonly property color sensorlow: isDarkMode ? "#589DF5" : "#2F6FE0"
readonly property color waferRingColor: toneBorder
readonly property color waferAxisColor: softBorder
// 11. Geometry // 11. Geometry
// Radius // Radius
readonly property int radiusXs: 4 // fields, tight elements readonly property int radiusXs: 4 // fields, tight elements
View File
View File
+14 -7
View File
@@ -5,13 +5,14 @@ from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle from PySide6.QtQuickControls2 import QQuickStyle
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from backend.device_controller import DeviceController from pygui.backend.device_controller import DeviceController
from backend.local_settings import LocalSettings from pygui.backend.local_settings import LocalSettings
from backend.local_settings_model import LocalSettingsModel from pygui.backend.local_settings_model import LocalSettingsModel
from backend.file_browser import FileBrowser from pygui.backend.file_browser import FileBrowser
# ===== Application Entry Point ===== # ===== Application Entry Point =====
if __name__ == "__main__": def main() -> int:
# ===== UI Style Setup ===== # ===== UI Style Setup =====
# Use a non-native controls style so our custom QML button backgrounds are supported. # Use a non-native controls style so our custom QML button backgrounds are supported.
QQuickStyle.setStyle("Basic") QQuickStyle.setStyle("Basic")
@@ -35,11 +36,17 @@ if __name__ == "__main__":
engine.rootContext().setContextProperty("deviceController", device_controller) engine.rootContext().setContextProperty("deviceController", device_controller)
# ===== QML Startup ===== # ===== QML Startup =====
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
# package directory is the import path the engine searches for qmldir.
engine.addImportPath(Path(__file__).parent) engine.addImportPath(Path(__file__).parent)
engine.loadFromModule("ISC", "Main") engine.loadFromModule("ISC", "Main")
# ===== Exit Handling ===== # ===== Exit Handling =====
if not engine.rootObjects(): if not engine.rootObjects():
sys.exit(-1) return -1
sys.exit(app.exec()) return app.exec()
if __name__ == "__main__":
sys.exit(main())
@@ -15,17 +15,17 @@ from typing import Any, Optional
from PySide6.QtCore import QObject, Property, Qt, Signal, Slot from PySide6.QtCore import QObject, Property, Qt, Signal, Slot
from backend.data_model import TemperatureTableModel from pygui.backend.data_model import TemperatureTableModel
from backend.graph_view import GraphView from pygui.backend.graph_view import GraphView
from backend.local_settings import LocalSettings from pygui.backend.local_settings import LocalSettings
from serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
convert_to_temperatures, convert_to_temperatures,
parse_binary_data, parse_binary_data,
remove_trailing_zeros, remove_trailing_zeros,
save_to_csv, save_to_csv,
) )
from serialcomm.device_service import DeviceService from pygui.serialcomm.device_service import DeviceService
from serialcomm.serial_port import WaferInfo from pygui.serialcomm.serial_port import WaferInfo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -8,8 +8,8 @@ from pathlib import Path
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox from PySide6.QtWidgets import QFileDialog, QMessageBox
from backend.csv_file_metadata import CSVFileMetadata from pygui.backend.csv_file_metadata import CSVFileMetadata
from backend.zwafer_parser import ZWaferParser from pygui.backend.zwafer_parser import ZWaferParser
# ===== File Browser Model ===== # ===== File Browser Model =====
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Frame:
"""One sample across all sensors ata a point in time"""
seq: int # monotonically increasing
t: float # seconds (relative or epoch)
values: list[float] # one per sensor, in sensor-layout order
+37
View File
@@ -0,0 +1,37 @@
"""Per-frame descriptive statistics"""
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class Stats:
min: float; min_index: int
max: float; max_index: int
diff: float; avg: float
sigma: float; three_sigma: float
def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean:
return Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
min_index, min_v = min(clean, key=lambda iv: iv[1])
max_index, max_v = max(clean, key=lambda iv: iv[1])
nums = [v for _, v in clean]
avg = sum(nums) / len(nums)
variance = sum((v - avg) ** 2 for v in nums) / len(nums)
sigma = math.sqrt(variance)
return Stats(
min=min_v,
min_index=min_index,
max=max_v,
max_index=max_index,
diff=max_v - min_v,
avg=avg,
sigma=sigma,
three_sigma=3 * sigma,
)
@@ -6,7 +6,7 @@ from typing import Any
from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot
from backend.local_settings import LocalSettings from pygui.backend.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")
@@ -2,7 +2,7 @@ from typing import List, Tuple, Optional
import numpy as np import numpy as np
from backend.contour_models import ContourLine, ContourSegment from pygui.backend.contour_models import ContourLine, ContourSegment
# ===== Contour Generation ===== # ===== Contour Generation =====
+34
View File
@@ -0,0 +1,34 @@
"""Detect process state (Idle, Ramp, Set) from the running average temp"""
from __future__ import annotations
from typing import Optional
STATE_IDLE = "idle"
STATE_RAMP = "ramp"
STATE_SET = "set"
class StabilityDetector:
def __init__(self, idle_below: float = 50.0, tolerance: float = 1.0,
settle_seconds: float = 10.0) -> None:
self._idle_below = idle_below
self._tolerance = tolerance
self._settle_seconds = settle_seconds
self._near_since: Optional[float] = None # When avg entered the +- tolerance band
def reset(self) -> None:
self._near_since = None
def update(self, avg: float, time: float, set_point: float) ->str:
if avg < self._idle_below:
self._near_since = None
return STATE_IDLE
if abs(avg - set_point) <= self._tolerance:
if self._near_since is None:
self._near_since = time
if time - self._near_since >= self._settle_seconds:
return STATE_SET
return STATE_RAMP
self._near_since = None
return STATE_RAMP
+42
View File
@@ -0,0 +1,42 @@
"""Classify sensor values into three bands around (target, margin)
Auto mode derives target=mean, margin=1
"""
from __future__ import annotations
import math
from dataclasses import dataclass
BAND_IN = "in_range"
BAND_HIGH = "high"
BAND_LOW = "low"
@dataclass(frozen=True)
class ThresholdConfig:
set_point: float = 149.0 # process target: used as band TARGET when auto=False
margin: float = 1.0 # used as band MARGIN when auto=False
auto: bool = True # auto=True: target=frame mean, margin=frame 1σ
def resolve_bounds(values: list[float], cfg: ThresholdConfig) -> tuple[float, float]:
if not cfg.auto:
return cfg.set_point, cfg.margin
clean = [v for v in values if not math.isnan(v)]
if not clean:
return cfg.set_point, cfg.margin
mean = sum(clean) / len(clean)
variance = sum((v - mean) ** 2 for v in clean) / len(clean)
return mean, math.sqrt(variance)
def classify(value: float, target: float, margin: float) -> str:
if math.isnan(value):
return BAND_IN
if value > target + margin:
return BAND_HIGH
if value < target - margin:
return BAND_LOW
return BAND_IN
def classify_all(values: list[float], cfg: ThresholdConfig) -> list[str]:
target, margin = resolve_bounds(values, cfg)
return [classify(v, target, margin)for v in values ]
@@ -2,7 +2,7 @@ from pathlib import Path
from typing import Tuple, Optional from typing import Tuple, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from backend.zwafer_models import ZWaferData, Sensor from pygui.backend.zwafer_models import ZWaferData, Sensor
# ===== Z-Wafer CSV Parser ===== # ===== Z-Wafer CSV Parser =====
@@ -1,6 +1,6 @@
"""Serial port communication layer for the temperature-sensing wafer.""" """Serial port communication layer for the temperature-sensing wafer."""
from serialcomm.device_service import DeviceService from pygui.serialcomm.device_service import DeviceService
from serialcomm.serial_port import SerialPort, WaferInfo from pygui.serialcomm.serial_port import SerialPort, WaferInfo
__all__ = ["DeviceService", "SerialPort", "WaferInfo"] __all__ = ["DeviceService", "SerialPort", "WaferInfo"]
@@ -11,8 +11,8 @@ from typing import Optional
import serial.tools.list_ports import serial.tools.list_ports
from backend.local_settings import LocalSettings from pygui.backend.local_settings import LocalSettings
from serialcomm.serial_port import SerialPort, WaferInfo from pygui.serialcomm.serial_port import SerialPort, WaferInfo
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
+5 -5
View File
@@ -1,7 +1,7 @@
"""Tests for serialcomm/data_parser.py binary parsing pipeline.""" """Tests for serialcomm/data_parser.py binary parsing pipeline."""
import pytest import pytest
from serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
csv_column_count, csv_column_count,
parse_binary_data, parse_binary_data,
convert_to_temperatures, convert_to_temperatures,
@@ -12,7 +12,6 @@ from serialcomm.data_parser import (
MAXDUT_X, MAXDUT_X,
) )
# ── csv_column_count ────────────────────────────────────────────────────────── # ── csv_column_count ──────────────────────────────────────────────────────────
@@ -203,6 +202,7 @@ class TestConvertToTemperatures:
def test_p_family_single_block(self): def test_p_family_single_block(self):
data = _make_p_block(1, value=0x0100) data = _make_p_block(1, value=0x0100)
hex_data = parse_binary_data(data, "P") hex_data = parse_binary_data(data, "P")
assert hex_data is not None
result = convert_to_temperatures(hex_data, "P") result = convert_to_temperatures(hex_data, "P")
assert len(result) == 1 assert len(result) == 1
assert all(isinstance(v, str) for v in result[0]) assert all(isinstance(v, str) for v in result[0])
@@ -270,9 +270,9 @@ class TestSaveToCsv:
result = save_to_csv(data, family, f"{family}00001", str(tmp_path)) result = save_to_csv(data, family, f"{family}00001", str(tmp_path))
assert result is not None, f"save_to_csv returned None for {family}" assert result is not None, f"save_to_csv returned None for {family}"
headers = open(result).readline().strip().split(",") headers = open(result).readline().strip().split(",")
assert len(headers) == expected_cols, ( assert (
f"{family}: expected {expected_cols} headers, got {len(headers)}" len(headers) == expected_cols
) ), f"{family}: expected {expected_cols} headers, got {len(headers)}"
assert headers[0] == "Sensor1" assert headers[0] == "Sensor1"
assert headers[-1] == f"Sensor{expected_cols}" assert headers[-1] == f"Sensor{expected_cols}"
+22
View File
@@ -0,0 +1,22 @@
import math
import pytest
from pygui.backend.frame_stats import compute_stats, Stats
def test_basic_stat():
s = compute_stats([148.0, 150.0, 149.0])
assert s.min == 148.0 and s.min_index == 0
assert s.max == 150.0 and s.max_index == 1
assert s.diff == pytest.approx(2.0)
assert s.avg == pytest.approx(149.0)
assert s.sigma == pytest.approx(math.sqrt(2 / 3))
assert s.three_sigma == pytest.approx(3 * math.sqrt(2 / 3))
def test_empty_values_returns_zeros():
s = compute_stats([])
assert s == Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
def test_ignores_nan():
s = compute_stats([149.0, float("nan"), 151.0])
+35
View File
@@ -0,0 +1,35 @@
from pygui.backend.stability_detector import (
StabilityDetector, STATE_IDLE, STATE_RAMP, STATE_SET,
)
SET_POINT = 149.0
def make():
return StabilityDetector(idle_below = 50.0, tolerance=1.0, settle_seconds=2.0)
def test_idle_when_cold():
d = make()
assert d.update(avg=25.0, time=0.0, set_point=SET_POINT) == STATE_IDLE
def test_ramp_while_far_from_setpoint():
d = make()
d.update(avg=100.0, time=0.0, set_point=SET_POINT)
def test_ramp_until_settle_time_elapses():
d = make()
assert d.update(avg=149.2, time=0.0, set_point=SET_POINT) == STATE_RAMP
assert d.update(avg=148.9, time=0.0, set_point=SET_POINT) == STATE_RAMP
def test_set_after_holding_near_setpoint():
d = make()
d.update(avg=149.2, time=0.0, set_point=SET_POINT)
d.update(avg=148.9, time=1.0, set_point=SET_POINT)
assert d.update(avg=149.0, time=2.5, set_point=SET_POINT) == STATE_SET
def test_back_to_ramp_on_disturbance():
d = make()
for t in (0.0, 1.0, 2.5):
d.update(149.0, t, set_point=SET_POINT)
assert d.update(avg=160.0, time=3.0, set_point=SET_POINT) == STATE_RAMP
+29
View File
@@ -0,0 +1,29 @@
import math
from pygui.backend.threshold_classifier import (
ThresholdConfig, classify, classify_all, resolve_bounds,
BAND_IN, BAND_HIGH, BAND_LOW
)
def test_classify_about_target_margin():
assert classify(149.0, target=149.0, margin=1.0) ==BAND_IN # exactly
assert classify(149.9, target=149.0, margin=1.0) ==BAND_IN # within
assert classify(150.5, target=149.0, margin=1.0) ==BAND_HIGH # 1.5 above
assert classify(147.5, target=149.0, margin=1.0) ==BAND_LOW # 1.5 below
def test_manual_bounds_use_set_point_and_margin():
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
assert resolve_bounds([200.0, 0.0], cfg) == (149.0, 1.0)
def test_auto_bounds_use_mean_and_sigma():
cfg = ThresholdConfig(auto=True)
target, margin = resolve_bounds([148.0, 150.0, 149.0], cfg)
assert target == 149.0
assert margin == math.sqrt(2 / 3)
def test_classify_all_manual():
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
assert classify_all([149.0, 151.0, 147.0], cfg) == [BAND_IN, BAND_HIGH, BAND_LOW]