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
*.so
*.a
*.egg-info/
.pytest_cache/
.ruff_cache/
# Virtual environment
.venv/
@@ -14,10 +17,11 @@ env/
# IDE
.vscode/
.idea/
.qtcreator/
*.swp
*.swo
*~
docs
# OS
.DS_Store
Thumbs.db
@@ -34,4 +38,6 @@ build/
dist/
*.spec
*.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
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e . # install the `pygui` package (src/ layout) in editable mode
```
## Run
```bash
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
- `main.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`.
- `ISC/Main.qml`: top-level window definition.
- `ISC/HomePage.qml`: main UI layout (left action rail, workspace panel, footer tabs).
- `ISC/Theme.qml`: shared theme constants and dark/light mode tokens.
- `ISC/qmldir`: QML module registration.
The application lives under a `src/` layout as the `pygui` package:
- `src/pygui/__main__.py`: PySide6 bootstrap; creates the Qt app and loads QML module `ISC/Main`. Entry point for `python -m pygui`.
- `src/pygui/backend/`: Qt-facing models and controllers (device, settings, file browser, wafer parsing).
- `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 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
- **Minimum size**: 1100 × 700 pixels
- **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
Window {
@@ -58,8 +66,8 @@ Window {
## Customization
- Toggle dark/light mode in `ISC/Theme.qml` via `isDarkMode`.
- Update sidebar and footer labels in `ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
- Toggle dark/light mode in `src/pygui/ISC/Theme.qml` via `isDarkMode`.
- Update sidebar and footer labels in `src/pygui/ISC/HomePage.qml` through `sideActions` and `bottomTabs`.
## Troubleshooting
+47 -1
View File
@@ -1,3 +1,8 @@
# ===== Build System =====
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
# ===== Project Metadata =====
[project]
name = "pygui"
@@ -6,9 +11,50 @@ version = "0.1.0"
[project.optional-dependencies]
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 =====
[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]
dev = [
@@ -92,6 +92,13 @@ QtObject {
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18"
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
// Radius
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.QtWidgets import QApplication
from backend.device_controller import DeviceController
from backend.local_settings import LocalSettings
from backend.local_settings_model import LocalSettingsModel
from backend.file_browser import FileBrowser
from pygui.backend.device_controller import DeviceController
from pygui.backend.local_settings import LocalSettings
from pygui.backend.local_settings_model import LocalSettingsModel
from pygui.backend.file_browser import FileBrowser
# ===== Application Entry Point =====
if __name__ == "__main__":
def main() -> int:
# ===== UI Style Setup =====
# Use a non-native controls style so our custom QML button backgrounds are supported.
QQuickStyle.setStyle("Basic")
@@ -35,11 +36,17 @@ if __name__ == "__main__":
engine.rootContext().setContextProperty("deviceController", device_controller)
# ===== 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.loadFromModule("ISC", "Main")
# ===== Exit Handling =====
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 backend.data_model import TemperatureTableModel
from backend.graph_view import GraphView
from backend.local_settings import LocalSettings
from serialcomm.data_parser import (
from pygui.backend.data_model import TemperatureTableModel
from pygui.backend.graph_view import GraphView
from pygui.backend.local_settings import LocalSettings
from pygui.serialcomm.data_parser import (
convert_to_temperatures,
parse_binary_data,
remove_trailing_zeros,
save_to_csv,
)
from serialcomm.device_service import DeviceService
from serialcomm.serial_port import WaferInfo
from pygui.serialcomm.device_service import DeviceService
from pygui.serialcomm.serial_port import WaferInfo
log = logging.getLogger(__name__)
@@ -8,8 +8,8 @@ from pathlib import Path
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox
from backend.csv_file_metadata import CSVFileMetadata
from backend.zwafer_parser import ZWaferParser
from pygui.backend.csv_file_metadata import CSVFileMetadata
from pygui.backend.zwafer_parser import ZWaferParser
# ===== 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 backend.local_settings import LocalSettings
from pygui.backend.local_settings import LocalSettings
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
from backend.contour_models import ContourLine, ContourSegment
from pygui.backend.contour_models import ContourLine, ContourSegment
# ===== 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 datetime import datetime, timedelta
from backend.zwafer_models import ZWaferData, Sensor
from pygui.backend.zwafer_models import ZWaferData, Sensor
# ===== Z-Wafer CSV Parser =====
@@ -1,6 +1,6 @@
"""Serial port communication layer for the temperature-sensing wafer."""
from serialcomm.device_service import DeviceService
from serialcomm.serial_port import SerialPort, WaferInfo
from pygui.serialcomm.device_service import DeviceService
from pygui.serialcomm.serial_port import SerialPort, WaferInfo
__all__ = ["DeviceService", "SerialPort", "WaferInfo"]
@@ -11,8 +11,8 @@ from typing import Optional
import serial.tools.list_ports
from backend.local_settings import LocalSettings
from serialcomm.serial_port import SerialPort, WaferInfo
from pygui.backend.local_settings import LocalSettings
from pygui.serialcomm.serial_port import SerialPort, WaferInfo
log = logging.getLogger(__name__)
+5 -5
View File
@@ -1,7 +1,7 @@
"""Tests for serialcomm/data_parser.py binary parsing pipeline."""
import pytest
from serialcomm.data_parser import (
from pygui.serialcomm.data_parser import (
csv_column_count,
parse_binary_data,
convert_to_temperatures,
@@ -12,7 +12,6 @@ from serialcomm.data_parser import (
MAXDUT_X,
)
# ── csv_column_count ──────────────────────────────────────────────────────────
@@ -203,6 +202,7 @@ class TestConvertToTemperatures:
def test_p_family_single_block(self):
data = _make_p_block(1, value=0x0100)
hex_data = parse_binary_data(data, "P")
assert hex_data is not None
result = convert_to_temperatures(hex_data, "P")
assert len(result) == 1
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))
assert result is not None, f"save_to_csv returned None for {family}"
headers = open(result).readline().strip().split(",")
assert len(headers) == expected_cols, (
f"{family}: expected {expected_cols} headers, got {len(headers)}"
)
assert (
len(headers) == expected_cols
), f"{family}: expected {expected_cols} headers, got {len(headers)}"
assert headers[0] == "Sensor1"
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]