Compare commits
36 Commits
SettingTab
...
831457941a
| Author | SHA1 | Date | |
|---|---|---|---|
| 831457941a | |||
| cfadbdf1c3 | |||
| f4621f1faf | |||
| 2cbc143c20 | |||
| 470f8acba3 | |||
| 62ce5a9c37 | |||
| 20a34e22ee | |||
| 7206334a27 | |||
| 5514653b94 | |||
| 93868bcde4 | |||
| b417211476 | |||
| 66942d250d | |||
| 8ad2a9b972 | |||
| 1119733929 | |||
| e545c245d7 | |||
| 7d32fb4b65 | |||
| 5b1c924d35 | |||
| b34b920759 | |||
| a70764e08c | |||
| 2e4763510d | |||
| 7e584e08e8 | |||
| cea4fb782e | |||
| 5b186df888 | |||
| f89a735d51 | |||
| 1cd54e81fc | |||
| 12bd778f13 | |||
| 72334795da | |||
| b9f8032203 | |||
| 97ca58bfc2 | |||
| b52b983bb2 | |||
| ca1a514f23 | |||
| 59528eb6c9 | |||
| 9cd3170e8a | |||
| 9779baa468 | |||
| af170666e8 | |||
| 16d8bf48af |
@@ -5,6 +5,9 @@ __pycache__/
|
|||||||
*.pyd
|
*.pyd
|
||||||
*.so
|
*.so
|
||||||
*.a
|
*.a
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
# Virtual environment
|
# Virtual environment
|
||||||
.venv/
|
.venv/
|
||||||
@@ -14,9 +17,11 @@ env/
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.qtcreator/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
docs
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# ===== ISC Tabs Module =====
|
|
||||||
module ISC.Tabs
|
|
||||||
|
|
||||||
# ===== Tab Components =====
|
|
||||||
SettingsTab 1.0 SettingsTab.qml
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
.PHONY: install test lint fix typecheck run clean
|
||||||
|
|
||||||
|
install:
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
test:
|
||||||
|
uv run pytest tests/
|
||||||
|
|
||||||
|
lint:
|
||||||
|
uv run ruff check src/
|
||||||
|
|
||||||
|
fix:
|
||||||
|
uv run ruff check src/ --fix
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
uv run mypy src/
|
||||||
|
|
||||||
|
run:
|
||||||
|
uv run python -m pygui
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf .pytest_cache .ruff_cache .mypy_cache
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||||
@@ -4,46 +4,89 @@ Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI s
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.10+ (tested with Python 3.14 in this workspace)
|
- Python 3.11+ (tested with Python 3.14 in this workspace)
|
||||||
- macOS, Linux, or Windows with GUI support
|
- macOS, Linux, or Windows with GUI support
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
From the project root:
|
From the project root:
|
||||||
|
|
||||||
|
### Option A: Using `uv` (Recommended)
|
||||||
|
If you have [uv](https://docs.astral.sh/uv/) installed:
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Using `pip`
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
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
|
make run
|
||||||
python main.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
A `Makefile` is provided to simplify common development and verification tasks:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
|
||||||
|
| `make run` | Launch the ISenseCloud application |
|
||||||
|
| `make test` | Run the `pytest` test suite |
|
||||||
|
| `make lint` | Check code style and quality with the `Ruff` linter |
|
||||||
|
| `make fix` | Automatically fix fixable `Ruff` style violations |
|
||||||
|
| `make typecheck` | Run the `Mypy` static type checker |
|
||||||
|
| `make clean` | Clean build and tool caches (`.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `__pycache__`) |
|
||||||
|
|
||||||
|
### Ruff Configuration
|
||||||
|
|
||||||
|
Ruff is configured in `pyproject.toml` to enforce:
|
||||||
|
- **E/W**: Pycodestyle errors and warnings
|
||||||
|
- **F**: Pyflakes linter rules
|
||||||
|
- **I**: Import sorting (isort parity)
|
||||||
|
- **N**: PEP 8 naming conventions
|
||||||
|
|
||||||
## 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, controllers, and visualizers organized into subdirectories:
|
||||||
- `ISC/qmldir`: QML module registration.
|
- `controllers/`: QML-facing controllers (Session & Device controllers).
|
||||||
|
- `data/`: local settings, file browser, and CSV recorder utilities.
|
||||||
|
- `models/`: data models (Session, Thresholds, Frame Player, Frame representation).
|
||||||
|
- `visualization/`: QML wafer map item integration.
|
||||||
|
- `wafer/`: layout definitions, coordinate mappings, and files parser.
|
||||||
|
- `src/pygui/serialcomm/`: serial port transport, device service scanning, and protocol 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,16 +101,46 @@ 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
|
||||||
|
|
||||||
- If the app does not start, verify the venv is active and dependencies are installed:
|
- If the app does not start, verify the virtual environment is active and dependencies are installed:
|
||||||
|
|
||||||
|
*Using `uv`:*
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
*Using `pip`:*
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
- 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:**
|
||||||
|
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||||
|
|
||||||
|
### Step-by-Step Windows Simulator Connection Guide
|
||||||
|
|
||||||
|
#### Step 1: Create the Virtual Serial Port Bridge
|
||||||
|
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### Step 2: Configure and Start the Wafer Simulator
|
||||||
|
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
||||||
|
1. Set the **Serial Port** to **`COM5`**.
|
||||||
|
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
|
||||||
|
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
|
||||||
|
4. Click **Start Simulator**. Once the client connects, you will see `Streaming (D2)` status and active data transfer logs.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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).
|
||||||
|
|
||||||
|

|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: aepwafer
|
||||||
|
wafers: ["A", "E", "P"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
|
||||||
|
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
|
||||||
|
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
|
||||||
|
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
|
||||||
|
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
|
||||||
|
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
4: { top: [0, 0] },
|
||||||
|
5: { top: [0, 0] },
|
||||||
|
6: { top: [0, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: bcdwafer
|
||||||
|
wafers: ["B", "C", "D"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
|
||||||
|
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
|
||||||
|
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
|
||||||
|
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
|
||||||
|
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
|
||||||
|
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
6: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: fwafer
|
||||||
|
wafers: ["F"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 200
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
|
||||||
|
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
|
||||||
|
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
|
||||||
|
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: xwafer
|
||||||
|
wafers: ["X"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: square
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 310
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
|
||||||
|
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
|
||||||
|
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
|
||||||
|
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
|
||||||
|
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
|
||||||
|
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
|
||||||
|
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
|
||||||
|
133.29, 176.72, 176.72]
|
||||||
|
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
|
||||||
|
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
|
||||||
|
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
|
||||||
|
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
|
||||||
|
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
|
||||||
|
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
|
||||||
|
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
|
||||||
|
176.79, 133.36 ]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
21: { left: [0, 0] },
|
||||||
|
22: { left: [0, 0] },
|
||||||
|
23: { left: [0, 0] },
|
||||||
|
24: { left: [0, 0] },
|
||||||
|
25: { left: [0, 0] },
|
||||||
|
26: { left: [0, 0] },
|
||||||
|
27: { left: [0, 0] },
|
||||||
|
28: { left: [0, 0] },
|
||||||
|
29: { left: [0, 0] },
|
||||||
|
30: { left: [0, 0] },
|
||||||
|
31: { left: [0, 0] },
|
||||||
|
32: { left: [0, 0] },
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
35: { left: [0, 0] },
|
||||||
|
36: { left: [0, 0] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer
|
||||||
|
wafers: ["Z"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
41: { top: [0, 0] },
|
||||||
|
42: { bottom: [0, 0] },
|
||||||
|
45: { top: [1, 0] },
|
||||||
|
46: { bottom: [0.5, 0] },
|
||||||
|
49: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1.75, -0.5] },
|
||||||
|
51: { top: [-1, 0] },
|
||||||
|
52: { top: [-1, 0] },
|
||||||
|
53: { bottom: [0, 0] },
|
||||||
|
54: { left: [0, -0.75] },
|
||||||
|
55: { right: [0, 0.5] },
|
||||||
|
58: { bottom: [-0.75, 0] },
|
||||||
|
60: { top: [0, 0] },
|
||||||
|
62: { right: [0, 1] },
|
||||||
|
63: { right: [0, 0] },
|
||||||
|
64: { left: [0, 0.25] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer_rev
|
||||||
|
wafers: [] # the reversed version doesn't represent any real wafer
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: true
|
||||||
|
reverse_y: true
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
37: { left: [0, 0] },
|
||||||
|
38: { left: [0, 0] },
|
||||||
|
39: { left: [0, 0] },
|
||||||
|
41: { top: [1.5, 0] },
|
||||||
|
42: { bottom: [1, 0] },
|
||||||
|
43: { left: [0, -1] },
|
||||||
|
45: { bottom: [-1, -1] },
|
||||||
|
46: { bottom: [0, -1] },
|
||||||
|
47: { right: [0, 1] },
|
||||||
|
48: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1, 0] },
|
||||||
|
52: { top: [0, 0] },
|
||||||
|
54: { right: [0.5, 1] },
|
||||||
|
56: { left: [0, 0.5] },
|
||||||
|
57: { left: [0, 0] },
|
||||||
|
58: { top: [0.5, 0] },
|
||||||
|
59: { left: [0, 0] },
|
||||||
|
60: { top: [-1, 0] },
|
||||||
|
61: { left: [0, 0] },
|
||||||
|
62: { left: [0, -1] },
|
||||||
|
63: { right: [0, 1] },
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# ===== Backend Package Marker =====
|
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -1,45 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# ===== Application Entry Point =====
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# ===== UI Style Setup =====
|
|
||||||
# Use a non-native controls style so our custom QML button backgrounds are supported.
|
|
||||||
QQuickStyle.setStyle("Basic")
|
|
||||||
|
|
||||||
# ===== Qt Bootstrap =====
|
|
||||||
# Minimal Qt bootstrap: load the ISC QML module and exit if it fails.
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
engine = QQmlApplicationEngine()
|
|
||||||
|
|
||||||
# ===== Shared Models =====
|
|
||||||
settings_model = LocalSettingsModel()
|
|
||||||
select_file_dialog_model = FileBrowser()
|
|
||||||
settings_model.loadSettings()
|
|
||||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
|
||||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
|
||||||
|
|
||||||
# ===== Device Controller (serial comm) =====
|
|
||||||
data_dir = str(settings_model._data_dir)
|
|
||||||
raw_settings = LocalSettings.read_settings(data_dir)
|
|
||||||
device_controller = DeviceController(raw_settings, data_dir)
|
|
||||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
|
||||||
|
|
||||||
# ===== QML Startup =====
|
|
||||||
engine.addImportPath(Path(__file__).parent)
|
|
||||||
engine.loadFromModule("ISC", "Main")
|
|
||||||
|
|
||||||
# ===== Exit Handling =====
|
|
||||||
if not engine.rootObjects():
|
|
||||||
sys.exit(-1)
|
|
||||||
|
|
||||||
sys.exit(app.exec())
|
|
||||||
+114
-1
@@ -1,16 +1,129 @@
|
|||||||
|
# ===== Build System =====
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
# ===== Project Metadata =====
|
# ===== Project Metadata =====
|
||||||
[project]
|
[project]
|
||||||
name = "pygui"
|
name = "pygui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"PySide6>=6.6.0",
|
||||||
|
"pyqtgraph>=0.13.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
"pyserial>=3.5",
|
||||||
|
"pandas>=2.0.0",
|
||||||
|
"matplotlib>=3.7.0",
|
||||||
|
"scipy>=1.17.1",
|
||||||
|
"pyyaml>=6.0.3",
|
||||||
|
"dtw-python",
|
||||||
|
]
|
||||||
|
|
||||||
[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/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/backend/frame_stats.py",
|
||||||
|
"src/pygui/backend/threshold_classifier.py",
|
||||||
|
"src/pygui/backend/stability_detector.py",
|
||||||
|
"src/pygui/backend/csv_recorder.py",
|
||||||
|
"src/pygui/backend/frame_player.py",
|
||||||
|
"src/pygui/backend/stream_reader.py",
|
||||||
|
"src/pygui/backend/session_model.py",
|
||||||
|
"src/pygui/backend/session_controller.py",
|
||||||
|
"src/pygui/backend/wafer_layouts.py",
|
||||||
|
"src/pygui/backend/rbf_heatmap.py",
|
||||||
|
"src/pygui/backend/wafer_map_item.py",
|
||||||
|
"src/pygui/serialcomm/__init__.py",
|
||||||
|
"src/pygui/serialcomm/data_parser.py",
|
||||||
|
"src/pygui/serialcomm/device_service.py",
|
||||||
|
"src/pygui/serialcomm/serial_port.py",
|
||||||
|
"src/pygui/ISC/Tabs/WaferMapTab.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/WaferMapView.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/SourcePanel.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/ReadoutPanel.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/TransportBar.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/ReplaceSensorDialog.qml",
|
||||||
|
"src/pygui/ISC/Tabs/components/qmldir",
|
||||||
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
|
"ruff",
|
||||||
|
"mypy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
environments = [
|
||||||
|
"sys_platform == 'darwin'",
|
||||||
|
"sys_platform == 'win32'",
|
||||||
|
"sys_platform == 'linux'",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ===== Ruff Linter and Formatter =====
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 125
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort (import sorting)
|
||||||
|
"N", # pep8-naming
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"N802", # Function name should be lowercase (standard for Qt Slots and Properties)
|
||||||
|
"N815", # Class attribute should not be mixedCase (standard for Qt Properties and Signals)
|
||||||
|
"E702", # Multiple statements on one line (semicolon, standard for inline Qt property setters with self.update())
|
||||||
|
]
|
||||||
|
|
||||||
|
# ===== Mypy Static Type Checker =====
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
check_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,7 @@ PySide6>=6.6.0
|
|||||||
pyqtgraph>=0.13.0
|
pyqtgraph>=0.13.0
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
pyserial>=3.5
|
pyserial>=3.5
|
||||||
|
pandas>=2.0.0
|
||||||
|
matplotlib>=3.7.0
|
||||||
|
scipy>=1.10.0
|
||||||
|
pyyaml>=6.0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import QtQuick
|
|||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
|
||||||
// ===== Home Workspace Shell =====
|
// ===== Home Workspace Shell =====
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -13,18 +14,148 @@ Rectangle {
|
|||||||
border.width: Theme.borderStrong
|
border.width: Theme.borderStrong
|
||||||
|
|
||||||
// ===== Navigation Model =====
|
// ===== Navigation Model =====
|
||||||
// Primary navigation shown in the left rail.
|
// ---------------------------------------------------------------------------
|
||||||
property var sideActions: ["DETECT WAFER", "READ MEMORY", "OPEN CSV IN EXCEL", "ERASE MEMORY", "IMPORT DATA", "STORED DATA"]
|
// TODO P1.1: Delete these 3 property blocks — superseded by mockup rail
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// The new rail uses a pill-tab-bar for navigation (Status/Data/Map) instead
|
||||||
|
// of sideActions[] + bottomTabs[]. The Repeaters below that consume these
|
||||||
|
// arrays are also deleted with the old layout (P1.1).
|
||||||
|
// selectedSideActionIndex is dead — the new rail has no concept of a
|
||||||
|
// globally-selected side action. selectedTabIndex stays, redefined below.
|
||||||
|
//
|
||||||
|
// Cross-ref: P1.2 (pill tabs), P1.3 (context panels), P1.4 (workspace)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
property var sideActions: ["DETECT WAFER", "READ MEMORY", "ERASE MEMORY"]
|
||||||
|
|
||||||
// ===== Footer Tab Model =====
|
// ===== Footer Tab Model =====
|
||||||
// Footer tabs drive the active workspace section.
|
property var bottomTabs: ["Status", "Data", "Wafer Map", "Settings"]
|
||||||
property var bottomTabs: ["Status", "Graph", "Data", "Wafer Map", "Compare", "Split", "Settings", "About"]
|
|
||||||
|
|
||||||
// ===== View State =====
|
// ===== View State =====
|
||||||
property int selectedTabIndex: 0
|
property int selectedTabIndex: 0
|
||||||
property int selectedSideActionIndex: -1 // nothing active on startup
|
property int selectedSideActionIndex: -1
|
||||||
|
|
||||||
// ===== Main Two-Column Layout =====
|
property bool memoryRead: false
|
||||||
|
property bool waferDetected: false
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onDetectResult(result){
|
||||||
|
//result is a waferInfo dict on success, None on failure
|
||||||
|
root.waferDetected = (result != null && result!= undefined)
|
||||||
|
}
|
||||||
|
function onParsedDataReady(result) {
|
||||||
|
if (result.success && result.csv_path) {
|
||||||
|
// Load the freshly read CSV into the player
|
||||||
|
streamController.loadFile(result.csv_path)
|
||||||
|
|
||||||
|
// Automatically switch to the "Wafer Map Tab" (index 3)
|
||||||
|
root.selectedTabIndex = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onReadResult(result) {
|
||||||
|
// result has "success" key on success, "error" key on failure
|
||||||
|
root.memoryRead = (result !== null &&
|
||||||
|
result !== undefined &&
|
||||||
|
result.success === true)
|
||||||
|
|
||||||
|
if (root.memoryRead) {
|
||||||
|
console.log(`[P1.1] Memory read complete: ${result.bytes} bytes`)
|
||||||
|
} else {
|
||||||
|
console.log(`[P1.1] Read failed: ${result.error || "unknown"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanFolderUrl(url) {
|
||||||
|
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doDetect() {
|
||||||
|
root.memoryRead = false
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.detectWafer()
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderDialog {
|
||||||
|
id: saveDirDialog
|
||||||
|
title: "Choose a folder to save Data"
|
||||||
|
onAccepted: {
|
||||||
|
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||||
|
root._doDetect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TODO P1.1: Delete importFileDialog + storedDataDialog (and the
|
||||||
|
// `selectedTabIndex = 3` lines inside them)
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// These dialogs were only opened from the old side-rail buttons
|
||||||
|
// "IMPORT DATA" (index 4) and "STORED DATA" (index 5). With the
|
||||||
|
// mockup rail, the Map tab's file list (P1.3) replaces both — it
|
||||||
|
// drives `streamController.loadFile()` directly from a file_browser
|
||||||
|
// Repeater. Keeping these dead dialogs would bloat the QML and
|
||||||
|
// confuse future contributors.
|
||||||
|
//
|
||||||
|
// `selectedTabIndex = 3` is also stale (Map tab is index 2 now).
|
||||||
|
// The replacement flow: file_row.onClicked → loadFile() →
|
||||||
|
// selectedTabIndex = 2.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
FileDialog {
|
||||||
|
id: importFileDialog
|
||||||
|
title: "Import CSV / ZWafer file"
|
||||||
|
nameFilters: ["CSV files (*.csv)", "ZWafer files (*.zwafer)", "All files (*)"]
|
||||||
|
onAccepted: {
|
||||||
|
var path = root.cleanFolderUrl(selectedFile)
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
streamController.loadFile(path)
|
||||||
|
root.selectedTabIndex = 3 // Switch to Wafer Map tab
|
||||||
|
root.selectedSideActionIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: storedDataDialog
|
||||||
|
title: "Open Stored CSV File"
|
||||||
|
nameFilters: ["CSV files (*.csv)", "All files (*)"]
|
||||||
|
onAccepted: {
|
||||||
|
var path = root.cleanFolderUrl(selectedFile)
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
streamController.loadFile(path)
|
||||||
|
root.selectedTabIndex = 3 // Switch to Wafer Map tab
|
||||||
|
root.selectedSideActionIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Main Two-Column Layout ======
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TODO P1.1: Delete the ENTIRE RowLayout below (lines ~112-341) — old
|
||||||
|
// side-rail + workspace + footer-tab-strip.
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// This ~230-line RowLayout is being replaced by:
|
||||||
|
// P1.2 — Pill tab bar (BOX 1, top of rail)
|
||||||
|
// P1.3 — Context-sensitive panel (BOX 2, mid rail)
|
||||||
|
// P1.5 — Hardware status footer (BOX 3, below BOX 2)
|
||||||
|
// P1.6 — Settings/About utility buttons (BOX 4, rail bottom)
|
||||||
|
// P1.4 — Workspace StackLayout (fills right side)
|
||||||
|
//
|
||||||
|
// None of the old structure (Repeater for sideActions[], Repeater for
|
||||||
|
// bottomTabs[], footer tab strip) survives. Keeping it would cause
|
||||||
|
// double-rendering or visual conflicts.
|
||||||
|
//
|
||||||
|
// Keep: saveDirDialog (FolderDialog), cleanFolderUrl(), _doDetect(),
|
||||||
|
// and all Connections blocks (lines 31-61).
|
||||||
|
//
|
||||||
|
// Cross-ref: P1.2, P1.3, P1.4, P1.5, P1.6
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
@@ -53,6 +184,14 @@ Rectangle {
|
|||||||
Button {
|
Button {
|
||||||
id: control
|
id: control
|
||||||
text: modelData
|
text: modelData
|
||||||
|
enabled: {
|
||||||
|
switch (index) {
|
||||||
|
case 0: return true // DETECT WAFER
|
||||||
|
case 1: return root.waferDetected // READ MEMORY
|
||||||
|
case 2: return root.waferDetected // ERASE MEMORY
|
||||||
|
default: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
property bool isActive: index === root.selectedSideActionIndex
|
property bool isActive: index === root.selectedSideActionIndex
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight)
|
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight)
|
||||||
@@ -60,9 +199,24 @@ Rectangle {
|
|||||||
onClicked: {
|
onClicked: {
|
||||||
root.selectedSideActionIndex = index
|
root.selectedSideActionIndex = index
|
||||||
root.selectedTabIndex = 0 // always jump to Status tab
|
root.selectedTabIndex = 0 // always jump to Status tab
|
||||||
if (index === 0) deviceController.detectWafer()
|
if (index === 0) {
|
||||||
else if (index === 1) deviceController.readMemoryAsync()
|
if (!deviceController.saveDataDir) {
|
||||||
else if (index === 2) deviceController.openCsvFile()
|
saveDirDialog.open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root._doDetect()
|
||||||
|
}
|
||||||
|
else if (index === 1) {
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.readMemoryAsync()
|
||||||
|
}
|
||||||
|
else if (index === 2) {
|
||||||
|
// ERASE MEMORY
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.eraseMemory(deviceController.selectedPort || "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -158,11 +312,23 @@ Rectangle {
|
|||||||
|
|
||||||
Label {
|
Label {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data"
|
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" && parent.tabName !== "Wafer Map" && parent.tabName !== "Graph"
|
||||||
text: parent.tabName + " content"
|
text: parent.tabName + " content"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 20
|
font.pixelSize: 20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
anchors.fill: parent
|
||||||
|
active: parent.tabName === "Wafer Map"
|
||||||
|
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
anchors.fill: parent
|
||||||
|
active: parent.tabName === "Graph"
|
||||||
|
source: parent.tabName === "Graph" ? "Tabs/GraphTab.qml" : ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +342,7 @@ Rectangle {
|
|||||||
color: Theme.tabBarBackground
|
color: Theme.tabBarBackground
|
||||||
border.color: Theme.workspaceBorder
|
border.color: Theme.workspaceBorder
|
||||||
border.width: Theme.borderThin
|
border.width: Theme.borderThin
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -4,8 +4,8 @@ import ISC
|
|||||||
// ===== App Window Shell =====
|
// ===== App Window Shell =====
|
||||||
Window {
|
Window {
|
||||||
// ===== Window Dimensions =====
|
// ===== Window Dimensions =====
|
||||||
width: 1400
|
width: 1920
|
||||||
height: 820
|
height: 1080
|
||||||
minimumWidth: 1100
|
minimumWidth: 1100
|
||||||
minimumHeight: 700
|
minimumHeight: 700
|
||||||
visible: true
|
visible: true
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.TableView
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Data Tab =====
|
// ===== Data Tab =====
|
||||||
@@ -74,6 +75,29 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Toolbar ---
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Open in Excel"
|
||||||
|
icon.source: "icon/excel.svg"
|
||||||
|
onClicked: deviceController.openCsvFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Compare"
|
||||||
|
onClicked: compareDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Split"
|
||||||
|
onClicked: splitDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true}
|
||||||
|
}
|
||||||
// --- Table container ---
|
// --- Table container ---
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// ===== Graph Tab =====
|
||||||
|
// Displays sensor temperature line charts using GraphQuickItem.
|
||||||
|
// Gets data from deviceController.getChartData() (parsed CSV, batch read)
|
||||||
|
// or from streamController.trendData (live streaming trend).
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
property var _chartData: null // cached result from getChartData()
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: Theme.rightPaneGap
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Line Chart"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
// Refresh button — pulls data from deviceController
|
||||||
|
Button {
|
||||||
|
id: refreshBtn
|
||||||
|
text: "REFRESH"
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.weight: Font.Medium
|
||||||
|
implicitHeight: 26
|
||||||
|
implicitWidth: 72
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mode selector (Trend / Full chart) ─────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Source:"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ComboBox {
|
||||||
|
id: sourceSelector
|
||||||
|
model: ["Parsed Data", "Live Trend"]
|
||||||
|
currentIndex: 0
|
||||||
|
implicitHeight: 26
|
||||||
|
font.pixelSize: 11
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: sourceSelector.displayText
|
||||||
|
color: Theme.fieldText
|
||||||
|
font: sourceSelector.font
|
||||||
|
horizontalAlignment: Text.AlignLeft
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
leftPadding: 8
|
||||||
|
}
|
||||||
|
onCurrentIndexChanged: reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: statusLabel
|
||||||
|
text: "Ready"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.italic: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chart Area ─────────────────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: graph
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 2
|
||||||
|
backgroundColor: Theme.cardBackground
|
||||||
|
gridColor: Theme.softBorder
|
||||||
|
axisColor: Theme.bodyColor
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
seriesColors: [Theme.sensorLow, Theme.sensorHigh, Theme.sensorInRange,
|
||||||
|
"#FFA726", "#AB47BC", "#00BCD4"]
|
||||||
|
showLegend: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data Loading ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function reloadData() {
|
||||||
|
if (sourceSelector.currentIndex === 0) {
|
||||||
|
// Parsed data from DeviceController
|
||||||
|
loadParsedData()
|
||||||
|
} else {
|
||||||
|
// Live trend from SessionController
|
||||||
|
loadLiveTrend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadParsedData() {
|
||||||
|
statusLabel.text = "Loading..."
|
||||||
|
var result = deviceController.getChartData()
|
||||||
|
if (!result || !result.success) {
|
||||||
|
graph.seriesData = []
|
||||||
|
graph.sensorNames = []
|
||||||
|
graph.title = "Sensor Temperature Over Time"
|
||||||
|
statusLabel.text = "No data — read a wafer first"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
graph.seriesData = result.series || []
|
||||||
|
graph.sensorNames = result.sensor_names || []
|
||||||
|
graph.title = "Sensor Temperature Over Time"
|
||||||
|
statusLabel.text = (result.series ? result.series.length : 0) + " sensors loaded"
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLiveTrend() {
|
||||||
|
// Live trend: connects to streamController.trendData
|
||||||
|
// The trend data comes as a JSON array of per-frame averages
|
||||||
|
statusLabel.text = "Connecting to live trend..."
|
||||||
|
// We need at least one data point
|
||||||
|
if (streamController.stats && streamController.stats.avg !== undefined) {
|
||||||
|
// Build a single-series graph from the trend buffer
|
||||||
|
// For now show a placeholder — the trendData signal handles updates
|
||||||
|
graph.title = "Live Average Temperature"
|
||||||
|
graph.yLabel = "Avg Temperature (°C)"
|
||||||
|
graph.xLabel = "Time (s)"
|
||||||
|
statusLabel.text = "Live trend — waiting for data..."
|
||||||
|
} else {
|
||||||
|
statusLabel.text = "No live data — start a stream on the Wafer Map tab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connections ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onParsedDataReady(result) {
|
||||||
|
if (sourceSelector.currentIndex === 0) {
|
||||||
|
root.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onTrendData(avgsJson) {
|
||||||
|
if (sourceSelector.currentIndex === 1) {
|
||||||
|
try {
|
||||||
|
var avgs = JSON.parse(avgsJson)
|
||||||
|
if (avgs && avgs.length > 0) {
|
||||||
|
graph.seriesData = [avgs]
|
||||||
|
graph.sensorNames = ["Average"]
|
||||||
|
graph.title = "Live Average Temperature"
|
||||||
|
graph.yLabel = "Avg Temperature (°C)"
|
||||||
|
graph.xLabel = "Frame"
|
||||||
|
statusLabel.text = "Live: " + avgs.length + " data points"
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On startup, try to load parsed data if available
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (deviceController.dataRowCount > 0) {
|
||||||
|
reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,25 +107,29 @@ Item {
|
|||||||
x: toggle.leftPadding
|
x: toggle.leftPadding
|
||||||
y: parent.height / 2 - height / 2
|
y: parent.height / 2 - height / 2
|
||||||
radius: Theme.radiusXs
|
radius: Theme.radiusXs
|
||||||
color: toggle.checked ? Theme.primaryAccent : Theme.fieldBackground
|
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||||
border.width: Theme.borderThin
|
border.width: Theme.borderThin
|
||||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||||
|
|
||||||
Rectangle {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
width: 9
|
text: "✓"
|
||||||
height: 9
|
font.pixelSize: 14
|
||||||
radius: Theme.radiusXs
|
font.bold: true
|
||||||
|
color: Theme.panelBackground
|
||||||
visible: toggle.checked
|
visible: toggle.checked
|
||||||
color: Theme.fieldBackground
|
// Small nudge to center the checkmark perfectly
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
contentItem: Text {
|
contentItem: Text {
|
||||||
text: toggle.text
|
text: toggle.text
|
||||||
color: Theme.checkboxText
|
color: Theme.headingColor
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
leftPadding: toggle.indicator.width + toggle.spacing
|
leftPadding: toggle.indicator.width + toggle.spacing
|
||||||
|
font.pixelSize: 13
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +225,7 @@ Item {
|
|||||||
Layout.preferredWidth: 90
|
Layout.preferredWidth: 90
|
||||||
Layout.preferredHeight: Theme.settingsButtonHeight
|
Layout.preferredHeight: Theme.settingsButtonHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
settingsModel.setChamberId(chamberField.text);
|
settingsModel.ChamberId = chamberField.text;
|
||||||
settingsModel.saveSettings();
|
settingsModel.saveSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +296,7 @@ Item {
|
|||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
text: "Reverse Z Wafer"
|
text: "Reverse Z Wafer"
|
||||||
checked: settingsModel.reverseZWafer
|
checked: settingsModel.reverseZWafer
|
||||||
onToggled: settingsModel.setReverseZWafer(checked)
|
onToggled: settingsModel.reverseZWafer = checked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Status Tab =====
|
// ===== Status Tab =====
|
||||||
// Blank on startup. Fills in after Detect Wafer fires from the left rail.
|
// Shows connection status, wafer info, and activity log.
|
||||||
|
// Initially visible — displays connection status and activity log from start.
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -18,40 +20,44 @@ ColumnLayout {
|
|||||||
property alias waferCycles: waferCycles.text
|
property alias waferCycles: waferCycles.text
|
||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
|
|
||||||
// Latches true once any operation starts (or a previous session is
|
|
||||||
// restored) and never resets, so the status panel stays visible even
|
|
||||||
// when a detect finds no wafer.
|
|
||||||
property bool statusActive: false
|
|
||||||
|
|
||||||
// Data summary after parse
|
// Data summary after parse
|
||||||
property int dataRows: 0
|
property int dataRows: 0
|
||||||
property int dataCols: 0
|
property int dataCols: 0
|
||||||
property string csvPath: ""
|
property string csvPath: ""
|
||||||
property bool dataParsed: false
|
property bool dataParsed: false
|
||||||
|
|
||||||
// ===== Empty state — nothing shown until detect fires =====
|
function cleanFolderUrl(url) {
|
||||||
Item {
|
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
||||||
Layout.fillWidth: true
|
}
|
||||||
Layout.fillHeight: true
|
|
||||||
visible: !root.statusActive
|
function currentFamilyCode() {
|
||||||
|
var info = deviceController.lastWaferInfo
|
||||||
|
return info && info.length > 0 ? (info[0] || "") : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAndSavePendingRead() {
|
||||||
|
deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderDialog {
|
||||||
|
id: saveDirDialog
|
||||||
|
title: "Choose a folder to save."
|
||||||
|
onAccepted: {
|
||||||
|
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||||
|
root.parseAndSavePendingRead()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Results area (shown once detect/operation runs) =====
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
visible: root.statusActive
|
|
||||||
spacing: Theme.rightPaneGap
|
spacing: Theme.rightPaneGap
|
||||||
|
|
||||||
// --- Connection Status ---
|
// --- Connection Status ---
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 86
|
Layout.preferredHeight: 92
|
||||||
color: {
|
color: Theme.cardBackground
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor + "22"
|
|
||||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor + "22"
|
|
||||||
return Theme.cardBackground
|
|
||||||
}
|
|
||||||
border.color: {
|
border.color: {
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
||||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
||||||
@@ -83,13 +89,44 @@ ColumnLayout {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "Save dir: " + deviceController.saveDataDir
|
text: "Save dir: " + (deviceController.saveDataDir || "None selected")
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
opacity: 0.7
|
opacity: 0.7
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: browseBtn
|
||||||
|
text: "BROWSE..."
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.weight: Font.Medium
|
||||||
|
implicitHeight: 20
|
||||||
|
implicitWidth: 65
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: saveDirDialog.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,26 +224,104 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Activity Log ---
|
// --- Activity Log ---
|
||||||
GroupBox {
|
Rectangle {
|
||||||
title: "Activity Log"
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Header
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: Theme.panelPadding
|
||||||
|
anchors.rightMargin: Theme.panelPadding
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "ACTIVITY LOG"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.letterSpacing: 1.8
|
||||||
|
font.weight: Font.Medium
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "REFRESH SESSION"
|
||||||
|
font.pixelSize: 10
|
||||||
|
implicitHeight: 24
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: deviceController.clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "CLEAR LOG"
|
||||||
|
font.pixelSize: 10
|
||||||
|
implicitHeight: 24
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: deviceController.clearActivityLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
anchors.fill: parent
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.margins: Theme.panelPadding
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
TextArea {
|
TextArea {
|
||||||
id: activityLog
|
id: activityLog
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: ""
|
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
||||||
readOnly: true
|
readOnly: true
|
||||||
font.family: "monospace"
|
font.family: "monospace"
|
||||||
font.pixelSize: 12
|
font.pixelSize: 12
|
||||||
wrapMode: TextArea.WordWrap
|
wrapMode: TextArea.WordWrap
|
||||||
leftPadding: 4
|
|
||||||
rightPadding: 4
|
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
|
background: null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,14 +337,20 @@ ColumnLayout {
|
|||||||
// --- Signal Handlers ---
|
// --- Signal Handlers ---
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
// Any operation start (detect/read/erase/debug) latches the panel on.
|
|
||||||
function onPortsUpdated() {
|
function onLogMessage(message) {
|
||||||
if (deviceController.operationInProgress)
|
if (message == "CHOOSE_SAVE_DIR"){
|
||||||
root.statusActive = true
|
saveDirDialog.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any operation start (detect/read/erase/debug) latches the panel on.
|
||||||
|
function onPortsUpdated() {
|
||||||
|
// Status tab always visible now
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function onDetectResult(result) {
|
function onDetectResult(result) {
|
||||||
root.statusActive = true
|
|
||||||
if (result && result.familyCode) {
|
if (result && result.familyCode) {
|
||||||
root.waferDetected = true
|
root.waferDetected = true
|
||||||
waferInfoFamily.text = result.familyCode
|
waferInfoFamily.text = result.familyCode
|
||||||
@@ -245,7 +366,6 @@ ColumnLayout {
|
|||||||
// Show restored wafer info if available
|
// Show restored wafer info if available
|
||||||
var info = deviceController.lastWaferInfo
|
var info = deviceController.lastWaferInfo
|
||||||
if (info && info.length > 0) {
|
if (info && info.length > 0) {
|
||||||
root.statusActive = true
|
|
||||||
root.waferDetected = true
|
root.waferDetected = true
|
||||||
waferInfoFamily.text = info[0] || "—"
|
waferInfoFamily.text = info[0] || "—"
|
||||||
waferSerial.text = info[1] || "—"
|
waferSerial.text = info[1] || "—"
|
||||||
@@ -255,7 +375,6 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
// Show data summary if data was previously parsed
|
// Show data summary if data was previously parsed
|
||||||
if (deviceController.dataRowCount > 0) {
|
if (deviceController.dataRowCount > 0) {
|
||||||
root.statusActive = true
|
|
||||||
root.dataParsed = true
|
root.dataParsed = true
|
||||||
root.dataRows = deviceController.dataRowCount
|
root.dataRows = deviceController.dataRowCount
|
||||||
root.dataCols = deviceController.dataColCount
|
root.dataCols = deviceController.dataColCount
|
||||||
@@ -271,6 +390,9 @@ ColumnLayout {
|
|||||||
root.dataRows = 0
|
root.dataRows = 0
|
||||||
root.dataCols = 0
|
root.dataCols = 0
|
||||||
root.csvPath = ""
|
root.csvPath = ""
|
||||||
|
|
||||||
|
if (result && result.success === true)
|
||||||
|
saveDirDialog.open()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
// Live elapsed-time counter
|
||||||
|
property int _liveSecs: 0
|
||||||
|
Timer {
|
||||||
|
id: liveTimer
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||||
|
onTriggered: root._liveSecs++
|
||||||
|
onRunningChanged: if (!running) root._liveSecs = 0
|
||||||
|
}
|
||||||
|
function fmtTime(s) {
|
||||||
|
var m = Math.floor(s / 60)
|
||||||
|
var ss = s % 60
|
||||||
|
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss
|
||||||
|
}
|
||||||
|
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
|
||||||
|
// into the trend chart's data property. The slot parseJsonToData() is defined
|
||||||
|
// on the Python TrendChartItem; we use it so malformed payloads are logged
|
||||||
|
// there instead of crashing the QML binding.
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onTrendData(avgsJson) {
|
||||||
|
trendChart.setDataFromJson(avgsJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Mode toggle
|
||||||
|
TabBar {
|
||||||
|
id: modeBar
|
||||||
|
currentIndex: 0 // Default to "Review"
|
||||||
|
spacing: 2
|
||||||
|
padding: 2
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
}
|
||||||
|
TabButton {
|
||||||
|
text: "Review"
|
||||||
|
implicitWidth: 64; implicitHeight: 28
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusSm - 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TabButton {
|
||||||
|
text: "Live"
|
||||||
|
enabled: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
implicitWidth: 58; implicitHeight: 28
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusSm - 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onCurrentIndexChanged:
|
||||||
|
if (currentIndex === 1){
|
||||||
|
// Guard: revert to Review if not connected
|
||||||
|
if (deviceController.connectionStatus !== "Connected") {
|
||||||
|
currentIndex = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Entering Live mode
|
||||||
|
streamController.setMode("live")
|
||||||
|
var fc = ""
|
||||||
|
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
||||||
|
fc = deviceController.lastWaferInfo[0]
|
||||||
|
}
|
||||||
|
streamController.startStream(deviceController.selectedPort, fc)
|
||||||
|
} else {
|
||||||
|
// Entering Review Mode (automatically calls stopStream in backend)
|
||||||
|
streamController.setMode("review")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live source info: WiFi icon + port · serial
|
||||||
|
RowLayout {
|
||||||
|
visible: streamController.mode === "live"
|
||||||
|
spacing: 5
|
||||||
|
// WiFi icon (Unicode approximation; swap for SVG if available)
|
||||||
|
Label {
|
||||||
|
id: liveIndicator
|
||||||
|
text: "◉"
|
||||||
|
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle")
|
||||||
|
? Theme.liveColor : Theme.bodyColor
|
||||||
|
font.pixelSize: 13
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
||||||
|
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
||||||
|
onRunningChanged: {
|
||||||
|
if (!running) liveIndicator.opacity = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: "Live stream"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
// SET badge + avg temp
|
||||||
|
Rectangle {
|
||||||
|
visible: streamController.state === "set"
|
||||||
|
height: 22; radius: Theme.radiusSm
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Theme.liveColor
|
||||||
|
border.width: 1
|
||||||
|
implicitWidth: setLabel.implicitWidth + 16
|
||||||
|
Label {
|
||||||
|
id: setLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "SET " + (streamController.stats.avg !== undefined
|
||||||
|
? streamController.stats.avg + " avg" : "")
|
||||||
|
color: Theme.liveColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record indicator
|
||||||
|
RowLayout {
|
||||||
|
spacing: 5
|
||||||
|
visible: streamController.recording
|
||||||
|
Rectangle {
|
||||||
|
width: 7; height: 7; radius: 4
|
||||||
|
color: Theme.recordColor
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: streamController.recording
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.2; duration: 600 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 600 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: "REC"
|
||||||
|
color: Theme.recordColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIVE timer
|
||||||
|
// RowLayout {
|
||||||
|
// spacing: 5
|
||||||
|
// visible: streamController.mode === "live" && streamController.state !== "idle"
|
||||||
|
// Rectangle {
|
||||||
|
// width: 7; height: 7; radius: 4
|
||||||
|
// color: Theme.liveColor
|
||||||
|
// }
|
||||||
|
// Label {
|
||||||
|
// text: "LIVE " + root.fmtTime(root._liveSecs)
|
||||||
|
// color: Theme.liveColor
|
||||||
|
// font.pixelSize: 10
|
||||||
|
// font.weight: Font.Medium
|
||||||
|
// font.letterSpacing: 0.8
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// IDLE label (review / not connected)
|
||||||
|
// Label {
|
||||||
|
// visible: streamController.mode === "review" || streamController.state === "idle"
|
||||||
|
// text: streamController.state.toUpperCas`e`()
|
||||||
|
// color: Theme.bodyColor
|
||||||
|
// font.pixelSize: 11
|
||||||
|
// font.weight: Font.Medium
|
||||||
|
// font.letterSpacing: 1.2
|
||||||
|
// }
|
||||||
|
Button{
|
||||||
|
text: "Export PNG"
|
||||||
|
onClicked: exportDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: exportDialog
|
||||||
|
title: "Export Wafer Map"
|
||||||
|
fileMode: FileDialog.SaveFile
|
||||||
|
nameFilters: ["PNG files (*.png)"]
|
||||||
|
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body: 3 zones (TODO P2.1: becomes 2 zones) ──────────────────────
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// TODO P2.1: Delete the SourcePanel Rectangle (lines ~219-233) —
|
||||||
|
// the file list moved to the rail (P1.3 Map context panel).
|
||||||
|
//
|
||||||
|
// THINKING:
|
||||||
|
// SourcePanel shows a file list driven by file_browser — now that
|
||||||
|
// the Map tab's context panel in the rail (P1.3) provides the same
|
||||||
|
// file list with search/filter, SourcePanel in the wafer body is
|
||||||
|
// redundant. The wafer map gets more horizontal space.
|
||||||
|
//
|
||||||
|
// After deletion: RowLayout has 2 children instead of 3.
|
||||||
|
// Add Layout.fillWidth: true to the center ColumnLayout so it
|
||||||
|
// fills the vacated width.
|
||||||
|
//
|
||||||
|
// Cross-ref: P1.3 (Map context panel), P2.1 (this task)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// Source panel — bordered card
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 220
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
SourcePanel {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
WaferMapView {
|
||||||
|
id: waferView
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
blend: readoutPanel.heatmapBlend
|
||||||
|
showLabels: readoutPanel.showLabels
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
id: trendPane
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 160
|
||||||
|
visible: trendChart.hasData
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
TrendChartItem {
|
||||||
|
id: trendChart
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true; implicitHeight: 16 }
|
||||||
|
TransportBar {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: streamController.mode !== "live" || trendChart.hasData
|
||||||
|
height: visible ? implicitHeight : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readout panel — bordered card
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 220
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ReadoutPanel {
|
||||||
|
id: readoutPanel
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 0
|
||||||
|
property var s: streamController.stats
|
||||||
|
property alias showLabels: labelsToggle.checked
|
||||||
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
|
|
||||||
|
component PanelCheckBox: CheckBox {
|
||||||
|
id: toggle
|
||||||
|
indicator: Rectangle {
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
x: toggle.leftPadding
|
||||||
|
y: parent.height / 2 - height / 2
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "✓"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.panelBackground
|
||||||
|
visible: toggle.checked
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
text: toggle.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
leftPadding: toggle.indicator.width + toggle.spacing
|
||||||
|
font.pixelSize: toggle.font.pixelSize || 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── READOUT ───────────────────────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: "READOUT"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.letterSpacing: 1.8
|
||||||
|
font.weight: Font.Medium
|
||||||
|
topPadding: 6
|
||||||
|
bottomPadding: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: statsLayout.implicitHeight + 20
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: statsLayout
|
||||||
|
anchors { fill: parent; margins: 10 }
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
// Min Temp Row
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label { text: "Min Temp"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
Label {
|
||||||
|
text: s.min !== undefined ? s.min : "—"
|
||||||
|
color: Theme.sensorLow
|
||||||
|
font.pixelSize: 13; font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: s.minIndex !== undefined ? "#" + s.minIndex : ""
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max Temp Row
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label { text: "Max Temp"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
Label {
|
||||||
|
text: s.max !== undefined ? s.max : "—"
|
||||||
|
color: Theme.sensorHigh
|
||||||
|
font.pixelSize: 13; font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: s.maxIndex !== undefined ? "#" + s.maxIndex : ""
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
||||||
|
|
||||||
|
// Differential Row
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label { text: "Differential"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.diff !== undefined ? s.diff : "—"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 13; font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average Row
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label { text: "Average"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.avg !== undefined ? s.avg : "—"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 13; font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
||||||
|
|
||||||
|
// Sigma Row
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label { text: "Sigma (σ)"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.sigma !== undefined ? s.sigma : "—"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 13; font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-Sigma Row
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label { text: "3σ Value"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Label {
|
||||||
|
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 12; font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { height: 12 }
|
||||||
|
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
||||||
|
Item { height: 12 }
|
||||||
|
|
||||||
|
// ── DISPLAY ───────────────────────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: "DISPLAY"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.letterSpacing: 1.8
|
||||||
|
font.weight: Font.Medium
|
||||||
|
topPadding: 6
|
||||||
|
bottomPadding: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: thicknessToggle
|
||||||
|
text: "Show Thickness"
|
||||||
|
checked: false
|
||||||
|
font.pixelSize: 11
|
||||||
|
onCheckedChanged: waferMapItem.showThickness = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: labelsToggle
|
||||||
|
text: "Labels"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: clusterAverageToggle
|
||||||
|
text: "Average Clusters"
|
||||||
|
checked: streamController.clusterAveragingEnabled
|
||||||
|
font.pixelSize: 11
|
||||||
|
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
Label {
|
||||||
|
text: "Heatmap"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
Layout.preferredWidth: 52
|
||||||
|
}
|
||||||
|
Slider {
|
||||||
|
id: heatmapSlider
|
||||||
|
from: 0; to: 1; value: 0
|
||||||
|
Layout.fillWidth: true
|
||||||
|
ToolTip.visible: hovered
|
||||||
|
ToolTip.text: Math.round(value * 100) + "%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { height: 12 }
|
||||||
|
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
||||||
|
Item { height: 12 }
|
||||||
|
|
||||||
|
// ── THRESHOLDS ────────────────────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: "THRESHOLDS"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.letterSpacing: 1.8
|
||||||
|
font.weight: Font.Medium
|
||||||
|
topPadding: 6
|
||||||
|
bottomPadding: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Set Point (°C)"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
bottomPadding: 3
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: spField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "149.0"
|
||||||
|
font.pixelSize: 12
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: spField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: spField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
}
|
||||||
|
onEditingFinished: pushThresholds()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { height: 10 }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Margin (±°C)"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
bottomPadding: 3
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: mgField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "1.0"
|
||||||
|
font.pixelSize: 12
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: mgField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: mgField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
}
|
||||||
|
onEditingFinished: pushThresholds()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { height: 10 }
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: autoCheck
|
||||||
|
text: "Auto range (mean ± 1σ)"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: 11
|
||||||
|
onCheckedChanged: pushThresholds()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
function pushThresholds() {
|
||||||
|
var sp = parseFloat(spField.text) || 149.0
|
||||||
|
var mg = parseFloat(mgField.text) || 1.0
|
||||||
|
streamController.setThresholds(sp, mg, autoCheck.checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: root
|
||||||
|
title: "Sensor Override"
|
||||||
|
modal: true
|
||||||
|
standardButtons: Dialog.NoButton
|
||||||
|
width: 300
|
||||||
|
|
||||||
|
// called by WaferMapView's TapHandler
|
||||||
|
property int sensorIndex: -1
|
||||||
|
property string sensorLabel: ""
|
||||||
|
property real currentValue: 0.0
|
||||||
|
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
|
||||||
|
|
||||||
|
function openFor(index) {
|
||||||
|
sensorIndex = index
|
||||||
|
var dot = streamController.sensorDots[index]
|
||||||
|
if (!dot) return
|
||||||
|
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1)
|
||||||
|
currentValue = dot.value
|
||||||
|
valueField.text = ""
|
||||||
|
offsetField.text = ""
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
radius: Theme.radiusLg
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Theme.rightPaneGap
|
||||||
|
|
||||||
|
// ── header ───────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label {
|
||||||
|
text: "Sensor #" + (root.sensorIndex + 1) +
|
||||||
|
(root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 13
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
visible: root.hasOverride
|
||||||
|
width: overrideTag.implicitWidth + 12
|
||||||
|
height: overrideTag.implicitHeight + 6
|
||||||
|
radius: 4
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
opacity: 0.85
|
||||||
|
Label {
|
||||||
|
id: overrideTag
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "OVERRIDE"
|
||||||
|
color: "white"
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Live: " + root.currentValue.toFixed(2)
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
||||||
|
|
||||||
|
// ── replace field ─────────────────────────────────────────
|
||||||
|
Label { text: "Replace with value"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
TextField {
|
||||||
|
id: valueField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "100"
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── offset field ──────────────────────────────────────────
|
||||||
|
Label { text: "Or add offset (± delta)"; color: Theme.bodyColor; font.pixelSize: 11 }
|
||||||
|
TextField {
|
||||||
|
id: offsetField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "e.g. +0.5 or -0.5 (blank = skip)"
|
||||||
|
inputMethodHints: Qt.ImhFormattedNumbersOnly
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
||||||
|
|
||||||
|
// ── buttons ───────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: applyBtn
|
||||||
|
text: "Apply"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: valueField.text !== "" || offsetField.text !== ""
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: applyBtn.down ? Theme.buttonNeutralPressed : applyBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: applyBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
if (valueField.text !== "") {
|
||||||
|
var v = parseFloat(valueField.text)
|
||||||
|
if (!isNaN(v))
|
||||||
|
streamController.replaceSensor(root.sensorIndex, v)
|
||||||
|
}
|
||||||
|
if (offsetField.text !== "") {
|
||||||
|
var d = parseFloat(offsetField.text)
|
||||||
|
if (!isNaN(d))
|
||||||
|
streamController.offsetSensor(root.sensorIndex, d)
|
||||||
|
}
|
||||||
|
root.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
id: clearBtn
|
||||||
|
text: "Clear"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: root.hasOverride
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: clearBtn.down ? Theme.buttonNeutralPressed : clearBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: clearBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
streamController.clearSensorEdit(root.sensorIndex)
|
||||||
|
root.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
id: cancelBtn
|
||||||
|
text: "Cancel"
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: cancelBtn.down ? Theme.buttonNeutralPressed : cancelBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: cancelBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 13
|
||||||
|
}
|
||||||
|
onClicked: root.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ".."
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
// ── Section label & Refresh ───────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SOURCE"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
font.letterSpacing: 1.8
|
||||||
|
font.weight: Font.Medium
|
||||||
|
topPadding: 6
|
||||||
|
bottomPadding: 8
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: editBtn
|
||||||
|
text: "✎"
|
||||||
|
implicitWidth: 26
|
||||||
|
implicitHeight: 26
|
||||||
|
hoverEnabled: true
|
||||||
|
font.pixelSize: 14
|
||||||
|
background: Rectangle {
|
||||||
|
color: editBtn.pressed ? Theme.buttonPressed
|
||||||
|
: editBtn.hovered ? Theme.buttonNeutralHover
|
||||||
|
: "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
file_browser.refreshFiles()
|
||||||
|
csvEditorDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: refreshBtn
|
||||||
|
text: "⟳"
|
||||||
|
implicitWidth: 26
|
||||||
|
implicitHeight: 26
|
||||||
|
hoverEnabled: true
|
||||||
|
font.pixelSize: 14
|
||||||
|
background: Rectangle {
|
||||||
|
color: refreshBtn.pressed ? Theme.buttonPressed
|
||||||
|
: refreshBtn.hovered ? Theme.buttonNeutralHover
|
||||||
|
: "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: file_browser.refreshFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Directory button ──────────────────────────────────────────────────
|
||||||
|
Button {
|
||||||
|
id: dirBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
hoverEnabled: true
|
||||||
|
implicitHeight: 32
|
||||||
|
leftPadding: 8
|
||||||
|
rightPadding: 8
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: dirBtn.pressed ? Theme.buttonNeutralPressed
|
||||||
|
: dirBtn.hovered ? Theme.buttonNeutralHover
|
||||||
|
: Theme.buttonNeutralBackground
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
}
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 6
|
||||||
|
Label {
|
||||||
|
text: "▤"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClicked: file_browser.chooseDirectory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter ────────────────────────────────────────────────────────────
|
||||||
|
TextField {
|
||||||
|
id: filter
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "Filter…"
|
||||||
|
font.pixelSize: 11
|
||||||
|
color: Theme.fieldText
|
||||||
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
|
selectedTextColor: Theme.fieldBackground
|
||||||
|
selectionColor: Theme.fieldText
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
|
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File list ─────────────────────────────────────────────────────────
|
||||||
|
ListView {
|
||||||
|
id: fileList
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
spacing: 2
|
||||||
|
highlight: null
|
||||||
|
highlightFollowsCurrentItem: false
|
||||||
|
|
||||||
|
model: file_browser.files
|
||||||
|
|
||||||
|
delegate: ItemDelegate {
|
||||||
|
id: fileItem
|
||||||
|
width: ListView.view.width
|
||||||
|
padding: 8
|
||||||
|
highlighted: false
|
||||||
|
focusPolicy: Qt.NoFocus
|
||||||
|
|
||||||
|
readonly property bool matchesFilter: {
|
||||||
|
if (filter.text === "") return true
|
||||||
|
var q = filter.text.toLowerCase()
|
||||||
|
return (modelData.baseName + modelData.waferType + modelData.date)
|
||||||
|
.toLowerCase().indexOf(q) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active only in review mode; no highlight during live streaming
|
||||||
|
readonly property bool isActive:
|
||||||
|
streamController.mode === "review" &&
|
||||||
|
streamController.loadedFile !== "" &&
|
||||||
|
modelData.fileName === streamController.loadedFile
|
||||||
|
|
||||||
|
visible: matchesFilter
|
||||||
|
height: matchesFilter ? implicitHeight : 0
|
||||||
|
|
||||||
|
onClicked: streamController.loadFile(modelData.fileName)
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: fileItem.isActive
|
||||||
|
? Qt.rgba(1, 1, 1, 0.06)
|
||||||
|
: fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
// Left accent bar — shown only when active
|
||||||
|
Rectangle {
|
||||||
|
visible: fileItem.isActive
|
||||||
|
width: 3
|
||||||
|
radius: 1.5
|
||||||
|
anchors {
|
||||||
|
top: parent.top; bottom: parent.bottom; left: parent.left
|
||||||
|
topMargin: 5; bottomMargin: 5
|
||||||
|
}
|
||||||
|
color: {
|
||||||
|
var t = modelData.waferType
|
||||||
|
if (t === "A" || t === "E" || t === "P") return "#3B82F6"
|
||||||
|
if (t === "B" || t === "C" || t === "D") return "#10B981"
|
||||||
|
if (t === "Z") return "#8B5CF6"
|
||||||
|
return Theme.headingColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
// Wafer-type avatar (circle)
|
||||||
|
Rectangle {
|
||||||
|
implicitWidth: 28; implicitHeight: 28
|
||||||
|
Layout.preferredWidth: 28
|
||||||
|
Layout.preferredHeight: 28
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
radius: 14
|
||||||
|
color: {
|
||||||
|
var t = modelData.waferType
|
||||||
|
if (t === "A" || t === "E" || t === "P") return "#1D4ED8"
|
||||||
|
if (t === "B" || t === "C" || t === "D") return "#065F46"
|
||||||
|
if (t === "Z") return "#7C3AED"
|
||||||
|
return "#374151"
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData.waferType || "?"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.weight: Font.Bold
|
||||||
|
color: "#FFFFFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typography hierarchy
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 1
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
// Primary: wafer type + serial number
|
||||||
|
Label {
|
||||||
|
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.weight: Font.Bold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary: date · time
|
||||||
|
RowLayout {
|
||||||
|
spacing: 3
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Label {
|
||||||
|
text: {
|
||||||
|
var d = modelData.date || ""
|
||||||
|
return d.length >= 10 ? d.substring(0, 10) : (d || "—")
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
visible: (modelData.timeStr || "") !== ""
|
||||||
|
text: "·"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
opacity: 0.45
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
visible: (modelData.timeStr || "") !== ""
|
||||||
|
text: modelData.timeStr || ""
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
opacity: 0.65
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tertiary: edited metadata (chamber · notes) if any
|
||||||
|
Label {
|
||||||
|
visible: {
|
||||||
|
var c = modelData.chamber || ""
|
||||||
|
var n = modelData.notes || ""
|
||||||
|
return c !== "" || n !== ""
|
||||||
|
}
|
||||||
|
text: {
|
||||||
|
var parts = []
|
||||||
|
if (modelData.chamber) parts.push(modelData.chamber)
|
||||||
|
if (modelData.notes) parts.push(modelData.notes)
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 9
|
||||||
|
opacity: 0.7
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer ────────────────────────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
text: file_browser.files.length + " files · CSV only"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectFileDialog {
|
||||||
|
id: csvEditorDialog
|
||||||
|
tableModel: file_browser.files
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
component TBtn: Button {
|
||||||
|
implicitWidth: 44
|
||||||
|
implicitHeight: 32
|
||||||
|
hoverEnabled: true
|
||||||
|
font.pixelSize: 13
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? Theme.transportButtonHover
|
||||||
|
: parent.hovered ? Theme.transportButtonBg
|
||||||
|
: Theme.transportBackground
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame counter badge on the left
|
||||||
|
Rectangle {
|
||||||
|
id: frameCounter
|
||||||
|
implicitWidth: frameCounterText.implicitWidth + 16
|
||||||
|
implicitHeight: 32
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: frameCounterText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Frame " + (streamController.frameIndex + 1) + " / " + streamController.frameTotal
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left fill — centers the button cluster
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
TBtn { text: "⏮"; onClicked: streamController.step(-1) }
|
||||||
|
TBtn { text: "⏹"; onClicked: streamController.stop() }
|
||||||
|
TBtn { text: "⏸"; onClicked: streamController.pause() }
|
||||||
|
// Play: slightly wider; leftPadding nudge compensates for ▶ optical offset
|
||||||
|
TBtn {
|
||||||
|
text: "▶"
|
||||||
|
implicitWidth: 52
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
leftPadding: 2
|
||||||
|
}
|
||||||
|
onClicked: streamController.play()
|
||||||
|
}
|
||||||
|
TBtn { text: "⏭"; onClicked: streamController.step(1) }
|
||||||
|
|
||||||
|
// Right fill — balances centering
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
// Speed cycle
|
||||||
|
Button {
|
||||||
|
id: speedBtn
|
||||||
|
property var speeds: [1, 2, 5]
|
||||||
|
property int idx: 0
|
||||||
|
implicitWidth: 44
|
||||||
|
implicitHeight: 32
|
||||||
|
hoverEnabled: true
|
||||||
|
text: speeds[idx] + "×"
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: Font.Medium
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.pressed ? Theme.transportButtonHover
|
||||||
|
: parent.hovered ? Theme.transportButtonBg
|
||||||
|
: Theme.transportBackground
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
idx = (idx + 1) % speeds.length
|
||||||
|
streamController.setSpeed(speeds[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
property real blend: 0.0
|
||||||
|
property bool showLabels: true
|
||||||
|
|
||||||
|
WaferMapItem {
|
||||||
|
id: map
|
||||||
|
anchors.fill: parent
|
||||||
|
sensors: streamController.sensorLayout // [{label,x,y}]
|
||||||
|
values: streamController.sensorValues // [float]
|
||||||
|
bands: streamController.sensorBands // ["in_range"|"high"|"low"]
|
||||||
|
shape: streamController.waferShape
|
||||||
|
size: streamController.waferSize
|
||||||
|
target: streamController.target
|
||||||
|
margin: streamController.margin
|
||||||
|
blend: root.blend
|
||||||
|
showLabels: root.showLabels
|
||||||
|
// Bind to Theme so colors update on dark/light mode switch
|
||||||
|
ringColor: Theme.waferRingColor
|
||||||
|
axisColor: Theme.waferAxisColor
|
||||||
|
lowColor: Theme.sensorLow
|
||||||
|
inRangeColor: Theme.sensorInRange
|
||||||
|
highColor: Theme.sensorHigh
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: (ev) => {
|
||||||
|
var idx = map.which_marker(ev.position.x, ev.position.y)
|
||||||
|
if (idx >= 0) replaceDialog.openFor(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReplaceSensorDialog { id: replaceDialog}
|
||||||
|
|
||||||
|
function exportImage(filePath) {
|
||||||
|
return map.export_image(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# ===== ISC Tabs Components Module =====
|
||||||
|
module ISC.Tabs.components
|
||||||
|
SourcePanel 1.0 SourcePanel.qml
|
||||||
|
ReadoutPanel 1.0 ReadoutPanel.qml
|
||||||
|
TransportBar 1.0 TransportBar.qml
|
||||||
|
WaferMapView 1.0 WaferMapView.qml
|
||||||
|
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29289 1.29289C9.48043 1.10536 9.73478 1 10 1H18C19.6569 1 21 2.34315 21 4V9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V4C19 3.44772 18.5523 3 18 3H11V8C11 8.55228 10.5523 9 10 9H5V20C5 20.5523 5.44772 21 6 21H7C7.55228 21 8 21.4477 8 22C8 22.5523 7.55228 23 7 23H6C4.34315 23 3 21.6569 3 20V8C3 7.73478 3.10536 7.48043 3.29289 7.29289L9.29289 1.29289ZM6.41421 7H9V4.41421L6.41421 7ZM19 12C19.5523 12 20 12.4477 20 13V19H23C23.5523 19 24 19.4477 24 20C24 20.5523 23.5523 21 23 21H19C18.4477 21 18 20.5523 18 20V13C18 12.4477 18.4477 12 19 12ZM11.8137 12.4188C11.4927 11.9693 10.8682 11.8653 10.4188 12.1863C9.96935 12.5073 9.86526 13.1318 10.1863 13.5812L12.2711 16.5L10.1863 19.4188C9.86526 19.8682 9.96935 20.4927 10.4188 20.8137C10.8682 21.1347 11.4927 21.0307 11.8137 20.5812L13.5 18.2205L15.1863 20.5812C15.5073 21.0307 16.1318 21.1347 16.5812 20.8137C17.0307 20.4927 17.1347 19.8682 16.8137 19.4188L14.7289 16.5L16.8137 13.5812C17.1347 13.1318 17.0307 12.5073 16.5812 12.1863C16.1318 11.8653 15.5073 11.9693 15.1863 12.4188L13.5 14.7795L11.8137 12.4188Z" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
# ===== ISC Tabs Module =====
|
||||||
|
module ISC.Tabs
|
||||||
|
|
||||||
|
GraphTab 1.0 GraphTab.qml
|
||||||
|
WaferMapTab 1.0 WaferMapTab.qml
|
||||||
|
DataTab 1.0 DataTab.qml
|
||||||
|
SettingsTab 1.0 SettingsTab.qml
|
||||||
|
StatusTab 1.0 StatusTab.qml
|
||||||
|
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||||
@@ -21,15 +21,18 @@ import QtQuick
|
|||||||
|
|
||||||
QtObject {
|
QtObject {
|
||||||
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
||||||
property bool isDarkMode: false
|
property bool isDarkMode: true
|
||||||
|
|
||||||
// ── 2. Tone palette (base values; consume via semantic tokens below) ─────
|
// ── 2. Tone palette (base values; consume via semantic tokens below) ─────
|
||||||
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA"
|
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA"
|
||||||
readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F2F2F2"
|
readonly property color tone150: isDarkMode ? "#161616" : "#F6F6F6"
|
||||||
readonly property color tone300: isDarkMode ? "#242424" : "#E8E8E8"
|
readonly property color tone200: isDarkMode ? "#1A1A1A" : "#F0F0F0"
|
||||||
|
readonly property color tone250: isDarkMode ? "#212121" : "#EAEAEA"
|
||||||
|
readonly property color tone300: isDarkMode ? "#282828" : "#E2E2E2"
|
||||||
|
readonly property color tone350: isDarkMode ? "#303030" : "#D6D6D6"
|
||||||
readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111"
|
readonly property color toneText: isDarkMode ? "#F2F2F2" : "#111111"
|
||||||
readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
|
readonly property color toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
|
||||||
readonly property color toneBorder: isDarkMode ? "#2B2B2B" : "#D8D8D8"
|
readonly property color toneBorder: isDarkMode ? "#2E2E2E" : "#D4D4D4"
|
||||||
|
|
||||||
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
||||||
readonly property color pageBackground: tone100
|
readonly property color pageBackground: tone100
|
||||||
@@ -41,11 +44,12 @@ QtObject {
|
|||||||
|
|
||||||
// ── 4. Borders ───────────────────────────────────────────────────────────
|
// ── 4. Borders ───────────────────────────────────────────────────────────
|
||||||
readonly property color cardBorder: toneBorder
|
readonly property color cardBorder: toneBorder
|
||||||
|
readonly property color cardSurfaceBorder: isDarkMode ? "#3C3C3C" : "#C8C8C8"
|
||||||
readonly property color responseBorder: toneBorder
|
readonly property color responseBorder: toneBorder
|
||||||
readonly property color workspaceBorder: toneBorder
|
readonly property color workspaceBorder: toneBorder
|
||||||
readonly property color innerFrameBorder: toneBorder
|
readonly property color innerFrameBorder: toneBorder
|
||||||
readonly property color outerFrameBorder: isDarkMode ? "#343434" : "#CECECE"
|
readonly property color outerFrameBorder: isDarkMode ? "#383838" : "#CACACA"
|
||||||
readonly property color softBorder: isDarkMode ? "#222222" : "#E2E2E2"
|
readonly property color softBorder: isDarkMode ? "#252525" : "#E0E0E0"
|
||||||
|
|
||||||
// ── 5. Text ──────────────────────────────────────────────────────────────
|
// ── 5. Text ──────────────────────────────────────────────────────────────
|
||||||
readonly property color headingColor: toneText
|
readonly property color headingColor: toneText
|
||||||
@@ -77,7 +81,7 @@ QtObject {
|
|||||||
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
|
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
|
||||||
readonly property color tabBarBackground: tone200
|
readonly property color tabBarBackground: tone200
|
||||||
readonly property color tabBackground: "transparent"
|
readonly property color tabBackground: "transparent"
|
||||||
readonly property color tabActiveBackground: "transparent"
|
readonly property color tabActiveBackground: isDarkMode ? "#333333" : "#FFFFFF"
|
||||||
readonly property color tabHoverBackground: tone300
|
readonly property color tabHoverBackground: tone300
|
||||||
readonly property color tabBorder: "transparent"
|
readonly property color tabBorder: "transparent"
|
||||||
readonly property color tabText: toneMute
|
readonly property color tabText: toneMute
|
||||||
@@ -87,17 +91,31 @@ QtObject {
|
|||||||
readonly property color sideRailBackground: tone200
|
readonly property color sideRailBackground: tone200
|
||||||
readonly property color sideActiveBackground: tone300
|
readonly property color sideActiveBackground: tone300
|
||||||
|
|
||||||
|
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
|
||||||
|
readonly property color transportBackground: isDarkMode ? "#0D0D0D" : "#E8E8E8"
|
||||||
|
readonly property color transportButtonBg: isDarkMode ? "#2A2A2A" : "#D4D4D4"
|
||||||
|
readonly property color transportButtonHover: isDarkMode ? "#3A3A3A" : "#C4C4C4"
|
||||||
|
readonly property color liveColor: isDarkMode ? "#22C55E" : "#16A34A"
|
||||||
|
readonly property color recordColor: isDarkMode ? "#EF4444" : "#DC2626"
|
||||||
|
|
||||||
// ── 10. Status ───────────────────────────────────────────────────────────
|
// ── 10. Status ───────────────────────────────────────────────────────────
|
||||||
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44"
|
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44"
|
||||||
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 ? "#5B9DF5" : "#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: 6 // fields, tight elements
|
||||||
readonly property int radiusSm: 6 // buttons
|
readonly property int radiusSm: 8 // buttons
|
||||||
readonly property int radiusMd: 10 // cards, group boxes
|
readonly property int radiusMd: 12 // cards, group boxes
|
||||||
readonly property int radiusLg: 14 // large panels / dialogs
|
readonly property int radiusLg: 18 // large panels / dialogs
|
||||||
// Border width
|
// Border width
|
||||||
readonly property int borderThin: 1
|
readonly property int borderThin: 1
|
||||||
readonly property int borderStrong: 2
|
readonly property int borderStrong: 2
|
||||||
@@ -114,7 +132,7 @@ QtObject {
|
|||||||
readonly property int tabBarHeight: 34
|
readonly property int tabBarHeight: 34
|
||||||
readonly property int tabBarPadding: 6
|
readonly property int tabBarPadding: 6
|
||||||
readonly property int tabSpacing: 2
|
readonly property int tabSpacing: 2
|
||||||
readonly property int tabRadius: 2
|
readonly property int tabRadius: 8
|
||||||
readonly property int tabFontSize: 12
|
readonly property int tabFontSize: 12
|
||||||
readonly property int tabButtonMinWidth: 80
|
readonly property int tabButtonMinWidth: 80
|
||||||
// Settings page layout
|
// Settings page layout
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtQml import QQmlApplicationEngine
|
||||||
|
from PySide6.QtQuickControls2 import QQuickStyle
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
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
|
||||||
|
|
||||||
|
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Application Entry Point =====
|
||||||
|
def main() -> int:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
# ===== UI Style Setup =====
|
||||||
|
# Use a non-native controls style so our custom QML button backgrounds are supported.
|
||||||
|
QQuickStyle.setStyle("Basic")
|
||||||
|
|
||||||
|
# ===== Qt Bootstrap =====
|
||||||
|
# Minimal Qt bootstrap: load the ISC QML module and exit if it fails.
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
engine = QQmlApplicationEngine()
|
||||||
|
|
||||||
|
# ===== Shared Models =====
|
||||||
|
settings_model = LocalSettingsModel()
|
||||||
|
select_file_dialog_model = FileBrowser()
|
||||||
|
settings_model.loadSettings()
|
||||||
|
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||||
|
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
||||||
|
|
||||||
|
# ===== Device Controller (serial comm) =====
|
||||||
|
data_dir = str(settings_model._data_dir)
|
||||||
|
raw_settings = LocalSettings.read_settings(data_dir)
|
||||||
|
device_controller = DeviceController(raw_settings, data_dir)
|
||||||
|
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||||
|
|
||||||
|
# ===== Session Controller (live/review wafer dashboard) =====
|
||||||
|
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
|
||||||
|
stream_controller = SessionController(settings=raw_settings_dict)
|
||||||
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
|
# Persist session state back to settings when it changes
|
||||||
|
def _persist_session_settings():
|
||||||
|
session_state = stream_controller.collect_settings()
|
||||||
|
for k, v in session_state.items():
|
||||||
|
if hasattr(raw_settings, k):
|
||||||
|
setattr(raw_settings, k, v)
|
||||||
|
LocalSettings.save_settings(data_dir, raw_settings)
|
||||||
|
|
||||||
|
stream_controller.settingsChanged.connect(_persist_session_settings)
|
||||||
|
|
||||||
|
# ===== 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():
|
||||||
|
return -1
|
||||||
|
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: aepwafer
|
||||||
|
wafers: ["A", "E", "P"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [150, 204, 249, 280, 290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97,
|
||||||
|
150, 203, 241, 255, 241, 203, 150, 98, 59, 45, 59, 98, 171, 207, 228, 228,
|
||||||
|
207, 171, 130, 94, 73, 73, 94, 130, 150, 186, 200, 186, 150, 115, 100, 115]
|
||||||
|
Y: [290, 280, 249, 204, 150, 97, 51, 21, 10, 21, 51, 97, 150, 204, 249, 280,
|
||||||
|
255, 241, 203, 150, 98, 59, 45, 59, 98, 150, 203, 241, 228, 207, 171, 130,
|
||||||
|
94, 73, 73, 94, 130, 171, 207, 228, 200, 186, 150, 115, 100, 115, 150, 186]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
4: { top: [0, 0] },
|
||||||
|
5: { top: [0, 0] },
|
||||||
|
6: { top: [0, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: bcdwafer
|
||||||
|
wafers: ["B", "C", "D"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0, 72.50, 125.57, 145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00,
|
||||||
|
-125.57, -72.50, 0, 47.50, 82.27, 95.00, 82.27, 47.50, 0, -47.50, -82.27,
|
||||||
|
-95.00, -82.27, -47.50, 38.97, 22.50, -38.97, -22.50, 0]
|
||||||
|
Y: [145.00, 125.57, 72.50, 0, -72.50, -125.57, -145.00, -125.57, -72.50, 0,
|
||||||
|
72.50, 125.57, 95.00, 82.27, 47.50, 0, -47.50, -82.27, -95.00, -82.27,
|
||||||
|
-47.50, 0, 47.50, 82.27, 22.50, -38.97, -22.50, 38.97, 0]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
6: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: fwafer
|
||||||
|
wafers: ["F"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 200
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: large
|
||||||
|
font_size: large
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [0.0, 47.5, 82.3, 95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5,
|
||||||
|
-55.0, -14.8, 39.7, 55.0, 14.8, -39.7, -14.7, 25.5, 14.7, -25.5]
|
||||||
|
Y: [95.0, 82.3, 47.5, 0.0, -47.5, -82.3, -95.0, -82.3, -47.5, 0.0, 47.5, 82.3,
|
||||||
|
13.3, 54.4, 40.0, -13.3, -54.4, -40.0, 25.5, 14.7, -25.5, -14.7]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
2: { left: [1, 0] },
|
||||||
|
3: { left: [1, 0] },
|
||||||
|
4: { left: [1, 0] },
|
||||||
|
5: { left: [1, 0] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: xwafer
|
||||||
|
wafers: ["X"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: square
|
||||||
|
|
||||||
|
# diameter/edge size of the wafer
|
||||||
|
size: 310
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top implies that the X/Y values
|
||||||
|
# for that axis are negative, or else we'll be drawing outside the image.
|
||||||
|
x_origin: left
|
||||||
|
y_origin: bottom
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the origin
|
||||||
|
X: [ 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
13.00, 23.00, 46.43, 89.86, 133.29, 176.72, 220.15, 263.58, 287.01, 297.01,
|
||||||
|
307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01, 307.01,
|
||||||
|
307.01, 307.01, 307.01, 297.01, 287.01, 263.58, 220.15, 176.72, 133.29,
|
||||||
|
89.86, 46.43, 23.00, 13.00, 46.43, 46.43, 46.43, 46.43, 46.43, 46.43,
|
||||||
|
89.86, 133.29, 176.72, 220.15, 263.58, 263.58, 263.58, 263.58, 263.58,
|
||||||
|
263.58, 220.15, 176.72, 133.29, 89.86, 89.86, 89.86, 89.86, 89.86,
|
||||||
|
133.29, 176.72, 220.15, 220.15, 220.15, 220.15, 176.72, 133.29, 133.29,
|
||||||
|
133.29, 176.72, 176.72]
|
||||||
|
Y: [ 3.00, 13.07, 23.07, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65, 287.08,
|
||||||
|
297.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08, 307.08,
|
||||||
|
307.08, 307.08, 307.08, 307.08, 297.08, 287.08, 263.65, 220.22, 176.79,
|
||||||
|
133.36, 89.93, 46.50, 23.07, 13.07, 3.00, 3.00, 3.00, 3.00, 3.00, 3.00,
|
||||||
|
3.00, 3.00, 3.00, 3.00, 3.00, 46.50, 89.93, 133.36, 176.79, 220.22, 263.65,
|
||||||
|
263.65, 263.65, 263.65, 263.65, 263.65, 220.22, 176.79, 133.36, 89.93,
|
||||||
|
46.50, 46.50, 46.50, 46.50, 46.50, 89.93, 133.36, 176.79, 220.22, 220.22,
|
||||||
|
220.22, 220.22, 176.79, 133.36, 89.93, 89.93, 89.93, 133.36, 176.79,
|
||||||
|
176.79, 133.36 ]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 1
|
||||||
|
|
||||||
|
# Normally, positive X is to the right, and positive Y is up.
|
||||||
|
# These settings can be used to reverse one or both of these.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
21: { left: [0, 0] },
|
||||||
|
22: { left: [0, 0] },
|
||||||
|
23: { left: [0, 0] },
|
||||||
|
24: { left: [0, 0] },
|
||||||
|
25: { left: [0, 0] },
|
||||||
|
26: { left: [0, 0] },
|
||||||
|
27: { left: [0, 0] },
|
||||||
|
28: { left: [0, 0] },
|
||||||
|
29: { left: [0, 0] },
|
||||||
|
30: { left: [0, 0] },
|
||||||
|
31: { left: [0, 0] },
|
||||||
|
32: { left: [0, 0] },
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
35: { left: [0, 0] },
|
||||||
|
36: { left: [0, 0] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer
|
||||||
|
wafers: ["Z"]
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: false
|
||||||
|
reverse_y: false
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
33: { left: [0, 0] },
|
||||||
|
34: { left: [0, 0] },
|
||||||
|
41: { top: [0, 0] },
|
||||||
|
42: { bottom: [0, 0] },
|
||||||
|
45: { top: [1, 0] },
|
||||||
|
46: { bottom: [0.5, 0] },
|
||||||
|
49: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1.75, -0.5] },
|
||||||
|
51: { top: [-1, 0] },
|
||||||
|
52: { top: [-1, 0] },
|
||||||
|
53: { bottom: [0, 0] },
|
||||||
|
54: { left: [0, -0.75] },
|
||||||
|
55: { right: [0, 0.5] },
|
||||||
|
58: { bottom: [-0.75, 0] },
|
||||||
|
60: { top: [0, 0] },
|
||||||
|
62: { right: [0, 1] },
|
||||||
|
63: { right: [0, 0] },
|
||||||
|
64: { left: [0, 0.25] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# This will be the name of the output files
|
||||||
|
name: zwafer_rev
|
||||||
|
wafers: [] # the reversed version doesn't represent any real wafer
|
||||||
|
|
||||||
|
# round or square wafer
|
||||||
|
shape: round
|
||||||
|
|
||||||
|
# diameter/size of the wafer
|
||||||
|
size: 300
|
||||||
|
|
||||||
|
# large or small markers and labels
|
||||||
|
marker_size: small
|
||||||
|
font_size: small
|
||||||
|
|
||||||
|
# origin settings. x_origin can be left, right, or center. y_origin can
|
||||||
|
# be top, bottom, or center. Using right or top probably also needs
|
||||||
|
# reverse_x/reverse_y to be set as well.
|
||||||
|
x_origin: center
|
||||||
|
y_origin: center
|
||||||
|
|
||||||
|
# sensor X and Y values, in mm relative to the wafer center
|
||||||
|
X: [0.00, -1.17, 8.92, 1.17, -8.92, 22.52, 29.35, -22.52, -29.35, 45.05, 73.37,
|
||||||
|
58.71, 9.66, -45.05, -73.37, -58.71, -9.66, 79.85, 89.06, 46.10, -23.86,
|
||||||
|
-79.85, -89.06, -46.10, 23.86, 66.96, 109.06, 87.27, 14.36, -66.96,
|
||||||
|
-109.06, -87.27, -14.36, 121.24, 135.23, 70.00, -36.23, -121.24, -135.23,
|
||||||
|
-70.00, 36.23, 124.71, 139.09, 72.00, -37.27, -124.71, -139.09, -72.00,
|
||||||
|
37.27, 89.49, 127.31, 145.74, 141.99, 116.62, 73.50, 19.19, -38.05, -89.49,
|
||||||
|
-127.31, -145.74, -141.99, -116.62, -73.50, -19.19, 38.05]
|
||||||
|
Y: [0.00, -8.92, -1.17, 8.92, 1.17, -29.35, 22.52, 29.35, -22.52, -58.71,
|
||||||
|
-9.66, 45.05, 73.37, 58.71, 9.66, -45.05, -73.37, -46.10, 23.86, 79.85,
|
||||||
|
89.06, 46.10, -23.86, -79.85, -89.06, -87.27, -14.36, 66.96, 109.06, 87.27,
|
||||||
|
14.36, -66.96, -109.06, -70.00, 36.23, 121.24, 135.23, 70.00, -36.23,
|
||||||
|
-121.24, -135.23, -72.00, 37.27, 124.71, 139.09, 72.00, -37.27, -124.71,
|
||||||
|
-139.09, -116.62, -73.50, -19.19, 38.05, 89.49, 127.31, 145.74, 141.99,
|
||||||
|
116.62, 73.50, 19.19, -38.05, -89.49, -127.31, -145.74, -141.99]
|
||||||
|
|
||||||
|
# set this to either 0 or 1 for the initial sensor number (usually 1)
|
||||||
|
start_sn: 0
|
||||||
|
|
||||||
|
# If one or both axes are reversed, this can be used to fix it
|
||||||
|
# without having to edit all of the coordinates.
|
||||||
|
# These should be false for most normal wafers.
|
||||||
|
reverse_x: true
|
||||||
|
reverse_y: true
|
||||||
|
|
||||||
|
# these are sensors whose labels need to be repositioned. Sensor numbers
|
||||||
|
# start at start_sn. Directions are up, down, left, and right, and values
|
||||||
|
# are multiples of the marker size.
|
||||||
|
label_exceptions: {
|
||||||
|
37: { left: [0, 0] },
|
||||||
|
38: { left: [0, 0] },
|
||||||
|
39: { left: [0, 0] },
|
||||||
|
41: { top: [1.5, 0] },
|
||||||
|
42: { bottom: [1, 0] },
|
||||||
|
43: { left: [0, -1] },
|
||||||
|
45: { bottom: [-1, -1] },
|
||||||
|
46: { bottom: [0, -1] },
|
||||||
|
47: { right: [0, 1] },
|
||||||
|
48: { left: [0, 0] },
|
||||||
|
50: { bottom: [-1, 0] },
|
||||||
|
52: { top: [0, 0] },
|
||||||
|
54: { right: [0.5, 1] },
|
||||||
|
56: { left: [0, 0.5] },
|
||||||
|
57: { left: [0, 0] },
|
||||||
|
58: { top: [0.5, 0] },
|
||||||
|
59: { left: [0, 0] },
|
||||||
|
60: { top: [-1, 0] },
|
||||||
|
61: { left: [0, 0] },
|
||||||
|
62: { left: [0, -1] },
|
||||||
|
63: { right: [0, 1] },
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ===== 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,8 +1,8 @@
|
|||||||
from typing import List, Tuple, Optional
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from backend.contour_models import ContourLine, ContourSegment
|
from pygui.backend.attic.contour_models import ContourLine, ContourSegment
|
||||||
|
|
||||||
|
|
||||||
# ===== Contour Generation =====
|
# ===== Contour Generation =====
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Cluster Average utility for wafer sensor values."""
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def average_clusters(values: list[float], clusters: list[list[int]]) -> list[float]:
|
||||||
|
"""Return a new list of values where members of each cluster are replaced by their mean.
|
||||||
|
|
||||||
|
NaN values are filtered out before computing the mean.
|
||||||
|
If all values in a cluster are NaN, they remain NaN.
|
||||||
|
"""
|
||||||
|
result = list(values)
|
||||||
|
for cluster in clusters:
|
||||||
|
valid_vals = []
|
||||||
|
for idx in cluster:
|
||||||
|
if idx < len(values):
|
||||||
|
val = values[idx]
|
||||||
|
if not math.isnan(val):
|
||||||
|
valid_vals.append(val)
|
||||||
|
|
||||||
|
if valid_vals:
|
||||||
|
mean_val = sum(valid_vals) / len(valid_vals)
|
||||||
|
for idx in cluster:
|
||||||
|
if idx < len(result):
|
||||||
|
result[idx] = mean_val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def group_sensors_by_radius(sensors: list[Sensor], tolerance: float = 2.0) -> list[list[int]]:
|
||||||
|
"""Group sensor indices into clusters if their radial distances are within tolerance."""
|
||||||
|
# Compute radii for all sensors
|
||||||
|
radii = [(i, math.hypot(s.x, s.y)) for i, s in enumerate(sensors)]
|
||||||
|
# Filter out center sensors (r < 1.0)
|
||||||
|
non_center = [item for item in radii if item[1] >= 1.0]
|
||||||
|
if not non_center:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Sort by radius
|
||||||
|
non_center.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
clusters: list[list[int]] = []
|
||||||
|
current_cluster: list[int] = [non_center[0][0]]
|
||||||
|
last_r = non_center[0][1]
|
||||||
|
|
||||||
|
for idx, r in non_center[1:]:
|
||||||
|
if r - last_r <= tolerance:
|
||||||
|
current_cluster.append(idx)
|
||||||
|
else:
|
||||||
|
if len(current_cluster) > 1:
|
||||||
|
clusters.append(current_cluster)
|
||||||
|
current_cluster = [idx]
|
||||||
|
last_r = r
|
||||||
|
|
||||||
|
if len(current_cluster) > 1:
|
||||||
|
clusters.append(current_cluster)
|
||||||
|
|
||||||
|
return clusters
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Dynamic Time Warping comparision between two temperature runs."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from dtw import dtw
|
||||||
|
|
||||||
|
|
||||||
|
def compare_runs(series_a: list[float], series_b: list[float]) -> dict:
|
||||||
|
"""Compute DTW alignment between 2 average-temperature series.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys:
|
||||||
|
- distance: float - warping distance
|
||||||
|
- index_a: list[int] - warping path indices into series_a
|
||||||
|
- index_b: list[int] - warping path indices into series_b
|
||||||
|
- warping_path: list[tuple[int,int]] - paired indices
|
||||||
|
"""
|
||||||
|
x = np.array(series_a, dtype=float)
|
||||||
|
y = np.array(series_b, dtype=float)
|
||||||
|
|
||||||
|
# Filter out NaN/Inf values so dtw doesn't choke on them.
|
||||||
|
mask_x = np.isfinite(x)
|
||||||
|
mask_y = np.isfinite(y)
|
||||||
|
if not mask_x.any() or not mask_y.any():
|
||||||
|
return {"distance": float('inf'), "index_a": [], "index_b": [], "warping_path": []}
|
||||||
|
|
||||||
|
x = x[mask_x]
|
||||||
|
y = y[mask_y]
|
||||||
|
|
||||||
|
alignment = dtw(x, y, keep_internals=True)
|
||||||
|
return {
|
||||||
|
"distance": float(alignment.distance),
|
||||||
|
"index_a": alignment.index1.tolist(),
|
||||||
|
"index_b": alignment.index2.tolist(),
|
||||||
|
"warping_path": list(zip(alignment.index1.tolist(), alignment.index2.tolist()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ===== Controllers Sub-package =====
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
|
||||||
|
__all__ = ["DeviceController", "SessionController"]
|
||||||
+103
-45
@@ -1,10 +1,4 @@
|
|||||||
"""QML-exposed controller for wafer device communication.
|
"""QML-exposed controller for wafer device communication."""
|
||||||
|
|
||||||
Bridges DeviceService (serialcomm) to QML via @Slot methods and signals.
|
|
||||||
All hardware operations run synchronously on the main thread (matching
|
|
||||||
C# Form1.cs per-operation open/close pattern). For long operations
|
|
||||||
(erase ~15s, read up to 120s) the caller should show a loading state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,19 +7,26 @@ import threading
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Property, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
|
|
||||||
from backend.data_model import TemperatureTableModel
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
from backend.graph_view import GraphView
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from backend.local_settings import LocalSettings
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
from serialcomm.data_parser import (
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
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
|
||||||
|
|
||||||
|
# import pygui.backend.wafer_map_item
|
||||||
|
|
||||||
|
stream_controller = SessionController()
|
||||||
|
# engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,14 +58,13 @@ class DeviceController(QObject):
|
|||||||
self._data_dir = data_dir
|
self._data_dir = data_dir
|
||||||
self._service = DeviceService(settings)
|
self._service = DeviceService(settings)
|
||||||
|
|
||||||
# Initialize status from persisted settings (with defaults for new installations)
|
# Always start fresh — never restore connection status from a prior session
|
||||||
self._connection_status = getattr(settings, 'connection_status', "Disconnected")
|
self._connection_status = "Disconnected"
|
||||||
self._operation_in_progress = False
|
self._operation_in_progress = False
|
||||||
self._activity_log: list[str] = getattr(settings, 'activity_log', [])
|
self._activity_log: list[str] = getattr(settings, 'activity_log', [])
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
# Default save dir lives next to the settings file (~/Documents/isc_data/csv)
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', str(Path.home() / "Documents" / "isc_data" / "csv"))
|
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
||||||
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {})
|
self._last_wafer_info: dict[str, Any] = getattr(settings, 'last_wafer_info', {})
|
||||||
self._last_csv_path: str = getattr(settings, 'last_csv_path', "")
|
self._last_csv_path: str = getattr(settings, 'last_csv_path', "")
|
||||||
self._selected_port: str = getattr(settings, 'selected_port', "")
|
self._selected_port: str = getattr(settings, 'selected_port', "")
|
||||||
@@ -87,9 +87,16 @@ class DeviceController(QObject):
|
|||||||
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
||||||
self.statusRestored.emit()
|
self.statusRestored.emit()
|
||||||
|
|
||||||
|
# Reset connection status on fresh start
|
||||||
|
self._connection_status = "Disconnected"
|
||||||
|
|
||||||
# Marshal worker-thread results onto the Qt main thread.
|
# Marshal worker-thread results onto the Qt main thread.
|
||||||
self._detectFinished.connect(self._handle_detect_finished, Qt.QueuedConnection)
|
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
self._readFinished.connect(self._handle_read_finished, Qt.QueuedConnection)
|
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.addHandler(QmlActivityLogHandler(self))
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
# ---- Properties ----
|
# ---- Properties ----
|
||||||
@Property(list, notify=portsUpdated)
|
@Property(list, notify=portsUpdated)
|
||||||
@@ -97,6 +104,12 @@ class DeviceController(QObject):
|
|||||||
"""Currently available serial port names."""
|
"""Currently available serial port names."""
|
||||||
return self._service.enumerate_ports()
|
return self._service.enumerate_ports()
|
||||||
|
|
||||||
|
def _set_connection_status(self, status: str) -> None:
|
||||||
|
"""Update connection status and notify QML."""
|
||||||
|
if self._connection_status != status:
|
||||||
|
self._connection_status = status
|
||||||
|
self.portsUpdated.emit(self.availablePorts)
|
||||||
|
|
||||||
@Property(str, notify=portsUpdated)
|
@Property(str, notify=portsUpdated)
|
||||||
def connectionStatus(self) -> str:
|
def connectionStatus(self) -> str:
|
||||||
"""Current connection status string for display."""
|
"""Current connection status string for display."""
|
||||||
@@ -113,6 +126,7 @@ class DeviceController(QObject):
|
|||||||
return self._save_data_dir
|
return self._save_data_dir
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSaveDataDir(self, path: str) -> None:
|
def setSaveDataDir(self, path: str) -> None:
|
||||||
"""Set the directory for saving parsed CSV data files."""
|
"""Set the directory for saving parsed CSV data files."""
|
||||||
self._save_data_dir = path
|
self._save_data_dir = path
|
||||||
@@ -125,6 +139,7 @@ class DeviceController(QObject):
|
|||||||
return self._selected_port
|
return self._selected_port
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSelectedPort(self, port: str) -> None:
|
def setSelectedPort(self, port: str) -> None:
|
||||||
"""Set the active serial port from the port selector."""
|
"""Set the active serial port from the port selector."""
|
||||||
self._selected_port = port
|
self._selected_port = port
|
||||||
@@ -167,6 +182,7 @@ class DeviceController(QObject):
|
|||||||
return self._graph_view
|
return self._graph_view
|
||||||
|
|
||||||
@Slot(result=object)
|
@Slot(result=object)
|
||||||
|
@slot_error_boundary
|
||||||
def getChartData(self) -> dict[str, Any]:
|
def getChartData(self) -> dict[str, Any]:
|
||||||
"""Extract chart-ready data from the current model.
|
"""Extract chart-ready data from the current model.
|
||||||
|
|
||||||
@@ -204,6 +220,31 @@ class DeviceController(QObject):
|
|||||||
self._activity_log = self._activity_log[-200:]
|
self._activity_log = self._activity_log[-200:]
|
||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def clearActivityLog(self) -> None:
|
||||||
|
"""Clear the activity log."""
|
||||||
|
self._activity_log.clear()
|
||||||
|
self.activityLogUpdated.emit("")
|
||||||
|
self._save_status()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def clearSession(self) -> None:
|
||||||
|
"""Clear the current session state, allowing a clean detect."""
|
||||||
|
self._set_connection_status("Disconnected")
|
||||||
|
self._selected_port = ""
|
||||||
|
self._last_wafer_info = {}
|
||||||
|
self._data_row_count = 0
|
||||||
|
self._data_col_count = 0
|
||||||
|
self._raw_bytes = None
|
||||||
|
self._operation_in_progress = False
|
||||||
|
self.detectResult.emit(None)
|
||||||
|
self.portsUpdated.emit(self.availablePorts)
|
||||||
|
self._append_log("Session refreshed/cleared")
|
||||||
|
self._save_status()
|
||||||
|
|
||||||
|
|
||||||
def _set_operation_progress(self, in_progress: bool) -> None:
|
def _set_operation_progress(self, in_progress: bool) -> None:
|
||||||
"""Set operation progress state and notify QML.
|
"""Set operation progress state and notify QML.
|
||||||
|
|
||||||
@@ -217,15 +258,17 @@ class DeviceController(QObject):
|
|||||||
# ---- Public Slots ----
|
# ---- Public Slots ----
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def refreshPorts(self) -> None:
|
def refreshPorts(self) -> None:
|
||||||
"""Scan for available serial ports and emit updated list."""
|
"""Scan for available serial ports and emit updated list."""
|
||||||
ports = self._service.enumerate_ports()
|
ports = self._service.enumerate_ports()
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self.portsUpdated.emit(ports)
|
self.portsUpdated.emit(ports)
|
||||||
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def detectWafer(self) -> None:
|
def detectWafer(self) -> None:
|
||||||
"""Scan all serial ports for a wafer (runs in background thread).
|
"""Scan all serial ports for a wafer (runs in background thread).
|
||||||
|
|
||||||
@@ -238,7 +281,7 @@ class DeviceController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Detecting..."
|
self._set_connection_status("Detecting...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log("Scanning all ports for wafer ...")
|
self._append_log("Scanning all ports for wafer ...")
|
||||||
|
|
||||||
@@ -255,13 +298,14 @@ class DeviceController(QObject):
|
|||||||
self._detectFinished.emit(port, info)
|
self._detectFinished.emit(port, info)
|
||||||
|
|
||||||
@Slot(object, object)
|
@Slot(object, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_detect_finished(
|
def _handle_detect_finished(
|
||||||
self, port: Optional[str], info: Optional[WaferInfo]
|
self, port: Optional[str], info: Optional[WaferInfo]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for detectWafer."""
|
"""Main-thread completion handler for detectWafer."""
|
||||||
if info is not None:
|
if info is not None:
|
||||||
self._selected_port = port or ""
|
self._selected_port = port or ""
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log(
|
self._append_log(
|
||||||
f"Detected: {info.serial_number} (family={info.family_code}, "
|
f"Detected: {info.serial_number} (family={info.family_code}, "
|
||||||
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
|
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
|
||||||
@@ -272,18 +316,20 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
else:
|
else:
|
||||||
self._selected_port = ""
|
self._selected_port = ""
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("No wafer detected on any port")
|
self._append_log("No wafer detected on any port")
|
||||||
self._last_wafer_info = {}
|
self._last_wafer_info = {}
|
||||||
self.detectResult.emit(None)
|
self.detectResult.emit(None)
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def readMemoryAsync(self) -> None:
|
def readMemoryAsync(self) -> None:
|
||||||
"""Read wafer memory in a background thread.
|
"""Read wafer memory in a background thread.
|
||||||
|
|
||||||
Uses the family code and port from the last detect.
|
Uses the family code and port from the last detect.
|
||||||
Emits readResult on completion, then auto-chains to parseAndSaveData.
|
Emits readResult on completion; QML prompts for the save directory
|
||||||
|
before calling parseAndSaveData.
|
||||||
"""
|
"""
|
||||||
if self._operation_in_progress:
|
if self._operation_in_progress:
|
||||||
self._append_log("Already busy — ignoring read request")
|
self._append_log("Already busy — ignoring read request")
|
||||||
@@ -298,7 +344,7 @@ class DeviceController(QObject):
|
|||||||
family_code = self._last_wafer_info.get("familyCode", "")
|
family_code = self._last_wafer_info.get("familyCode", "")
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Reading..."
|
self._set_connection_status("Reading...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(
|
self._append_log(
|
||||||
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
||||||
@@ -320,22 +366,21 @@ class DeviceController(QObject):
|
|||||||
self._readFinished.emit(port, family_code, data)
|
self._readFinished.emit(port, family_code, data)
|
||||||
|
|
||||||
@Slot(str, str, object)
|
@Slot(str, str, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_read_finished(
|
def _handle_read_finished(
|
||||||
self, port: str, family_code: str, data: Optional[bytes]
|
self, port: str, family_code: str, data: Optional[bytes]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for readMemoryAsync."""
|
"""Main-thread completion handler for readMemoryAsync."""
|
||||||
if data is not None:
|
if data is not None:
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log(f"Read {len(data)} bytes")
|
self._append_log(f"Read {len(data)} bytes")
|
||||||
self._raw_bytes = data
|
self._raw_bytes = data
|
||||||
self.readResult.emit({"success": True, "bytes": len(data)})
|
self.readResult.emit({"success": True, "bytes": len(data)})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
# Auto-chain: read → parse → save (matches C# behavior).
|
self._append_log("Memory read complete - choose save directory to save CSV")
|
||||||
# Parse runs on main thread — it's CPU-bound but bounded (~1s).
|
|
||||||
self.parseAndSaveData(family_code, port)
|
|
||||||
self._save_status()
|
self._save_status()
|
||||||
return
|
return
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("Read returned no data")
|
self._append_log("Read returned no data")
|
||||||
self._raw_bytes = None
|
self._raw_bytes = None
|
||||||
self.readResult.emit({"error": "Read returned no data"})
|
self.readResult.emit({"error": "Read returned no data"})
|
||||||
@@ -343,38 +388,40 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def eraseMemory(self, port: str) -> None:
|
def eraseMemory(self, port: str) -> None:
|
||||||
"""Send p1 erase command."""
|
"""Send p1 erase command."""
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Erasing..."
|
self._set_connection_status("Erasing...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
||||||
|
|
||||||
ok = self._service.erase_wafer(port)
|
ok = self._service.erase_wafer(port)
|
||||||
if ok:
|
if ok:
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log("Erase command accepted — wait ~15 seconds")
|
self._append_log("Erase command accepted — wait ~15 seconds")
|
||||||
else:
|
else:
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("Erase command failed")
|
self._append_log("Erase command failed")
|
||||||
self.eraseResult.emit({"success": ok})
|
self.eraseResult.emit({"success": ok})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def readDebug(self, port: str) -> None:
|
def readDebug(self, port: str) -> None:
|
||||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
||||||
|
|
||||||
Emits debugResult with counts on success.
|
Emits debugResult with counts on success.
|
||||||
"""
|
"""
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Reading debug..."
|
self._set_connection_status("Reading debug...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(f"Reading debug data on {port} ...")
|
self._append_log(f"Reading debug data on {port} ...")
|
||||||
|
|
||||||
# Step 1: D1 sensor data
|
# Step 1: D1 sensor data
|
||||||
sensor_data = self._service.read_wafer_data(port, family_code="")
|
sensor_data = self._service.read_wafer_data(port, family_code="")
|
||||||
if sensor_data is None:
|
if sensor_data is None:
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("D1 read failed — aborting debug read")
|
self._append_log("D1 read failed — aborting debug read")
|
||||||
self.debugResult.emit({"error": "D1 read failed"})
|
self.debugResult.emit({"error": "D1 read failed"})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
@@ -385,13 +432,13 @@ class DeviceController(QObject):
|
|||||||
# Step 2: F1 debug data
|
# Step 2: F1 debug data
|
||||||
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
||||||
if debug_data is None:
|
if debug_data is None:
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("F1 read failed")
|
self._append_log("F1 read failed")
|
||||||
self.debugResult.emit({"error": "F1 read failed"})
|
self.debugResult.emit({"error": "F1 read failed"})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log(
|
self._append_log(
|
||||||
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
||||||
f"{len(debug_data)} debug bytes"
|
f"{len(debug_data)} debug bytes"
|
||||||
@@ -404,6 +451,7 @@ class DeviceController(QObject):
|
|||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
||||||
"""Parse raw bytes into temperatures and save to CSV.
|
"""Parse raw bytes into temperatures and save to CSV.
|
||||||
|
|
||||||
@@ -419,12 +467,10 @@ class DeviceController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self._save_data_dir:
|
if not self._save_data_dir:
|
||||||
self._append_log("No save data directory set")
|
from pathlib import Path
|
||||||
self.parsedDataReady.emit({
|
self._save_data_dir = str(Path(self._data_dir) / "csv")
|
||||||
"success": False,
|
self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
|
||||||
"error": "No save data directory set",
|
self._save_status()
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
fc = family_code or (
|
fc = family_code or (
|
||||||
self._last_wafer_info.get("familyCode", "")
|
self._last_wafer_info.get("familyCode", "")
|
||||||
@@ -471,6 +517,7 @@ class DeviceController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._append_log(f"Saved CSV: {csv_path}")
|
self._append_log(f"Saved CSV: {csv_path}")
|
||||||
|
self._last_csv_path = csv_path
|
||||||
|
|
||||||
# Load data into the QAbstractTableModel
|
# Load data into the QAbstractTableModel
|
||||||
self._data_model.load_data(temp_data, cols)
|
self._data_model.load_data(temp_data, cols)
|
||||||
@@ -504,7 +551,7 @@ class DeviceController(QObject):
|
|||||||
self._settings.last_csv_path = self._last_csv_path
|
self._settings.last_csv_path = self._last_csv_path
|
||||||
|
|
||||||
# Save to disk
|
# Save to disk
|
||||||
LocalSettings.save_settings(LocalSettings._settings_path(self._data_dir), self._settings)
|
LocalSettings.save_settings(self._data_dir, self._settings)
|
||||||
|
|
||||||
# ---- Helpers ----
|
# ---- Helpers ----
|
||||||
|
|
||||||
@@ -519,3 +566,14 @@ class DeviceController(QObject):
|
|||||||
"runtime": info.runtime,
|
"runtime": info.runtime,
|
||||||
"cycleCount": info.cycle_count,
|
"cycleCount": info.cycle_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class QmlActivityLogHandler(logging.Handler):
|
||||||
|
def __init__(self, controller: DeviceController) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._controller = controller
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
level = record.levelname
|
||||||
|
message = record.getMessage()
|
||||||
|
msg = f"[{timestamp}] {level}: {message}"
|
||||||
|
self._controller._append_log(msg)
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
"""QML-facing controller for the live/review wafer dashboard."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
|
||||||
|
|
||||||
|
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
|
||||||
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
||||||
|
from pygui.backend.models.replay_stats import ReplayStatsTracker
|
||||||
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
|
from pygui.backend.models.session_model import SessionModel
|
||||||
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MODE_LIVE = "live"
|
||||||
|
MODE_REVIEW = "review"
|
||||||
|
|
||||||
|
|
||||||
|
class SessionController(QObject):
|
||||||
|
# public signals
|
||||||
|
frameUpdated = Signal()
|
||||||
|
modeChanged = Signal()
|
||||||
|
stateChanged = Signal()
|
||||||
|
recordingChanged = Signal()
|
||||||
|
sensorsChanged = Signal()
|
||||||
|
loadedFileChanged = Signal()
|
||||||
|
clusterAveragingEnabledChanged = Signal()
|
||||||
|
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||||
|
# trend: per-frame avg for live graph
|
||||||
|
trendData = Signal(str) # JSON array of floats
|
||||||
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
|
_liveFrame = Signal(object) # Frame
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | None = None,
|
||||||
|
settings: dict | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._mode = MODE_REVIEW
|
||||||
|
self._model = SessionModel()
|
||||||
|
self._player = FramePlayer()
|
||||||
|
self._reader: Optional[StreamReader] = None
|
||||||
|
self._recorder = CsvRecorder()
|
||||||
|
self._sensors: list[Sensor] = []
|
||||||
|
self._last = None # last SessionUpdate
|
||||||
|
self._elapsed = 0.0
|
||||||
|
self._trend_buffer: list[float] = [] # rolling window
|
||||||
|
self._trend_timestamps: list[float] = [] # monotonic timestamps
|
||||||
|
|
||||||
|
# Trend refresh timer - update every 1s
|
||||||
|
self._trend_timer = QTimer(self)
|
||||||
|
self._trend_timer.setInterval(1000)
|
||||||
|
self._trend_timer.timeout.connect(self._flush_trend)
|
||||||
|
|
||||||
|
self._play_timer = QTimer(self)
|
||||||
|
self._play_timer.timeout.connect(self._advance)
|
||||||
|
self._speed = 1.0
|
||||||
|
|
||||||
|
# Q1: coalesce live repaints to ~20 Hz; data is still processed every frame.
|
||||||
|
self._repaint_timer = QTimer(self)
|
||||||
|
self._repaint_timer.setInterval(50) # ~20 Hz
|
||||||
|
self._repaint_timer.timeout.connect(self._flush_repaint)
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
|
self._sensor_editor = SensorEditor()
|
||||||
|
self._last_raw_frame: Frame | None = None
|
||||||
|
self._loaded_file: str = ""
|
||||||
|
self._cluster_averaging_enabled = False
|
||||||
|
self._active_clusters: list[list[int]] = []
|
||||||
|
self._stats_tracker = ReplayStatsTracker()
|
||||||
|
|
||||||
|
# Restore persisted state if available
|
||||||
|
self._load_settings(settings or {})
|
||||||
|
|
||||||
|
# ---- persisted settings ----
|
||||||
|
|
||||||
|
def _load_settings(self, settings: dict) -> None:
|
||||||
|
"""Restore session state from persisted settings dict."""
|
||||||
|
# Do NOT auto-restore the last session file — always start fresh.
|
||||||
|
|
||||||
|
def collect_settings(self) -> dict:
|
||||||
|
"""Return a dict of session state to persist."""
|
||||||
|
return {
|
||||||
|
"session_last_file": self._loaded_file,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- properties QML binds to ----
|
||||||
|
@Property(str, notify=modeChanged)
|
||||||
|
def mode(self) -> str: return self._mode
|
||||||
|
|
||||||
|
@Property(int, notify=frameUpdated)
|
||||||
|
def frameIndex(self) -> int: return self._player.index
|
||||||
|
|
||||||
|
@Property(int, notify=frameUpdated)
|
||||||
|
def frameTotal(self) -> int: return self._player.total
|
||||||
|
|
||||||
|
@Property(str, notify=stateChanged)
|
||||||
|
def state(self) -> str:
|
||||||
|
if self._last:
|
||||||
|
return self._last.state
|
||||||
|
# IF we dont have data yet, but the reader is running -> Streaming
|
||||||
|
if self._mode == MODE_LIVE and self._reader is not None:
|
||||||
|
return "streaming"
|
||||||
|
|
||||||
|
return "idle"
|
||||||
|
|
||||||
|
@Property(bool, notify=recordingChanged)
|
||||||
|
def recording(self) -> bool: return self._recorder.is_recording
|
||||||
|
|
||||||
|
@Property(list, notify=frameUpdated)
|
||||||
|
def sensorDots(self) -> list[dict]:
|
||||||
|
"""Per-sensor render data for the radial map."""
|
||||||
|
if not self._last:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for i, s in enumerate(self._sensors):
|
||||||
|
v = self._last.values[i] if i < len(self._last.values) else 0.0
|
||||||
|
band = self._last.bands[i] if i < len(self._last.bands) else "in_range"
|
||||||
|
out.append({"label": s.label, "x": s.x, "y": s.y,
|
||||||
|
"value": round(v, 2), "band": band, "index": i})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=sensorsChanged)
|
||||||
|
def sensorLayout(self) -> list:
|
||||||
|
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"label": s.label,
|
||||||
|
"x": s.x,
|
||||||
|
"y": s.y,
|
||||||
|
"side": getattr(s, "side", "right"),
|
||||||
|
"offset_x": getattr(s, "offset_x", 0.0),
|
||||||
|
"offset_y": getattr(s, "offset_y", 0.0),
|
||||||
|
}
|
||||||
|
for s in self._sensors
|
||||||
|
]
|
||||||
|
|
||||||
|
@Property(str, notify=sensorsChanged)
|
||||||
|
def waferShape(self) -> str:
|
||||||
|
"""Wafer shape: 'round' or 'square'."""
|
||||||
|
return getattr(self._sensors, "shape", "round")
|
||||||
|
|
||||||
|
@Property(float, notify=sensorsChanged)
|
||||||
|
def waferSize(self) -> float:
|
||||||
|
"""Wafer size in mm."""
|
||||||
|
return getattr(self._sensors, "size", 300.0)
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated)
|
||||||
|
def sensorValues(self) -> list:
|
||||||
|
"""[float] in sensor order."""
|
||||||
|
if not self._last:
|
||||||
|
return []
|
||||||
|
return [round(v, 3) for v in self._last.values]
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated)
|
||||||
|
def sensorBands(self) -> list:
|
||||||
|
"""['in_range'|'high'|'low'] in sensor order."""
|
||||||
|
if not self._last:
|
||||||
|
return []
|
||||||
|
return list(self._last.bands)
|
||||||
|
|
||||||
|
@Property(float, notify=frameUpdated)
|
||||||
|
def target(self) -> float:
|
||||||
|
"""Resolved band center (frame mean in auto mode, else set_point)."""
|
||||||
|
return self._last.target if self._last else 149.0
|
||||||
|
|
||||||
|
@Property(float, notify=frameUpdated)
|
||||||
|
def margin(self) -> float:
|
||||||
|
"""Resolved band half-width (frame 1σ in auto mode, else margin)."""
|
||||||
|
return self._last.margin if self._last else 1.0
|
||||||
|
|
||||||
|
@Property(dict, notify=frameUpdated)
|
||||||
|
def stats(self) -> dict:
|
||||||
|
if not self._last:
|
||||||
|
return {}
|
||||||
|
s = self._last.stats
|
||||||
|
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
||||||
|
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||||
|
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||||
|
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
||||||
|
|
||||||
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def loadedFile(self) -> str: return self._loaded_file
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated)
|
||||||
|
def overriddenSensors(self) -> list[int]:
|
||||||
|
"""Indices of sensors that currently have a replacement or offset."""
|
||||||
|
return self._sensor_editor.active_indices()
|
||||||
|
|
||||||
|
@Property(bool, notify=clusterAveragingEnabledChanged)
|
||||||
|
def clusterAveragingEnabled(self) -> bool:
|
||||||
|
return self._cluster_averaging_enabled
|
||||||
|
|
||||||
|
@clusterAveragingEnabled.setter
|
||||||
|
def clusterAveragingEnabled(self, value: bool) -> None:
|
||||||
|
if self._cluster_averaging_enabled == value:
|
||||||
|
return
|
||||||
|
self._cluster_averaging_enabled = value
|
||||||
|
self.clusterAveragingEnabledChanged.emit()
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
# ---- mode + thresholds ----
|
||||||
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def setMode(self, mode: str) -> None:
|
||||||
|
if mode == self._mode:
|
||||||
|
return
|
||||||
|
self.stopStream()
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._mode = mode
|
||||||
|
self.modeChanged.emit()
|
||||||
|
|
||||||
|
@Slot(float, float, bool)
|
||||||
|
@slot_error_boundary
|
||||||
|
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
||||||
|
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
||||||
|
if self._last: # re-band current frame in place
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
# ---- review: file load + playback ----
|
||||||
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def loadFile(self, file_path: str) -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.data.data_records import (
|
||||||
|
is_official_csv,
|
||||||
|
read_data_records,
|
||||||
|
read_official_csv,
|
||||||
|
)
|
||||||
|
from pygui.backend.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
if is_official_csv(file_path):
|
||||||
|
sensors, records = read_official_csv(file_path)
|
||||||
|
frames = frames_from_wafer_data(None, records)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data, _ = ZWaferParser().parse(file_path)
|
||||||
|
except (ValueError, KeyError) as exc:
|
||||||
|
log.warning("Could not parse %s: %s", file_path, exc)
|
||||||
|
return
|
||||||
|
if data is None or not data.sensors:
|
||||||
|
log.warning("Could not parse %s", file_path)
|
||||||
|
return
|
||||||
|
records = read_data_records(file_path)
|
||||||
|
sensors = data.sensors
|
||||||
|
frames = frames_from_wafer_data(data, records)
|
||||||
|
|
||||||
|
if not sensors or not frames:
|
||||||
|
log.warning("No sensors or data in %s", file_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
wafer_id = ""
|
||||||
|
if not is_official_csv(file_path):
|
||||||
|
wafer_id = data.serial if (data and data.serial) else ""
|
||||||
|
else:
|
||||||
|
stem = Path(file_path).stem
|
||||||
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
|
||||||
|
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
|
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
||||||
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
|
if not self._active_clusters:
|
||||||
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
|
self._player.load(frames)
|
||||||
|
self._model.reset()
|
||||||
|
self._stats_tracker.reset()
|
||||||
|
self._loaded_file = file_path
|
||||||
|
self.loadedFileChanged.emit()
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
self._emit_current()
|
||||||
|
# Populate stats tracker from all loaded frames for trend chart (P3.1)
|
||||||
|
for frame in self._player._frames:
|
||||||
|
self._stats_tracker.track(frame.seq, frame.values)
|
||||||
|
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||||
|
self.settingsChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def play(self) -> None:
|
||||||
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def pause(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._player.seek(0)
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def step(self, delta: int) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._player.step(delta)
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def seek(self, i: int) -> None:
|
||||||
|
self._player.seek(i)
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def setSpeed(self, x: float) -> None:
|
||||||
|
self._speed = max(0.1, x)
|
||||||
|
if self._play_timer.isActive():
|
||||||
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
|
def _next_interval_ms(self) -> int:
|
||||||
|
"""Timer interval in ms for the next frame, scaled by playback speed. Falls back to 500ms."""
|
||||||
|
ms = self._player.next_frame_ms()
|
||||||
|
if ms <= 0:
|
||||||
|
ms = 500.0
|
||||||
|
return max(1, int(ms / self._speed))
|
||||||
|
|
||||||
|
def _advance(self) -> None:
|
||||||
|
if self._player.at_end:
|
||||||
|
self._play_timer.stop()
|
||||||
|
return
|
||||||
|
self._player.step(1)
|
||||||
|
self._emit_current()
|
||||||
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
|
def _apply_pipeline(self, raw_values: list[float]) -> list[float]:
|
||||||
|
"""Apply override first, then optional cluster averaging."""
|
||||||
|
edited = self._sensor_editor.apply(raw_values)
|
||||||
|
if getattr(self, "_cluster_averaging_enabled", False):
|
||||||
|
return average_clusters(edited, getattr(self, "_active_clusters", []))
|
||||||
|
return edited
|
||||||
|
|
||||||
|
def _emit_current(self) -> None:
|
||||||
|
frame = self._player.current()
|
||||||
|
if frame is None:
|
||||||
|
return
|
||||||
|
self._last_raw_frame = frame
|
||||||
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
|
values=self._apply_pipeline(frame.values))
|
||||||
|
self._last = self._model.process(edited)
|
||||||
|
self.frameUpdated.emit()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
def _reprocess_current(self) -> None:
|
||||||
|
"""Re-run the model on the last raw frame"""
|
||||||
|
frame = self._last_raw_frame
|
||||||
|
if frame is None:
|
||||||
|
return
|
||||||
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
|
values=self._apply_pipeline(frame.values))
|
||||||
|
self._last = self._model.process(edited)
|
||||||
|
self.frameUpdated.emit()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
# ---- live: stream start/stop ----
|
||||||
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def startStream(self, port: str, family_code: str = "") -> None:
|
||||||
|
|
||||||
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
|
from pygui.serialcomm.serial_port import SerialPort # transport open
|
||||||
|
|
||||||
|
if family_code:
|
||||||
|
try:
|
||||||
|
self._sensors = load_layout_for_wafer_id(family_code)
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Could not load layout for %s: %s", family_code, e)
|
||||||
|
|
||||||
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
|
if not self._active_clusters:
|
||||||
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
|
|
||||||
|
# The new binary protocol sends payload of (sensorCount * 2) bytes
|
||||||
|
# Each sensor is a big-endian 16-bit value (Sign-Magnitude).
|
||||||
|
expected_sensors = 80 if (family_code or "") == "X" else 244
|
||||||
|
|
||||||
|
def parse_binary_frame(payload: bytes, seq: int) -> Frame:
|
||||||
|
values = []
|
||||||
|
num_sensors = min(expected_sensors, len(payload) // 2)
|
||||||
|
for i in range(num_sensors):
|
||||||
|
high_byte = payload[i * 2]
|
||||||
|
low_byte = payload[i * 2 + 1]
|
||||||
|
val16 = (high_byte << 8) | low_byte
|
||||||
|
|
||||||
|
is_negative = (val16 & 0x8000) != 0
|
||||||
|
integer_part = (val16 >> 7) & 0xFF
|
||||||
|
fractional_bits = val16 & 0x7F
|
||||||
|
fractional_part = fractional_bits / 128.0
|
||||||
|
result = integer_part + fractional_part
|
||||||
|
if is_negative:
|
||||||
|
result = -result
|
||||||
|
values.append(result)
|
||||||
|
|
||||||
|
return Frame(seq=seq, time=time.monotonic(), values=values)
|
||||||
|
|
||||||
|
# Clear out any old data from the prev sessions
|
||||||
|
self._model.reset()
|
||||||
|
self._trend_buffer.clear()
|
||||||
|
self._trend_timestamps.clear()
|
||||||
|
self._trend_timer.start()
|
||||||
|
self._stats_tracker.reset()
|
||||||
|
self._last = None
|
||||||
|
self._last_raw_frame = None
|
||||||
|
|
||||||
|
transport = SerialPort.open_port(port, timeout=1)
|
||||||
|
# Send 'D2' command padded to 512 bytes to start the stream
|
||||||
|
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
||||||
|
transport.write(cmd)
|
||||||
|
|
||||||
|
def on_error(exc: Exception):
|
||||||
|
log.error("Live stream error: %s", exc)
|
||||||
|
|
||||||
|
self._reader = StreamReader(
|
||||||
|
transport, parse_binary_frame,
|
||||||
|
on_frame=lambda f: self._liveFrame.emit(f),
|
||||||
|
on_error=on_error,
|
||||||
|
family_code=family_code or "A")
|
||||||
|
self._reader.start()
|
||||||
|
self._repaint_timer.start()
|
||||||
|
|
||||||
|
self.setMode("live")
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def stopStream(self) -> None:
|
||||||
|
self._repaint_timer.stop()
|
||||||
|
self._trend_timer.stop()
|
||||||
|
if self._reader:
|
||||||
|
transport = self._reader._transport
|
||||||
|
self._reader.stop()
|
||||||
|
self._reader = None
|
||||||
|
|
||||||
|
# Send 'D2S' command padded to 512 bytes to stop the stream
|
||||||
|
if transport and transport.is_open:
|
||||||
|
try:
|
||||||
|
cmd = b"D2S" + (b"F" * 509)
|
||||||
|
transport.write(cmd)
|
||||||
|
transport.flush()
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Error sending stop command: %s", exc)
|
||||||
|
|
||||||
|
self.stateChanged.emit()
|
||||||
|
self.stopRecording()
|
||||||
|
|
||||||
|
@Slot(object)
|
||||||
|
@slot_error_boundary
|
||||||
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
|
import json
|
||||||
|
self._stats_tracker.track(frame.seq, frame.values)
|
||||||
|
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||||
|
self._last_raw_frame = frame
|
||||||
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
|
values=self._apply_pipeline(frame.values))
|
||||||
|
self._last = self._model.process(edited)
|
||||||
|
if self._recorder.is_recording:
|
||||||
|
self._recorder.write(frame) # always record raw values, never edited
|
||||||
|
self._dirty = True
|
||||||
|
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||||
|
|
||||||
|
if self._last and self._last.stats:
|
||||||
|
avg = self._last.stats.avg
|
||||||
|
now = time.monotonic()
|
||||||
|
self._trend_buffer.append(avg)
|
||||||
|
self._trend_timestamps.append(now)
|
||||||
|
|
||||||
|
# drop entries older than 60 secs
|
||||||
|
cutoff = now -60
|
||||||
|
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
|
||||||
|
self._trend_timestamps.pop(0)
|
||||||
|
self._trend_buffer.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_repaint(self) -> None:
|
||||||
|
if not self._dirty:
|
||||||
|
return
|
||||||
|
self._dirty = False
|
||||||
|
self.frameUpdated.emit()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
# ---- recording ----
|
||||||
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def startRecording(self, path: str, serial: str = "") -> None:
|
||||||
|
self._recorder.start(path, self._sensors, serial)
|
||||||
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def stopRecording(self) -> None:
|
||||||
|
if self._recorder.is_recording:
|
||||||
|
self._recorder.stop()
|
||||||
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def replaceSensor(self, index: int, value: float) -> None:
|
||||||
|
"""Override sensor `index` to display `value` every frame."""
|
||||||
|
self._sensor_editor.set_replacement(index, value)
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def offsetSensor(self, index: int, delta: float) -> None:
|
||||||
|
"""Shift sensor `index` by `delta` every frame."""
|
||||||
|
self._sensor_editor.set_offset(index, delta)
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def clearSensorEdit(self, index: int) -> None:
|
||||||
|
"""Remove all overrides for sensor `index`."""
|
||||||
|
self._sensor_editor.clear(index)
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def clearSensorEdits(self) -> None:
|
||||||
|
"""Remove all sensor overrides."""
|
||||||
|
self._sensor_editor.clear()
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
def _flush_trend(self) -> None:
|
||||||
|
import json
|
||||||
|
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# ===== Crypto Sub-package =====
|
||||||
|
from pygui.backend.crypto.crypto_helper import * # noqa: F403
|
||||||
@@ -2,8 +2,8 @@ import secrets
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
|
|
||||||
# ===== Crypto Utilities =====
|
# ===== Crypto Utilities =====
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ===== 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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# ===== Backend Data Constants =====
|
||||||
|
|
||||||
|
DEFAULT_DATA_DIR_NAME = "isc_data"
|
||||||
@@ -25,8 +25,8 @@ class CSVFileMetadata:
|
|||||||
try:
|
try:
|
||||||
return datetime.strptime(self.date, date_format)
|
return datetime.strptime(self.date, date_format)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
# If format is wrong or date is None, return current time
|
# If format is wrong or date is None, return None
|
||||||
return datetime.now()
|
return None
|
||||||
|
|
||||||
# ===== Formatting =====
|
# ===== Formatting =====
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -48,4 +48,5 @@ class CSVFileMetadata:
|
|||||||
|
|
||||||
def string_date_format(self) -> str:
|
def string_date_format(self) -> str:
|
||||||
"""Helper to ensure the date is always a string for JSON."""
|
"""Helper to ensure the date is always a string for JSON."""
|
||||||
return self.format_date(self.get_date())
|
dt = self.get_date()
|
||||||
|
return self.format_date(dt) if dt else self.date
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Append live frames"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, TextIO
|
||||||
|
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
class CsvRecorder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._file_handle: Optional[TextIO] = None
|
||||||
|
self._oath: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_recording(self) -> bool:
|
||||||
|
return self._file_handle is not None
|
||||||
|
|
||||||
|
|
||||||
|
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
|
||||||
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handle = open(path, "w", encoding="utf-8", newline ="")
|
||||||
|
file_handle.write(f"Wafer ID={serial}\n")
|
||||||
|
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
|
||||||
|
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
|
||||||
|
file_handle.write("X (mm)," + ",".join(str(s.x) for s in sensors) + "\n")
|
||||||
|
file_handle.write("Y (mm)," + ",".join(str(s.y) for s in sensors) + "\n")
|
||||||
|
file_handle.write("data\n")
|
||||||
|
file_handle.flush()
|
||||||
|
self._file_handle, self._path = file_handle, path
|
||||||
|
|
||||||
|
def write(self, frame: Frame) -> None:
|
||||||
|
if self._file_handle is None:
|
||||||
|
return
|
||||||
|
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
|
||||||
|
self._file_handle.write(",".join(row) + "\n")
|
||||||
|
self._file_handle.flush()
|
||||||
|
|
||||||
|
def stop(self) -> Optional[str]:
|
||||||
|
if self._file_handle is None:
|
||||||
|
return None
|
||||||
|
self._file_handle.close()
|
||||||
|
path, self._file_handle, self._path = self._path, None, None
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) ->bool:
|
||||||
|
"""Copy a range of frames from source CSV into a new file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Output CSV path.
|
||||||
|
source_path: Source CSV path with full wafer data
|
||||||
|
start_frame: First frame index (inclusive).
|
||||||
|
end_frame: Last frame index (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success.
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
df = pd.read_csv(source_path)
|
||||||
|
segment = df.iloc[start_frame:end_frame + 1]
|
||||||
|
segment.to_csv(file_path, index=False)
|
||||||
|
return True
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def read_data_records(file_path: str) -> list[DataRecord]:
|
||||||
|
records: list[DataRecord] = []
|
||||||
|
in_data = False
|
||||||
|
with Path(file_path).open("r", encoding="utf-8") as file_handle:
|
||||||
|
for line in file_handle:
|
||||||
|
line = line.rstrip().rstrip(",").strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if not in_data:
|
||||||
|
if line.split(",")[0].lower() == "data":
|
||||||
|
in_data = True
|
||||||
|
continue
|
||||||
|
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
||||||
|
try:
|
||||||
|
nums = [float(c) for c in cols]
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not nums:
|
||||||
|
continue
|
||||||
|
records.append(DataRecord(time=nums[0], values=nums[1:]))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def is_official_csv(file_path: str) -> bool:
|
||||||
|
"""Return True if the file uses the headerless official format (sensor-name header row, no 'data' sentinel)."""
|
||||||
|
with Path(file_path).open("r", encoding="utf-8") as f:
|
||||||
|
first = f.readline().rstrip().rstrip(",")
|
||||||
|
if not first:
|
||||||
|
return False
|
||||||
|
parts = [p.strip() for p in first.split(",") if p.strip()]
|
||||||
|
# Official format: first cell looks like "SensorN" (starts with letter, not key=value)
|
||||||
|
return bool(parts) and parts[0].lower().startswith("sensor") and "=" not in parts[0]
|
||||||
|
|
||||||
|
|
||||||
|
def read_official_csv(file_path: str) -> tuple[list[Sensor], list[DataRecord]]:
|
||||||
|
"""Parse the headerless official CSV: row 0 = sensor names, rows 1+ = values (no timestamp)."""
|
||||||
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
|
|
||||||
|
stem = Path(file_path).stem
|
||||||
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
try:
|
||||||
|
sensors = load_layout_for_wafer_id(wafer_id)
|
||||||
|
except KeyError:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
records: list[DataRecord] = []
|
||||||
|
with Path(file_path).open("r", encoding="utf-8") as f:
|
||||||
|
f.readline() # skip sensor-name header
|
||||||
|
for i, line in enumerate(f):
|
||||||
|
line = line.rstrip().rstrip(",")
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
cols = [c.strip() for c in line.split(",") if c.strip()]
|
||||||
|
try:
|
||||||
|
values = [float(c) for c in cols]
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if values:
|
||||||
|
records.append(DataRecord(time=float(i) * 0.5, values=values))
|
||||||
|
return sensors, records
|
||||||
@@ -5,11 +5,12 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Property, QStandardPaths, Signal, Slot
|
from PySide6.QtCore import Property, QObject, 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.data.constants import DEFAULT_DATA_DIR_NAME
|
||||||
from backend.zwafer_parser import ZWaferParser
|
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
|
|
||||||
# ===== File Browser Model =====
|
# ===== File Browser Model =====
|
||||||
@@ -120,24 +121,49 @@ class FileBrowser(QObject):
|
|||||||
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
||||||
try:
|
try:
|
||||||
metadata = self._load_metadata(csv_path)
|
metadata = self._load_metadata(csv_path)
|
||||||
|
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
|
||||||
|
date_str = metadata.string_date_format()
|
||||||
|
serial = metadata.wafer[1:] if len(metadata.wafer) > 1 else ""
|
||||||
|
if not serial:
|
||||||
|
stem = csv_path.stem
|
||||||
|
serial = stem[1:] if len(stem) > 1 else ""
|
||||||
|
if "-" in serial:
|
||||||
|
serial = serial.split("-")[0]
|
||||||
|
if "_" in serial:
|
||||||
|
serial = serial.split("_")[0]
|
||||||
|
time_str = date_str[11:] if len(date_str) > 10 else ""
|
||||||
self._files.append(
|
self._files.append(
|
||||||
{
|
{
|
||||||
"selected": selected_state.get(str(csv_path), False),
|
"selected": selected_state.get(str(csv_path), False),
|
||||||
|
"baseName": csv_path.stem,
|
||||||
"wafer": metadata.wafer,
|
"wafer": metadata.wafer,
|
||||||
"date": metadata.string_date_format(),
|
"waferType": wafer_type,
|
||||||
|
"serialNumber": serial,
|
||||||
|
"date": date_str,
|
||||||
|
"timeStr": time_str,
|
||||||
"chamber": metadata.chamber,
|
"chamber": metadata.chamber,
|
||||||
"notes": metadata.notes,
|
"notes": metadata.notes,
|
||||||
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
|
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
|
||||||
"fileName": str(csv_path),
|
"fileName": str(csv_path),
|
||||||
"highlight": metadata.get_wafer_type() in {"A", "B", "C"},
|
"highlight": wafer_type in {"A", "B", "C"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
stem = csv_path.stem
|
||||||
|
serial_fallback = stem[1:] if len(stem) > 1 else ""
|
||||||
|
if "-" in serial_fallback:
|
||||||
|
serial_fallback = serial_fallback.split("-")[0]
|
||||||
|
if "_" in serial_fallback:
|
||||||
|
serial_fallback = serial_fallback.split("_")[0]
|
||||||
self._files.append(
|
self._files.append(
|
||||||
{
|
{
|
||||||
"selected": selected_state.get(str(csv_path), False),
|
"selected": selected_state.get(str(csv_path), False),
|
||||||
"wafer": csv_path.stem,
|
"baseName": stem,
|
||||||
|
"wafer": stem,
|
||||||
|
"waferType": stem[0].upper() if stem else "",
|
||||||
|
"serialNumber": serial_fallback,
|
||||||
"date": "",
|
"date": "",
|
||||||
|
"timeStr": "",
|
||||||
"chamber": "",
|
"chamber": "",
|
||||||
"notes": "Unable to parse metadata",
|
"notes": "Unable to parse metadata",
|
||||||
"masterType": master_state.get(str(csv_path), ""),
|
"masterType": master_state.get(str(csv_path), ""),
|
||||||
@@ -168,25 +194,19 @@ class FileBrowser(QObject):
|
|||||||
# Fall back to CSV/header parsing if sidecar is malformed.
|
# Fall back to CSV/header parsing if sidecar is malformed.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
meta = self._metadata_from_filename(csv_path)
|
||||||
parser_data, _ = self._parser.parse(str(csv_path))
|
parser_data, _ = self._parser.parse(str(csv_path))
|
||||||
if parser_data is not None:
|
if parser_data is not None:
|
||||||
wafer = parser_data.serial or ""
|
if parser_data.serial:
|
||||||
date_text = ""
|
meta.wafer = parser_data.serial
|
||||||
if parser_data.date != datetime.min:
|
if parser_data.date != datetime.min:
|
||||||
date_text = CSVFileMetadata.format_date(parser_data.date)
|
meta.date = CSVFileMetadata.format_date(parser_data.date)
|
||||||
return CSVFileMetadata(
|
|
||||||
wafer=wafer,
|
|
||||||
date=date_text,
|
|
||||||
chamber="",
|
|
||||||
notes="",
|
|
||||||
filename=str(csv_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
return self._metadata_from_filename(csv_path)
|
return meta
|
||||||
|
|
||||||
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
|
def _metadata_from_filename(self, csv_path: Path) -> CSVFileMetadata:
|
||||||
match = re.match(
|
match = re.match(
|
||||||
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<run>\d+))?$",
|
r"^(?P<wafer>[^-]+)-(?P<date>\d{8})(?:_(?P<time>\d{6}))?$",
|
||||||
csv_path.stem,
|
csv_path.stem,
|
||||||
)
|
)
|
||||||
if not match:
|
if not match:
|
||||||
@@ -194,7 +214,9 @@ class FileBrowser(QObject):
|
|||||||
|
|
||||||
date_text = ""
|
date_text = ""
|
||||||
try:
|
try:
|
||||||
parsed = datetime.strptime(match.group("date"), "%Y%m%d")
|
date_part = match.group("date")
|
||||||
|
time_part = match.group("time") or "000000"
|
||||||
|
parsed = datetime.strptime(date_part + time_part, "%Y%m%d%H%M%S")
|
||||||
date_text = CSVFileMetadata.format_date(parsed)
|
date_text = CSVFileMetadata.format_date(parsed)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
date_text = ""
|
date_text = ""
|
||||||
@@ -211,7 +233,7 @@ class FileBrowser(QObject):
|
|||||||
QStandardPaths.DocumentsLocation
|
QStandardPaths.DocumentsLocation
|
||||||
)
|
)
|
||||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||||
return base_dir / "isc_data"
|
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)
|
||||||
@@ -236,7 +258,7 @@ class FileBrowser(QObject):
|
|||||||
def showInfo(self, message: str) -> None:
|
def showInfo(self, message: str) -> None:
|
||||||
self._show_info(message)
|
self._show_info(message)
|
||||||
|
|
||||||
@Slot(str, result="QVariantMap")
|
@Slot(str, result=dict)
|
||||||
def parseCsvMetadata(self, file_path: str) -> dict:
|
def parseCsvMetadata(self, file_path: str) -> dict:
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ===== Settings Persistence Model =====
|
# ===== Settings Persistence Model =====
|
||||||
class LocalSettings:
|
class LocalSettings:
|
||||||
@@ -29,6 +32,9 @@ 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:
|
||||||
@@ -51,7 +57,8 @@ class LocalSettings:
|
|||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading setting file: {e}")
|
log = logging.getLogger(__name__)
|
||||||
|
log.warning("Error reading setting file: %s",e)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@@ -66,7 +73,7 @@ class LocalSettings:
|
|||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=4)
|
json.dump(data, f, indent=4)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving settings: {e}")
|
log.exception("Error saving settings: %s",e)
|
||||||
|
|
||||||
# ===== Master File Helpers =====
|
# ===== Master File Helpers =====
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -4,10 +4,10 @@ from copy import deepcopy
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Property, QDateTime, QStandardPaths, Signal, Slot
|
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot
|
||||||
|
|
||||||
from backend.local_settings import LocalSettings
|
|
||||||
|
|
||||||
|
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
|
||||||
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class LocalSettingsModel(QObject):
|
|||||||
QStandardPaths.DocumentsLocation
|
QStandardPaths.DocumentsLocation
|
||||||
)
|
)
|
||||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||||
return base_dir / "isc_data"
|
return base_dir / DEFAULT_DATA_DIR_NAME
|
||||||
|
|
||||||
def _new_defaults(self) -> dict[str, Any]:
|
def _new_defaults(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -221,7 +221,7 @@ class LocalSettingsModel(QObject):
|
|||||||
self.waferRetriesChanged.emit()
|
self.waferRetriesChanged.emit()
|
||||||
self._recompute_derived()
|
self._recompute_derived()
|
||||||
|
|
||||||
@Property("QVariantMap", notify=mastersChanged)
|
@Property(dict, notify=mastersChanged)
|
||||||
def masters(self) -> dict[str, str]:
|
def masters(self) -> dict[str, str]:
|
||||||
return dict(self._masters)
|
return dict(self._masters)
|
||||||
|
|
||||||
@@ -334,27 +334,3 @@ class LocalSettingsModel(QObject):
|
|||||||
@Slot(str)
|
@Slot(str)
|
||||||
def clearMaster(self, family: str) -> None:
|
def clearMaster(self, family: str) -> None:
|
||||||
self.setMaster(family, "")
|
self.setMaster(family, "")
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def setChamberId(self, value: str) -> None:
|
|
||||||
self.chamberId = value
|
|
||||||
|
|
||||||
@Slot(bool)
|
|
||||||
def setReverseZWafer(self, value: bool) -> None:
|
|
||||||
self.reverseZWafer = value
|
|
||||||
|
|
||||||
@Slot(bool)
|
|
||||||
def setDebugMode(self, value: bool) -> None:
|
|
||||||
self.debugMode = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferReadTimeout(self, value: int) -> None:
|
|
||||||
self.waferReadTimeout = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferDetectTimeout(self, value: int) -> None:
|
|
||||||
self.waferDetectTimeout = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferRetries(self, value: int) -> None:
|
|
||||||
self.waferRetries = value
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# ===== 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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
|
time: float # seconds (relative or epoch)
|
||||||
|
values: list[float] # one per sensor, in sensor-layout order
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.wafer.zwafer_models import ZWaferData
|
||||||
|
|
||||||
|
|
||||||
|
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
|
||||||
|
"""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)]
|
||||||
|
|
||||||
|
class FramePlayer:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._frames: list[Frame] =[]
|
||||||
|
self._i =0
|
||||||
|
|
||||||
|
def load(self, frames: list[Frame]) -> "FramePlayer":
|
||||||
|
self._frames = frames
|
||||||
|
self._i = 0
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return len(self._frames)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index(self) -> int:
|
||||||
|
return self._i
|
||||||
|
|
||||||
|
@property
|
||||||
|
def at_end(self) -> bool:
|
||||||
|
return self._i >= len(self._frames) -1
|
||||||
|
|
||||||
|
def current(self) -> Frame | None:
|
||||||
|
if not self._frames:
|
||||||
|
return None
|
||||||
|
return self._frames[self._i]
|
||||||
|
|
||||||
|
def _clamp(self, i:int):
|
||||||
|
return max(0, min(i, len(self._frames) - 1)) if self._frames else 0
|
||||||
|
|
||||||
|
def step(self, delta: int) -> "FramePlayer":
|
||||||
|
self._i = self._clamp(self._i + delta)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def seek(self, i: int) -> "FramePlayer":
|
||||||
|
self._i = self._clamp(i)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def next_frame_ms(self) -> float:
|
||||||
|
"""Wall-clock ms until the next frame based on recorded timestamps; 0 if at end."""
|
||||||
|
if self._i + 1 >= len(self._frames):
|
||||||
|
return 0.0
|
||||||
|
return (self._frames[self._i + 1].time - self._frames[self._i].time) * 1000.0
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayStatsTracker:
|
||||||
|
"""Accumulates per-frame statistics across a replay session."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.history: list[dict] = []
|
||||||
|
self.running_avg: float = 0.0
|
||||||
|
|
||||||
|
def track(self, frame_index: int, temperatures: list[float]) -> dict:
|
||||||
|
arr = np.array(temperatures, dtype=np.float32)
|
||||||
|
clean_arr = arr[np.isfinite(arr)]
|
||||||
|
|
||||||
|
if clean_arr.size == 0:
|
||||||
|
stats = {"frame": frame_index, "min":0.0, "max":0.0, "avg":0.0, "std":0.0}
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
"frame": frame_index,
|
||||||
|
"min": float(np.min(clean_arr)),
|
||||||
|
"max": float(np.max(clean_arr)),
|
||||||
|
"avg": float(np.mean(clean_arr)),
|
||||||
|
"std": float(np.std(clean_arr)),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.history.append(stats)
|
||||||
|
self.running_avg = float(np.mean([s["avg"] for s in self.history]))
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def get_trend(self) -> list[float]:
|
||||||
|
"""Return list of per-frame averages for the trend chart."""
|
||||||
|
return [s["avg"] for s in self.history]
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.history.clear()
|
||||||
|
self.running_avg = 0.0
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Per-session sensor value overrides (replacement and/or offset)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class SensorEditor:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._replacements: dict[int, float] = {}
|
||||||
|
self._offsets: dict[int, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_replacement(self, index: int, value: float) -> None:
|
||||||
|
"""Force sensor `index` to read `value` for every frame this session."""
|
||||||
|
self._replacements[index] = value
|
||||||
|
|
||||||
|
def set_offset(self, index: int, value: float) -> None:
|
||||||
|
"""Shift sensor `index` by `delta` (applied after any replacement)."""
|
||||||
|
self._offsets[index] = value
|
||||||
|
|
||||||
|
def clear(self, index: int | None = None) -> None:
|
||||||
|
"""Remove overrides. Pass an index to clear one sensor; omit to clear all."""
|
||||||
|
if index is None:
|
||||||
|
self._replacements.clear()
|
||||||
|
self._offsets.clear()
|
||||||
|
else:
|
||||||
|
self._replacements.pop(index, None)
|
||||||
|
self._offsets.pop(index, None)
|
||||||
|
def has_overrides(self) -> bool:
|
||||||
|
return bool(self._replacements or self._offsets)
|
||||||
|
|
||||||
|
def active_indices(self) -> list[int]:
|
||||||
|
"""Return sorted sensor indices that have any override."""
|
||||||
|
return sorted(set(self._replacements) | set(self._offsets))
|
||||||
|
|
||||||
|
|
||||||
|
def apply(self, values: list[float]) -> list[float]:
|
||||||
|
"""Return a new list wit all overrides applied (original unchanged)"""
|
||||||
|
if not self.has_overrides():
|
||||||
|
return list(values)
|
||||||
|
result = list(values)
|
||||||
|
for i in range(len(result)):
|
||||||
|
v = result[i]
|
||||||
|
if i in self._replacements:
|
||||||
|
v = self._replacements[i]
|
||||||
|
if i in self._offsets:
|
||||||
|
v += self._offsets[i]
|
||||||
|
result[i] = v
|
||||||
|
return result
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.models.frame_stats import Stats, compute_stats
|
||||||
|
from pygui.backend.models.stability_detector import StabilityDetector
|
||||||
|
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SessionUpdate:
|
||||||
|
seq: int
|
||||||
|
values: list[float]
|
||||||
|
bands: list[str]
|
||||||
|
stats: Stats
|
||||||
|
state: str
|
||||||
|
target: float
|
||||||
|
margin: float
|
||||||
|
|
||||||
|
class SessionModel:
|
||||||
|
def __init__(self, thresholds: ThresholdConfig | None = None) -> None:
|
||||||
|
self._config = thresholds or ThresholdConfig()
|
||||||
|
self._stability = StabilityDetector()
|
||||||
|
|
||||||
|
def set_thresholds(self, config: ThresholdConfig) -> None:
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._stability.reset()
|
||||||
|
|
||||||
|
def process(self, frame: Frame) -> SessionUpdate:
|
||||||
|
stats = compute_stats(frame.values)
|
||||||
|
target, margin = resolve_bounds(frame.values, self._config)
|
||||||
|
bands = [classify(v, target, margin)for v in frame.values]
|
||||||
|
# State always track the user's process set_point, even in auto-color mode.
|
||||||
|
state = self._stability.update(stats.avg, frame.time, self._config.set_point)
|
||||||
|
return SessionUpdate(
|
||||||
|
seq=frame.seq, values=frame.values, bands=bands,
|
||||||
|
stats=stats, state=state, target=target, margin=margin,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""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 ]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Threshold-based temperature profile segmentation into Ramp/Soak/Cool phases."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataSegment:
|
||||||
|
"""A contiguous segment of the temperature profile."""
|
||||||
|
label: str
|
||||||
|
start_frame: int
|
||||||
|
end_frame: int
|
||||||
|
avg_temp: float
|
||||||
|
|
||||||
|
|
||||||
|
def segment_profile(avg_temps: list[float], threshold: float = 40.0) -> list[DataSegment]:
|
||||||
|
"""Scan average temperatures and identify Ramp/Soak/Cool phases.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
avg_temps: Per-frame average temperatures.
|
||||||
|
threshold: Temperature threshold (°C) defining Soak entry/exit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DataSegment objects covering the entire profile.
|
||||||
|
"""
|
||||||
|
if not avg_temps:
|
||||||
|
return []
|
||||||
|
|
||||||
|
segments: list[DataSegment] = []
|
||||||
|
n = len(avg_temps)
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < n and avg_temps[i] < threshold:
|
||||||
|
i += 1
|
||||||
|
if i > 0:
|
||||||
|
avg = sum(avg_temps[:i]) / i
|
||||||
|
segments.append(DataSegment("Ramp", 0, i - 1, round(avg, 2)))
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
start = i
|
||||||
|
above = avg_temps[i] >= threshold
|
||||||
|
while i < n and (avg_temps[i] >= threshold) == above:
|
||||||
|
i += 1
|
||||||
|
label = "Soak" if above else "Cool"
|
||||||
|
count = i - start
|
||||||
|
avg = sum(avg_temps[start:i]) / count
|
||||||
|
segments.append(DataSegment(label, start, i - 1, round(avg, 2)))
|
||||||
|
|
||||||
|
return segments
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
def slot_error_boundary(func):
|
||||||
|
"""Wrap a @Slot method so exceptions are logged + signalled to QML.
|
||||||
|
|
||||||
|
Usage (stack under @Slot):
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def myMethod(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
On exception:
|
||||||
|
- Logs full traceback via logger.exception()
|
||||||
|
- Emits logMessage signal with human-readable error (if available)
|
||||||
|
- Returns None so QML never sees a crash
|
||||||
|
"""
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logging.getLogger(self.__class__.__module__)
|
||||||
|
logger.exception("Slot '%s' raised: %s", func.__name__, e)
|
||||||
|
if hasattr(self, "logMessage"):
|
||||||
|
self.logMessage.emit(f"Error in {func.__name__}: {str(e)}")
|
||||||
|
return None
|
||||||
|
return wrapper
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# ===== 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,417 @@
|
|||||||
|
"""QQuickPaintedItem line chart for embedding in QML.
|
||||||
|
|
||||||
|
Draws multiple sensor-data line series with axis labels, grid lines,
|
||||||
|
auto-scaled Y range, legend, and a dark-theme default palette that
|
||||||
|
complements Theme.qml.
|
||||||
|
|
||||||
|
Usage from QML::
|
||||||
|
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: graph
|
||||||
|
anchors.fill: parent
|
||||||
|
seriesData: [...]
|
||||||
|
sensorNames: ["Sensor1", "Sensor2"]
|
||||||
|
title: "Sensor Temperature Over Time"
|
||||||
|
yLabel: "Temperature (°C)"
|
||||||
|
xLabel: "Measurement"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QRectF, Qt, Signal
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class GraphQuickItem(QQuickPaintedItem):
|
||||||
|
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||||
|
|
||||||
|
seriesDataChanged = Signal()
|
||||||
|
sensorNamesChanged = Signal()
|
||||||
|
titleChanged = Signal()
|
||||||
|
xLabelChanged = Signal()
|
||||||
|
yLabelChanged = Signal()
|
||||||
|
colorsChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._series_data: list[list[float]] = [] # [[sensor1_vals...], [sensor2_vals...]]
|
||||||
|
self._sensor_names: list[str] = [] # parallel to series_data
|
||||||
|
self._title: str = ""
|
||||||
|
self._x_label: str = "Measurement"
|
||||||
|
self._y_label: str = "Temperature (°C)"
|
||||||
|
self._show_legend: bool = True
|
||||||
|
|
||||||
|
# Dark-theme colour defaults (match Theme.qml where possible)
|
||||||
|
self._bg_color = QColor("#1A1A1A") # tone200
|
||||||
|
self._grid_color = QColor("#2A2A2A") # toneBorder
|
||||||
|
self._axis_color = QColor("#A8A8A8") # toneMute
|
||||||
|
self._text_color = QColor("#F2F2F2") # toneText
|
||||||
|
self._series_colors: list[QColor] = [
|
||||||
|
QColor("#FF5757"), # Red
|
||||||
|
QColor("#42A5F5"), # Blue
|
||||||
|
QColor("#66BB6A"), # Green
|
||||||
|
QColor("#FFA726"), # Orange
|
||||||
|
QColor("#AB47BC"), # Purple
|
||||||
|
QColor("#00BCD4"), # Cyan
|
||||||
|
QColor("#FF7043"), # Deep Orange
|
||||||
|
QColor("#795548"), # Brown
|
||||||
|
QColor("#5C6BC0"), # Indigo
|
||||||
|
QColor("#307D75"), # Teal
|
||||||
|
]
|
||||||
|
|
||||||
|
self._min_y: float = 0.0
|
||||||
|
self._max_y: float = 150.0
|
||||||
|
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
||||||
|
|
||||||
|
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=seriesDataChanged)
|
||||||
|
def seriesData(self) -> list:
|
||||||
|
return self._series_data
|
||||||
|
|
||||||
|
@seriesData.setter
|
||||||
|
def seriesData(self, val: list) -> None:
|
||||||
|
self._series_data = [list(s) for s in (val or [])]
|
||||||
|
self._auto_range()
|
||||||
|
self.seriesDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QStringList", notify=sensorNamesChanged)
|
||||||
|
def sensorNames(self) -> list:
|
||||||
|
return self._sensor_names
|
||||||
|
|
||||||
|
@sensorNames.setter
|
||||||
|
def sensorNames(self, val: list) -> None:
|
||||||
|
self._sensor_names = list(val or [])
|
||||||
|
self.sensorNamesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=titleChanged)
|
||||||
|
def title(self) -> str:
|
||||||
|
return self._title
|
||||||
|
|
||||||
|
@title.setter
|
||||||
|
def title(self, val: str) -> None:
|
||||||
|
self._title = str(val or "")
|
||||||
|
self.titleChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=xLabelChanged)
|
||||||
|
def xLabel(self) -> str:
|
||||||
|
return self._x_label
|
||||||
|
|
||||||
|
@xLabel.setter
|
||||||
|
def xLabel(self, val: str) -> None:
|
||||||
|
self._x_label = str(val or "")
|
||||||
|
self.xLabelChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=yLabelChanged)
|
||||||
|
def yLabel(self) -> str:
|
||||||
|
return self._y_label
|
||||||
|
|
||||||
|
@yLabel.setter
|
||||||
|
def yLabel(self, val: str) -> None:
|
||||||
|
self._y_label = str(val or "")
|
||||||
|
self.yLabelChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=colorsChanged)
|
||||||
|
def showLegend(self) -> bool:
|
||||||
|
return self._show_legend
|
||||||
|
|
||||||
|
@showLegend.setter
|
||||||
|
def showLegend(self, val: bool) -> None:
|
||||||
|
self._show_legend = bool(val)
|
||||||
|
self.colorsChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Colour properties (QML can bind Theme tokens here) ────────────────────
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def backgroundColor(self) -> QColor:
|
||||||
|
return self._bg_color
|
||||||
|
|
||||||
|
@backgroundColor.setter
|
||||||
|
def backgroundColor(self, c: QColor) -> None:
|
||||||
|
self._bg_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def gridColor(self) -> QColor:
|
||||||
|
return self._grid_color
|
||||||
|
|
||||||
|
@gridColor.setter
|
||||||
|
def gridColor(self, c: QColor) -> None:
|
||||||
|
self._grid_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def axisColor(self) -> QColor:
|
||||||
|
return self._axis_color
|
||||||
|
|
||||||
|
@axisColor.setter
|
||||||
|
def axisColor(self, c: QColor) -> None:
|
||||||
|
self._axis_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def textColor(self) -> QColor:
|
||||||
|
return self._text_color
|
||||||
|
|
||||||
|
@textColor.setter
|
||||||
|
def textColor(self, c: QColor) -> None:
|
||||||
|
self._text_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=colorsChanged)
|
||||||
|
def seriesColors(self) -> list:
|
||||||
|
"""Return hex strings of the current series color palette."""
|
||||||
|
return [c.name() for c in self._series_colors]
|
||||||
|
|
||||||
|
@seriesColors.setter
|
||||||
|
def seriesColors(self, hex_list: list) -> None:
|
||||||
|
colors = []
|
||||||
|
for h in (hex_list or []):
|
||||||
|
try:
|
||||||
|
colors.append(QColor(str(h)))
|
||||||
|
except Exception:
|
||||||
|
colors.append(QColor("#FFFFFF"))
|
||||||
|
if colors:
|
||||||
|
self._series_colors = colors
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _auto_range(self) -> None:
|
||||||
|
"""Set Y range to cover all data with 10% padding."""
|
||||||
|
all_vals: list[float] = []
|
||||||
|
for series in self._series_data:
|
||||||
|
for v in series:
|
||||||
|
try:
|
||||||
|
all_vals.append(float(v))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if not all_vals:
|
||||||
|
return
|
||||||
|
mn = min(all_vals)
|
||||||
|
mx = max(all_vals)
|
||||||
|
if mx <= mn:
|
||||||
|
mn -= 5.0
|
||||||
|
mx += 5.0
|
||||||
|
pad = (mx - mn) * 0.1
|
||||||
|
self._min_y = mn - pad
|
||||||
|
self._max_y = mx + pad
|
||||||
|
|
||||||
|
def _plot_rect(self) -> QRectF:
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
pl = self._padding.get("left", 60)
|
||||||
|
pr = self._padding.get("right", 20)
|
||||||
|
pt = self._padding.get("top", 30)
|
||||||
|
pb = self._padding.get("bottom", 50)
|
||||||
|
return QRectF(pl, pt, max(10, w - pl - pr), max(10, h - pt - pb))
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None:
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
if w < 50 or h < 50:
|
||||||
|
return
|
||||||
|
|
||||||
|
pr = self._plot_rect()
|
||||||
|
plot_left = pr.left()
|
||||||
|
plot_top = pr.top()
|
||||||
|
plot_w = pr.width()
|
||||||
|
plot_h = pr.height()
|
||||||
|
|
||||||
|
# --- Background fill ---
|
||||||
|
painter.fillRect(0, 0, int(w), int(h), self._bg_color)
|
||||||
|
|
||||||
|
# --- Title ---
|
||||||
|
if self._title:
|
||||||
|
title_font = QFont()
|
||||||
|
title_font.setPixelSize(14)
|
||||||
|
title_font.setBold(True)
|
||||||
|
painter.setFont(title_font)
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(0, 4, w, 24),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter,
|
||||||
|
self._title,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._series_data:
|
||||||
|
self._paint_empty(painter, pr)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Determine Y tick count and values ---
|
||||||
|
n_y_ticks = max(2, min(8, int(plot_h // 40)))
|
||||||
|
y_range = self._max_y - self._min_y
|
||||||
|
if y_range <= 0:
|
||||||
|
y_range = 10.0
|
||||||
|
y_step = y_range / (n_y_ticks - 1) if n_y_ticks > 1 else y_range
|
||||||
|
|
||||||
|
# --- Grid & Y-axis labels ---
|
||||||
|
grid_pen = QPen(self._grid_color, 1)
|
||||||
|
axis_pen = QPen(self._axis_color, 1)
|
||||||
|
label_font = QFont()
|
||||||
|
label_font.setPixelSize(10)
|
||||||
|
|
||||||
|
painter.setFont(label_font)
|
||||||
|
fm = QFontMetrics(label_font)
|
||||||
|
|
||||||
|
for i in range(n_y_ticks):
|
||||||
|
y_val = self._min_y + i * y_step
|
||||||
|
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
|
||||||
|
y_px = plot_top + y_frac * plot_h
|
||||||
|
|
||||||
|
# Grid line
|
||||||
|
painter.setPen(grid_pen)
|
||||||
|
painter.drawLine(
|
||||||
|
int(plot_left), int(y_px),
|
||||||
|
int(plot_left + plot_w), int(y_px),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Label
|
||||||
|
label_str = f"{y_val:.1f}"
|
||||||
|
tw = fm.horizontalAdvance(label_str)
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawText(
|
||||||
|
int(plot_left - tw - 6), int(y_px + fm.height() // 2 - 3),
|
||||||
|
label_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Y axis label (rotated) ---
|
||||||
|
if self._y_label:
|
||||||
|
painter.save()
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
label_font_b = QFont()
|
||||||
|
label_font_b.setPixelSize(11)
|
||||||
|
painter.setFont(label_font_b)
|
||||||
|
painter.translate(14, plot_top + plot_h / 2)
|
||||||
|
painter.rotate(-90)
|
||||||
|
painter.drawText(0, 0, self._y_label)
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
# --- X axis label ---
|
||||||
|
if self._x_label:
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
label_font_b = QFont()
|
||||||
|
label_font_b.setPixelSize(11)
|
||||||
|
painter.setFont(label_font_b)
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(plot_left, h - 18, plot_w, 16),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter,
|
||||||
|
self._x_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- X-axis ticks ---
|
||||||
|
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
|
||||||
|
if num_points > 1:
|
||||||
|
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
|
||||||
|
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
||||||
|
for i in range(n_x_ticks):
|
||||||
|
idx = int(round(i * x_tick_step))
|
||||||
|
x_frac = idx / (num_points - 1)
|
||||||
|
x_px = plot_left + x_frac * plot_w
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawText(
|
||||||
|
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||||
|
str(idx + 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Axes boundary ---
|
||||||
|
border_pen = QPen(self._axis_color, 1)
|
||||||
|
painter.setPen(border_pen)
|
||||||
|
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
||||||
|
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
||||||
|
|
||||||
|
# --- Figure out max series length ---
|
||||||
|
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
|
||||||
|
if max_len < 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Draw each series ---
|
||||||
|
for si, series in enumerate(self._series_data):
|
||||||
|
if len(series) < 1:
|
||||||
|
continue
|
||||||
|
color = self._series_colors[si % len(self._series_colors)]
|
||||||
|
pen = QPen(color, 2)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
pts: list[tuple[float, float]] = []
|
||||||
|
for i, val in enumerate(series):
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
|
||||||
|
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
|
||||||
|
x_px = plot_left + x_frac * plot_w
|
||||||
|
y_px = plot_top + y_frac * plot_h
|
||||||
|
pts.append((x_px, y_px))
|
||||||
|
|
||||||
|
if len(pts) < 2:
|
||||||
|
if len(pts) == 1:
|
||||||
|
painter.drawPoint(int(pts[0][0]), int(pts[0][1]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i in range(len(pts) - 1):
|
||||||
|
painter.drawLine(
|
||||||
|
int(pts[i][0]), int(pts[i][1]),
|
||||||
|
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Legend ---
|
||||||
|
if self._show_legend and self._sensor_names:
|
||||||
|
legend_font = QFont()
|
||||||
|
legend_font.setPixelSize(10)
|
||||||
|
painter.setFont(legend_font)
|
||||||
|
lfm = QFontMetrics(legend_font)
|
||||||
|
|
||||||
|
# Compute legend dimensions
|
||||||
|
lh = 16
|
||||||
|
max_name_w = max(lfm.horizontalAdvance(n) for n in self._sensor_names)
|
||||||
|
lw = max_name_w + 24
|
||||||
|
legend_count = min(len(self._sensor_names), len(self._series_data))
|
||||||
|
lh_total = legend_count * lh + 4
|
||||||
|
|
||||||
|
legend_x = int(plot_left + plot_w - lw - 8)
|
||||||
|
legend_y = int(plot_top + 8)
|
||||||
|
|
||||||
|
# Background
|
||||||
|
painter.fillRect(legend_x, legend_y, lw, lh_total, QColor(0, 0, 0, 140))
|
||||||
|
|
||||||
|
for i in range(legend_count):
|
||||||
|
y_pos = legend_y + 4 + i * lh
|
||||||
|
color = self._series_colors[i % len(self._series_colors)]
|
||||||
|
# Color swatch
|
||||||
|
painter.fillRect(legend_x + 4, y_pos + 2, 12, 10, color)
|
||||||
|
# Label
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
painter.drawText(legend_x + 20, y_pos + 11, self._sensor_names[i])
|
||||||
|
|
||||||
|
def _paint_empty(self, painter: QPainter, pr: QRectF) -> None:
|
||||||
|
"""Draw placeholder when no data is available."""
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
empty_font = QFont()
|
||||||
|
empty_font.setPixelSize(14)
|
||||||
|
painter.setFont(empty_font)
|
||||||
|
painter.drawText(
|
||||||
|
pr,
|
||||||
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
|
"No data — read a wafer or open a CSV file",
|
||||||
|
)
|
||||||
@@ -9,16 +9,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Property, Signal, Slot
|
import pyqtgraph as pg
|
||||||
|
from pyqtgraph import PlotWidget
|
||||||
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
from PySide6.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Import pyqtgraph after Qt is initialized
|
|
||||||
import pyqtgraph as pg
|
|
||||||
from pyqtgraph import PlotWidget
|
|
||||||
|
|
||||||
|
|
||||||
class GraphView(QObject):
|
class GraphView(QObject):
|
||||||
"""QML-exposed controller for a pyqtgraph line chart.
|
"""QML-exposed controller for a pyqtgraph line chart.
|
||||||
|
|
||||||
@@ -167,3 +164,71 @@ class GraphView(QObject):
|
|||||||
self._plot_window = None
|
self._plot_window = None
|
||||||
self._plot_widget = None
|
self._plot_widget = None
|
||||||
self._series = []
|
self._series = []
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def updateTrend(self, avgs_json: str) -> None:
|
||||||
|
"""Plot the running avg temp series."""
|
||||||
|
import json
|
||||||
|
if not self._plot_widget:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
avgs = json.loads(avgs_json)
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("Failed to parse trend data: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if hasattr(self,'_trend_line') and self._trend_line is not None:
|
||||||
|
self._plot_widget.removeItem(self._trend_line)
|
||||||
|
|
||||||
|
if not avgs:
|
||||||
|
return
|
||||||
|
|
||||||
|
x = list(range(len(avgs)))
|
||||||
|
pen = pg.mkPen("#5B9DF5", width=2)
|
||||||
|
self._trend_line = self._plot_widget.plot(x, avgs, name="Average", pen=pen)
|
||||||
|
|
||||||
|
# Y-axis range with buffer
|
||||||
|
y_min, y_max = min(avgs), max(avgs)
|
||||||
|
buf = max((y_max - y_min) * 0.1, 1.0) if y_max > y_min else 5.0
|
||||||
|
self._plot_widget.setYRange(y_min - buf, y_max + buf)
|
||||||
|
|
||||||
|
@Slot(str,str,str)
|
||||||
|
def updateComparison(self, series_a_json: str, series_b_json: str,path_json: str) -> None:
|
||||||
|
"""Overlay two runs with DTW warping path connections.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_a_json: JSON list of floats -- Run Aaverage temps.
|
||||||
|
series_b_json: JSON list of floats -- Run B average temps.
|
||||||
|
path_json: JSON list of [index_a, index_b] paris.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
if not self._plot_widget:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._plot_widget.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
a = json.loads(series_a_json)
|
||||||
|
b = json.loads(series_b_json)
|
||||||
|
path = json.loads(path_json)
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("Failed to parse comparison data: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
pen_a = pg.mkPen("#5B9DF5", width=2) #blue
|
||||||
|
pen_b = pg.mkPen("#22C55E", width=2) #green
|
||||||
|
|
||||||
|
self._plot_widget.plot(list(range(len(a))), a, name="Run A", pen=pen_a)
|
||||||
|
self._plot_widget.plot(list(range(len(b))), b, name="Run B", pen=pen_b)
|
||||||
|
|
||||||
|
# Draw every 20th warping link as vertical dotted lines
|
||||||
|
step = max(1, len(path)//20)
|
||||||
|
for i in range (0,len(path),step):
|
||||||
|
index_a, index_b = path[i]
|
||||||
|
if index_a < len(a) and index_b < len(b):
|
||||||
|
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||||
|
style=Qt.DashLine))
|
||||||
|
self._plot_widget.addItem(line)
|
||||||
|
|
||||||
|
self._plot_widget.setTitle("DTW Comparison")
|
||||||
|
self._plot_widget.setLabel("left", "Temperature", units="°C")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""RBF (thin-plate spline) heatmap field.
|
||||||
|
|
||||||
|
Uses CuPy for GPU acceleration when available, falls back to NumPy + SciPy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.interpolate import RBFInterpolator
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cupy as _cupy # type: ignore
|
||||||
|
BACKEND = "cupy"
|
||||||
|
except Exception:
|
||||||
|
_cupy = None
|
||||||
|
BACKEND = "numpy"
|
||||||
|
|
||||||
|
_KERNEL = "thin_plate_spline"
|
||||||
|
_SMOOTHING = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def interpolate_field(
|
||||||
|
xs: np.ndarray,
|
||||||
|
ys: np.ndarray,
|
||||||
|
vs: np.ndarray,
|
||||||
|
*,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
|
||||||
|
round_clip: bool = False,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Return a (height, width) float64 array of interpolated values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xs, ys: sensor positions in mm (1-D arrays, length N)
|
||||||
|
vs: sensor values (length N)
|
||||||
|
width/height: output grid dimensions in pixels
|
||||||
|
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
|
||||||
|
round_clip: if True, pixels outside the inscribed ellipse become NaN
|
||||||
|
"""
|
||||||
|
coords = np.column_stack([xs, ys])
|
||||||
|
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
|
||||||
|
|
||||||
|
xmin, xmax, ymin, ymax = extent
|
||||||
|
gx = np.linspace(xmin, xmax, width)
|
||||||
|
gy = np.linspace(ymin, ymax, height)
|
||||||
|
grid_x, grid_y = np.meshgrid(gx, gy)
|
||||||
|
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
|
||||||
|
|
||||||
|
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
|
||||||
|
field = rbf(flat).reshape(height, width)
|
||||||
|
|
||||||
|
if round_clip:
|
||||||
|
cx = (xmin + xmax) / 2
|
||||||
|
cy = (ymin + ymax) / 2
|
||||||
|
rx = (xmax - xmin) / 2
|
||||||
|
ry = (ymax - ymin) / 2
|
||||||
|
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
|
||||||
|
field = np.where(dist <= 1.0, field, np.nan)
|
||||||
|
|
||||||
|
return field.astype(np.float64)
|
||||||
|
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
||||||
|
|
||||||
|
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
||||||
|
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
||||||
|
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
||||||
|
|
||||||
|
The series is driven by the existing `streamController.trendData` signal, which
|
||||||
|
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
||||||
|
streaming.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||||
|
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class TrendChartItem(QQuickPaintedItem):
|
||||||
|
"""Painted trend chart; driven by a list of floats via the `data` property."""
|
||||||
|
|
||||||
|
dataChanged = Signal()
|
||||||
|
hasDataChanged = Signal()
|
||||||
|
lineColorChanged = Signal()
|
||||||
|
gridColorChanged = Signal()
|
||||||
|
textColorChanged = Signal()
|
||||||
|
paddingChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QQuickPaintedItem] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setRenderTarget(QQuickPaintedItem.RenderTarget.FramebufferObject)
|
||||||
|
self.setAntialiasing(True)
|
||||||
|
self.setFillColor(QColor("transparent"))
|
||||||
|
|
||||||
|
self._data: list[float] = []
|
||||||
|
self._padding: int = 8
|
||||||
|
self._line_color = QColor("#5B9DF5")
|
||||||
|
self._grid_color = QColor("#2A3441")
|
||||||
|
self._text_color = QColor("#CBD5E1")
|
||||||
|
|
||||||
|
# ── Qt properties ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=dataChanged)
|
||||||
|
def data(self) -> list[float]:
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
@data.setter
|
||||||
|
def data(self, val) -> None:
|
||||||
|
coerced = self._coerce_floats(val)
|
||||||
|
if coerced == self._data:
|
||||||
|
return
|
||||||
|
self._data = coerced
|
||||||
|
self.dataChanged.emit()
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=hasDataChanged)
|
||||||
|
def hasData(self) -> bool:
|
||||||
|
"""True when the data list has at least one point.
|
||||||
|
|
||||||
|
QML can bind to this safely (QVariantList `.length` is not bindable).
|
||||||
|
"""
|
||||||
|
return len(self._data) > 0
|
||||||
|
|
||||||
|
@Property(QColor, notify=lineColorChanged)
|
||||||
|
def lineColor(self) -> QColor:
|
||||||
|
return self._line_color
|
||||||
|
|
||||||
|
@lineColor.setter
|
||||||
|
def lineColor(self, c: QColor) -> None:
|
||||||
|
if c == self._line_color:
|
||||||
|
return
|
||||||
|
self._line_color = QColor(c)
|
||||||
|
self.lineColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=gridColorChanged)
|
||||||
|
def gridColor(self) -> QColor:
|
||||||
|
return self._grid_color
|
||||||
|
|
||||||
|
@gridColor.setter
|
||||||
|
def gridColor(self, c: QColor) -> None:
|
||||||
|
if c == self._grid_color:
|
||||||
|
return
|
||||||
|
self._grid_color = QColor(c)
|
||||||
|
self.gridColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=textColorChanged)
|
||||||
|
def textColor(self) -> QColor:
|
||||||
|
return self._text_color
|
||||||
|
|
||||||
|
@textColor.setter
|
||||||
|
def textColor(self, c: QColor) -> None:
|
||||||
|
if c == self._text_color:
|
||||||
|
return
|
||||||
|
self._text_color = QColor(c)
|
||||||
|
self.textColorChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(int, notify=paddingChanged)
|
||||||
|
def padding(self) -> int:
|
||||||
|
return self._padding
|
||||||
|
|
||||||
|
@padding.setter
|
||||||
|
def padding(self, val: int) -> None:
|
||||||
|
v = max(0, int(val))
|
||||||
|
if v == self._padding:
|
||||||
|
return
|
||||||
|
self._padding = v
|
||||||
|
self.paddingChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def setDataFromJson(self, avgs_json: str) -> None:
|
||||||
|
"""Slot for QML Connections handler: parse JSON array and update data.
|
||||||
|
|
||||||
|
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(avgs_json) if avgs_json else []
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
|
||||||
|
return
|
||||||
|
coerced = self._coerce_floats(parsed)
|
||||||
|
if coerced == self._data:
|
||||||
|
return
|
||||||
|
self._data = coerced
|
||||||
|
self.dataChanged.emit()
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None: # type: ignore[override]
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
||||||
|
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
plot_rect = QRectF(self._padding, self._padding,
|
||||||
|
max(1, w - 2 * self._padding),
|
||||||
|
max(1, h - 2 * self._padding))
|
||||||
|
|
||||||
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
|
if len(self._data) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
y_min, y_max = self._y_range()
|
||||||
|
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
||||||
|
self._draw_line(painter, plot_rect, y_min, y_max)
|
||||||
|
|
||||||
|
# ── internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _coerce_floats(self, val) -> list[float]:
|
||||||
|
if not val:
|
||||||
|
return []
|
||||||
|
out: list[float] = []
|
||||||
|
for v in val:
|
||||||
|
try:
|
||||||
|
out.append(float(v))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _y_range(self) -> tuple[float, float]:
|
||||||
|
lo = min(self._data)
|
||||||
|
hi = max(self._data)
|
||||||
|
if hi - lo < 1e-9:
|
||||||
|
return lo - 5.0, hi + 5.0
|
||||||
|
buf = (hi - lo) * 0.1
|
||||||
|
return lo - buf, hi + buf
|
||||||
|
|
||||||
|
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
||||||
|
if n <= 1:
|
||||||
|
return plot_rect.left()
|
||||||
|
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
||||||
|
|
||||||
|
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
||||||
|
if y_max - y_min < 1e-9:
|
||||||
|
return plot_rect.center().y()
|
||||||
|
t = (v - y_min) / (y_max - y_min)
|
||||||
|
return plot_rect.bottom() - t * plot_rect.height()
|
||||||
|
|
||||||
|
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||||
|
pen = QPen(self._grid_color)
|
||||||
|
pen.setWidthF(1.0)
|
||||||
|
pen.setCosmetic(True)
|
||||||
|
painter.setPen(pen)
|
||||||
|
rows = 4
|
||||||
|
for i in range(rows + 1):
|
||||||
|
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
||||||
|
painter.drawLine(int(plot_rect.left()), int(y),
|
||||||
|
int(plot_rect.right()), int(y))
|
||||||
|
|
||||||
|
def _draw_axes_labels(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
) -> None:
|
||||||
|
painter.setPen(self._text_color)
|
||||||
|
font = QFont(painter.font())
|
||||||
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||||
|
painter.setFont(font)
|
||||||
|
rows = 4
|
||||||
|
for i in range(rows + 1):
|
||||||
|
t = i / rows
|
||||||
|
y_val = y_max - t * (y_max - y_min)
|
||||||
|
y_px = plot_rect.top() + t * plot_rect.height()
|
||||||
|
label = f"{y_val:.1f}"
|
||||||
|
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
|
||||||
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
|
label)
|
||||||
|
|
||||||
|
def _draw_line(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
y_min: float,
|
||||||
|
y_max: float,
|
||||||
|
) -> None:
|
||||||
|
n = len(self._data)
|
||||||
|
pen = QPen(self._line_color)
|
||||||
|
pen.setWidthF(2.0)
|
||||||
|
pen.setCosmetic(True)
|
||||||
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
poly = QPolygon()
|
||||||
|
for i, v in enumerate(self._data):
|
||||||
|
px = self._x_to_px(i, n, plot_rect)
|
||||||
|
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
||||||
|
poly.append(QPoint(int(px), int(py)))
|
||||||
|
painter.drawPolyline(poly)
|
||||||
|
|
||||||
|
# Trailing dot at the last data point
|
||||||
|
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
||||||
|
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
||||||
|
painter.setBrush(self._line_color)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
radius = 3
|
||||||
|
painter.drawEllipse(last_x - radius, last_y - radius,
|
||||||
|
radius * 2, radius * 2)
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
"""QQuickPaintedItem wafer map — ported from the replay app's ReplayWidget.
|
||||||
|
|
||||||
|
Draws:
|
||||||
|
• Radial ring template (concentric guides + crosshair axes + top notch)
|
||||||
|
• RBF heatmap layer (blended under markers via `blend` 0→1)
|
||||||
|
• Sensor marker circles colored by band (low/in_range/high)
|
||||||
|
• Numbered labels (toggle via `showLabels`)
|
||||||
|
|
||||||
|
All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QBrush,
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QImage,
|
||||||
|
QPainter,
|
||||||
|
QPen,
|
||||||
|
QPolygon,
|
||||||
|
) # fmt: skip
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class WaferMapItem(QQuickPaintedItem):
|
||||||
|
"""Painted wafer map; driven by SessionController via QML property bindings."""
|
||||||
|
|
||||||
|
sensorsChanged = Signal()
|
||||||
|
valuesChanged = Signal()
|
||||||
|
bandsChanged = Signal()
|
||||||
|
targetChanged = Signal()
|
||||||
|
marginChanged = Signal()
|
||||||
|
blendChanged = Signal()
|
||||||
|
showLabelsChanged = Signal()
|
||||||
|
colorsChanged = Signal()
|
||||||
|
shapeChanged = Signal()
|
||||||
|
sizeChanged = Signal()
|
||||||
|
thicknessChanged = Signal()
|
||||||
|
showThicknessChanged = Signal()
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._sensors: list[Sensor] = []
|
||||||
|
self._values: list[float] = []
|
||||||
|
self._bands: list[str] = []
|
||||||
|
self._target: float = 149.0
|
||||||
|
self._margin: float = 1.0
|
||||||
|
self._blend: float = 0.0
|
||||||
|
self._show_labels: bool = True
|
||||||
|
self._shape: str = "round"
|
||||||
|
self._size: float = 300.0
|
||||||
|
self._thickness_data: list[float] = []
|
||||||
|
self._show_thickness: bool = False
|
||||||
|
self._thickness_heatmap:QImage | None = None
|
||||||
|
|
||||||
|
# Dark-theme color defaults (match Theme.qml tokens)
|
||||||
|
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
||||||
|
self._axis_color = QColor("#3A4D5C") # waferAxisColor (softBorder)
|
||||||
|
self._low_color = QColor("#5B9DF5") # sensorLow
|
||||||
|
self._in_range_color = QColor("#22C55E") # sensorInRange
|
||||||
|
self._high_color = QColor("#EF4444") # sensorHigh
|
||||||
|
self._text_color = QColor("#CBD5E1") # bodyColor
|
||||||
|
|
||||||
|
# Internal draw state
|
||||||
|
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
|
||||||
|
self._marker_r: int = 4
|
||||||
|
self._heatmap: QImage | None = None
|
||||||
|
|
||||||
|
self.widthChanged.connect(self._on_resize)
|
||||||
|
self.heightChanged.connect(self._on_resize)
|
||||||
|
|
||||||
|
# ── Qt properties ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=sensorsChanged)
|
||||||
|
def sensors(self) -> list:
|
||||||
|
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
||||||
|
|
||||||
|
@sensors.setter
|
||||||
|
def sensors(self, val: list) -> None:
|
||||||
|
self._sensors = [
|
||||||
|
Sensor(
|
||||||
|
label=d["label"],
|
||||||
|
x=float(d["x"]),
|
||||||
|
y=float(d["y"]),
|
||||||
|
side=d.get("side", "right"),
|
||||||
|
offset_x=float(d.get("offset_x", 0.0)),
|
||||||
|
offset_y=float(d.get("offset_y", 0.0))
|
||||||
|
)
|
||||||
|
for d in (val or [])
|
||||||
|
]
|
||||||
|
self._rebuild()
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=valuesChanged)
|
||||||
|
def values(self) -> list:
|
||||||
|
return self._values
|
||||||
|
|
||||||
|
@values.setter
|
||||||
|
def values(self, val: list) -> None:
|
||||||
|
self._values = list(val or [])
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.valuesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=bandsChanged)
|
||||||
|
def bands(self) -> list:
|
||||||
|
return self._bands
|
||||||
|
|
||||||
|
@bands.setter
|
||||||
|
def bands(self, val: list) -> None:
|
||||||
|
self._bands = list(val or [])
|
||||||
|
self.bandsChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=targetChanged)
|
||||||
|
def target(self) -> float:
|
||||||
|
return self._target
|
||||||
|
|
||||||
|
@target.setter
|
||||||
|
def target(self, val: float) -> None:
|
||||||
|
self._target = float(val)
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.targetChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=marginChanged)
|
||||||
|
def margin(self) -> float:
|
||||||
|
return self._margin
|
||||||
|
|
||||||
|
@margin.setter
|
||||||
|
def margin(self, val: float) -> None:
|
||||||
|
self._margin = float(val)
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.marginChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=blendChanged)
|
||||||
|
def blend(self) -> float:
|
||||||
|
return self._blend
|
||||||
|
|
||||||
|
@blend.setter
|
||||||
|
def blend(self, val: float) -> None:
|
||||||
|
self._blend = max(0.0, min(1.0, float(val)))
|
||||||
|
if self._blend > 0 and self._heatmap is None:
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.blendChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=showLabelsChanged)
|
||||||
|
def showLabels(self) -> bool:
|
||||||
|
return self._show_labels
|
||||||
|
|
||||||
|
@showLabels.setter
|
||||||
|
def showLabels(self, val: bool) -> None:
|
||||||
|
self._show_labels = bool(val)
|
||||||
|
self.showLabelsChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=shapeChanged)
|
||||||
|
def shape(self) -> str:
|
||||||
|
return self._shape
|
||||||
|
|
||||||
|
@shape.setter
|
||||||
|
def shape(self, val: str) -> None:
|
||||||
|
self._shape = str(val).lower()
|
||||||
|
self._rebuild()
|
||||||
|
self.shapeChanged.emit()
|
||||||
|
|
||||||
|
@Property(float, notify=sizeChanged)
|
||||||
|
def size(self) -> float:
|
||||||
|
return self._size
|
||||||
|
|
||||||
|
@size.setter
|
||||||
|
def size(self, val: float) -> None:
|
||||||
|
self._size = float(val)
|
||||||
|
self._rebuild()
|
||||||
|
self.sizeChanged.emit()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=thicknessChanged)
|
||||||
|
def thicknessData(self) -> list:
|
||||||
|
return self._thickness_data
|
||||||
|
|
||||||
|
@thicknessData.setter
|
||||||
|
def thicknessData(self, val:list) -> None:
|
||||||
|
self._thickness_data = list(val or [])
|
||||||
|
self._rebuild_thickness()
|
||||||
|
self.thicknessChanged.emit()
|
||||||
|
self.update
|
||||||
|
|
||||||
|
@Property(bool, notify=showThicknessChanged)
|
||||||
|
def showThickness(self) -> bool:
|
||||||
|
return self._show_thickness
|
||||||
|
|
||||||
|
@showThickness.setter
|
||||||
|
def showThickness(self, val: bool) -> None:
|
||||||
|
self._show_thickness = bool(val)
|
||||||
|
self.showThicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# Colour properties — QML can bind these to Theme tokens
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def ringColor(self) -> QColor: return self._ring_color
|
||||||
|
@ringColor.setter
|
||||||
|
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def axisColor(self) -> QColor: return self._axis_color
|
||||||
|
@axisColor.setter
|
||||||
|
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def lowColor(self) -> QColor: return self._low_color
|
||||||
|
@lowColor.setter
|
||||||
|
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def inRangeColor(self) -> QColor: return self._in_range_color
|
||||||
|
@inRangeColor.setter
|
||||||
|
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def highColor(self) -> QColor: return self._high_color
|
||||||
|
@highColor.setter
|
||||||
|
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def textColor(self) -> QColor: return self._text_color
|
||||||
|
@textColor.setter
|
||||||
|
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
|
||||||
|
|
||||||
|
# ── slots ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Slot(float, float, result=int)
|
||||||
|
def which_marker(self, x: float, y: float) -> int:
|
||||||
|
"""Return the sensor index nearest to (x, y) within marker radius, else -1."""
|
||||||
|
r = max(self._marker_r, 6)
|
||||||
|
for idx, (mx, my) in self._markers.items():
|
||||||
|
if abs(mx - x) <= r and abs(my - y) <= r:
|
||||||
|
return idx
|
||||||
|
return -1
|
||||||
|
|
||||||
|
@Slot(str, result=bool)
|
||||||
|
def export_image(self, file_path: str) -> bool:
|
||||||
|
"""Export the current wafer map rendering to a PNG file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_pat: Absolute path for the output PNG.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success, False on failure
|
||||||
|
"""
|
||||||
|
if not file_path:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = self.grabToImage()
|
||||||
|
img = result.image()
|
||||||
|
img.save(file_path, "PNG")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Export failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rebuild_thickness(self) -> None:
|
||||||
|
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
||||||
|
if not self._sensors or not self._thickness_data:
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
xs = np.array([s.x for s in self._sensors])
|
||||||
|
ys = np.array([s.y for s in self._sensors])
|
||||||
|
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
||||||
|
if len(vs) < len(self._sensors):
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
field = interpolate_field(
|
||||||
|
xs, ys, vs,
|
||||||
|
width=ds, height=ds,
|
||||||
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
|
round_clip=(self._shape == "round"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._thickness_heatmap = None
|
||||||
|
return
|
||||||
|
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||||||
|
vmin, vmax = np.nanmin(field), np.nanmax(field)
|
||||||
|
span = vmax - vmin or 1.0
|
||||||
|
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
||||||
|
|
||||||
|
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
|
||||||
|
rgb = np.zeros((ds, ds, 3), dtype=np.float32)
|
||||||
|
rgb[:, :, 0] = 0.5 + 0.5 * t
|
||||||
|
rgb[:, :, 1] = 0.5 + 0.15 * t
|
||||||
|
rgb[:, :, 2] = 0.5 - 0.5 * t
|
||||||
|
|
||||||
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
|
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
||||||
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
|
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8)
|
||||||
|
|
||||||
|
self._thickness_heatmap = (
|
||||||
|
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_resize(self) -> None:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
def _rebuild(self) -> None:
|
||||||
|
self._compute_markers()
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _draw_size(self) -> int:
|
||||||
|
return max(1, int(min(self.width(), self.height())))
|
||||||
|
|
||||||
|
def _center(self) -> tuple[int, int]:
|
||||||
|
return int(self.width() / 2), int(self.height() / 2)
|
||||||
|
|
||||||
|
def _wafer_radius_mm(self) -> float:
|
||||||
|
"""Radius of the wafer bounding circle in mm (5% padding beyond outermost sensor)."""
|
||||||
|
if not self._sensors:
|
||||||
|
return 150.0
|
||||||
|
r = max(math.hypot(s.x, s.y) for s in self._sensors)
|
||||||
|
return r * 1.05
|
||||||
|
|
||||||
|
def _sensor_ring_radii_mm(self) -> list[float]:
|
||||||
|
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||||
|
if not self._sensors:
|
||||||
|
r = self._wafer_radius_mm()
|
||||||
|
return [r * f for f in (0.25, 0.50, 0.75, 1.0)]
|
||||||
|
# Cluster radii that are within 2 mm of each other into one ring; skip center point.
|
||||||
|
radii = sorted(r for r in {math.hypot(s.x, s.y) for s in self._sensors} if r > 1.0)
|
||||||
|
groups: list[float] = []
|
||||||
|
for r in radii:
|
||||||
|
if not groups or r - groups[-1] > 2.0:
|
||||||
|
groups.append(r)
|
||||||
|
else:
|
||||||
|
groups[-1] = (groups[-1] + r) / 2 # merge close values
|
||||||
|
# Always include the outer boundary ring so the wafer circle is drawn.
|
||||||
|
outer = self._wafer_radius_mm()
|
||||||
|
if not groups or outer - groups[-1] > 2.0:
|
||||||
|
groups.append(outer)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
def _scale(self, ds: int, r_mm: float) -> float:
|
||||||
|
"""Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
|
||||||
|
return (ds / 2 - 24) / r_mm
|
||||||
|
|
||||||
|
def _to_px(self, x_mm: float, y_mm: float, cx: int, cy: int, scale: float) -> tuple[int, int]:
|
||||||
|
"""Center-origin mm → pixel (top-left origin). Y is flipped."""
|
||||||
|
return cx + int(x_mm * scale), cy - int(y_mm * scale)
|
||||||
|
|
||||||
|
def _compute_markers(self) -> None:
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
sc = self._scale(ds, r_mm)
|
||||||
|
cx, cy = self._center()
|
||||||
|
self._marker_r = max(3, ds // 70)
|
||||||
|
self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
|
||||||
|
for i, s in enumerate(self._sensors)}
|
||||||
|
|
||||||
|
def _rebuild_heatmap(self) -> None:
|
||||||
|
if not self._sensors or not self._values or self._blend == 0.0:
|
||||||
|
self._heatmap = None
|
||||||
|
return
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
xs = np.array([s.x for s in self._sensors])
|
||||||
|
ys = np.array([s.y for s in self._sensors])
|
||||||
|
vs = np.array(self._values[:len(self._sensors)], dtype=float)
|
||||||
|
if len(vs) < len(self._sensors):
|
||||||
|
self._heatmap = None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
field = interpolate_field(
|
||||||
|
xs, ys, vs,
|
||||||
|
width=ds, height=ds,
|
||||||
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
|
round_clip=(self._shape == "round"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._heatmap = None
|
||||||
|
return
|
||||||
|
self._heatmap = self._field_to_qimage(field, ds)
|
||||||
|
|
||||||
|
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage:
|
||||||
|
"""Apply a band-aware tri-color gradient → RGBA QImage."""
|
||||||
|
lo_b = self._target - self._margin
|
||||||
|
hi_b = self._target + self._margin
|
||||||
|
span = hi_b - lo_b or 1.0
|
||||||
|
# t: 0 = lo_b, 1 = hi_b (clipped)
|
||||||
|
t = np.clip((field - lo_b) / span, 0.0, 1.0)
|
||||||
|
|
||||||
|
def c(q: QColor) -> np.ndarray:
|
||||||
|
return np.array([q.redF(), q.greenF(), q.blueF()], dtype=np.float32)
|
||||||
|
|
||||||
|
lo_c = c(self._low_color)
|
||||||
|
mid_c = c(self._in_range_color)
|
||||||
|
hi_c = c(self._high_color)
|
||||||
|
|
||||||
|
t2 = t * 2 # 0→2 across full range
|
||||||
|
|
||||||
|
lower = t <= 0.5
|
||||||
|
t_lo = np.clip(t2, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in lower half
|
||||||
|
t_hi = np.clip(t2 - 1.0, 0.0, 1.0)[:, :, np.newaxis] # 0→1 in upper half
|
||||||
|
|
||||||
|
rgb = np.where(
|
||||||
|
lower[:, :, np.newaxis],
|
||||||
|
lo_c * (1 - t_lo) + mid_c * t_lo,
|
||||||
|
mid_c * (1 - t_hi) + hi_c * t_hi,
|
||||||
|
)
|
||||||
|
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
|
||||||
|
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
|
||||||
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
|
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
||||||
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
|
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8)
|
||||||
|
|
||||||
|
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None:
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_px = int(ds / 2 - 4)
|
||||||
|
cx, cy = self._center()
|
||||||
|
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
|
self._paint_template(painter, cx, cy, r_px)
|
||||||
|
|
||||||
|
if self._heatmap and self._blend > 0.0:
|
||||||
|
painter.setOpacity(self._blend)
|
||||||
|
painter.drawImage(cx - self._heatmap.width() // 2,
|
||||||
|
cy - self._heatmap.height() // 2, self._heatmap)
|
||||||
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
|
if self._show_thickness and self._thickness_heatmap:
|
||||||
|
painter.setOpacity(0.4)
|
||||||
|
painter.drawImage(cx - self._thickness_heatmap.width() // 2,
|
||||||
|
cy - self._thickness_heatmap.height()//2, self._thickness_heatmap)
|
||||||
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
|
self._paint_markers(painter)
|
||||||
|
|
||||||
|
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||||
|
ds = self._draw_size()
|
||||||
|
r_mm = self._wafer_radius_mm()
|
||||||
|
sc = self._scale(ds, r_mm)
|
||||||
|
|
||||||
|
if self._shape == "square":
|
||||||
|
# Draw square boundary (thick pen)
|
||||||
|
border_pen = QPen(self._ring_color, 2, Qt.PenStyle.SolidLine)
|
||||||
|
painter.setPen(border_pen)
|
||||||
|
half_size_px = int(self._size / 2 * sc)
|
||||||
|
painter.drawRect(cx - half_size_px, cy - half_size_px, 2 * half_size_px, 2 * half_size_px)
|
||||||
|
|
||||||
|
# Crosshair axes
|
||||||
|
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawLine(cx, cy - half_size_px, cx, cy + half_size_px)
|
||||||
|
painter.drawLine(cx - half_size_px, cy, cx + half_size_px, cy)
|
||||||
|
|
||||||
|
# Draw concentric square guide lines at the distinct radii of sensor rings
|
||||||
|
grid_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
|
||||||
|
painter.setPen(grid_pen)
|
||||||
|
for ring_r_mm in self._sensor_ring_radii_mm()[:-1]: # exclude the outermost border
|
||||||
|
rr = max(1, int(ring_r_mm * sc))
|
||||||
|
painter.drawRect(cx - rr, cy - rr, 2 * rr, 2 * rr)
|
||||||
|
else:
|
||||||
|
# Concentric rings
|
||||||
|
ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
|
||||||
|
painter.setPen(ring_pen)
|
||||||
|
for ring_r_mm in self._sensor_ring_radii_mm():
|
||||||
|
rr = max(1, int(ring_r_mm * sc))
|
||||||
|
painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr)
|
||||||
|
|
||||||
|
# Crosshair axes
|
||||||
|
axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawLine(cx, cy - r_px, cx, cy + r_px)
|
||||||
|
painter.drawLine(cx - r_px, cy, cx + r_px, cy)
|
||||||
|
|
||||||
|
# Top notch triangle (wafer orientation marker)
|
||||||
|
nw = max(6, ds // 25)
|
||||||
|
nh = max(4, ds // 35)
|
||||||
|
notch = QPolygon([
|
||||||
|
QPoint(cx, cy - r_px),
|
||||||
|
QPoint(cx - nw // 2, cy - r_px + nh),
|
||||||
|
QPoint(cx + nw // 2, cy - r_px + nh),
|
||||||
|
])
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QBrush(self._axis_color))
|
||||||
|
painter.drawPolygon(notch)
|
||||||
|
|
||||||
|
def _paint_markers(self, painter: QPainter) -> None:
|
||||||
|
r = self._marker_r
|
||||||
|
|
||||||
|
# Scale font size based on the number of sensors to prevent overlap on dense wafers
|
||||||
|
num_sensors = len(self._sensors)
|
||||||
|
font_scale = 1.0
|
||||||
|
if num_sensors > 60:
|
||||||
|
font_scale = 0.7 # reduce font size by 30% for dense wafers
|
||||||
|
elif num_sensors > 40:
|
||||||
|
font_scale = 0.85
|
||||||
|
|
||||||
|
id_font = QFont()
|
||||||
|
id_font.setPointSize(max(4, int(r * font_scale)))
|
||||||
|
id_font.setBold(True)
|
||||||
|
|
||||||
|
temp_font = QFont()
|
||||||
|
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
|
||||||
|
|
||||||
|
band_color = {
|
||||||
|
"in_range": self._in_range_color,
|
||||||
|
"high": self._high_color,
|
||||||
|
"low": self._low_color,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, s in enumerate(self._sensors):
|
||||||
|
if i not in self._markers:
|
||||||
|
continue
|
||||||
|
px, py = self._markers[i]
|
||||||
|
color = band_color.get(
|
||||||
|
self._bands[i] if i < len(self._bands) else "in_range",
|
||||||
|
self._in_range_color,
|
||||||
|
)
|
||||||
|
# Filled circle with thin dark outline for contrast over heatmap
|
||||||
|
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
|
||||||
|
|
||||||
|
if self._show_labels:
|
||||||
|
has_temp = i < len(self._values)
|
||||||
|
|
||||||
|
# Fetch text alignment side and offsets
|
||||||
|
side = getattr(s, "side", "right").lower()
|
||||||
|
ox = getattr(s, "offset_x", 0.0) * r
|
||||||
|
oy = getattr(s, "offset_y", 0.0) * r
|
||||||
|
|
||||||
|
# Pre-compute metrics using current scaled fonts
|
||||||
|
painter.setFont(id_font)
|
||||||
|
id_fm = painter.fontMetrics()
|
||||||
|
id_line_h = id_fm.height()
|
||||||
|
id_ascent = id_fm.ascent()
|
||||||
|
|
||||||
|
painter.setFont(temp_font)
|
||||||
|
temp_fm = painter.fontMetrics()
|
||||||
|
temp_line_h = temp_fm.height()
|
||||||
|
temp_ascent = temp_fm.ascent()
|
||||||
|
|
||||||
|
id_text = s.label
|
||||||
|
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
||||||
|
|
||||||
|
id_w = id_fm.horizontalAdvance(id_text)
|
||||||
|
temp_w = temp_fm.horizontalAdvance(temp_text) if has_temp else 0
|
||||||
|
text_w = max(id_w, temp_w)
|
||||||
|
|
||||||
|
# Height of the 1 or 2-line text block
|
||||||
|
text_h = (id_line_h + temp_line_h) if has_temp else id_line_h
|
||||||
|
|
||||||
|
# Calculate box top-left (lx, ly) relative to dot center (px, py)
|
||||||
|
gap = 3
|
||||||
|
if side == "left":
|
||||||
|
lx = px - r - gap - text_w - ox
|
||||||
|
ly = py - text_h // 2 + oy
|
||||||
|
elif side == "top":
|
||||||
|
lx = px - text_w // 2 + ox
|
||||||
|
ly = py - r - gap - text_h - oy
|
||||||
|
elif side == "bottom":
|
||||||
|
lx = px - text_w // 2 + ox
|
||||||
|
ly = py + r + gap + oy
|
||||||
|
else: # "right" or default
|
||||||
|
lx = px + r + gap + ox
|
||||||
|
ly = py - text_h // 2 + oy
|
||||||
|
|
||||||
|
# Draw Sensor ID (first line)
|
||||||
|
painter.setFont(id_font)
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
y1 = ly + id_ascent
|
||||||
|
if side in ("top", "bottom"):
|
||||||
|
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)
|
||||||
|
elif side == "left":
|
||||||
|
painter.drawText(lx + (text_w - id_w), y1, id_text)
|
||||||
|
else:
|
||||||
|
painter.drawText(lx, y1, id_text)
|
||||||
|
|
||||||
|
# Draw Temperature (second line)
|
||||||
|
if has_temp:
|
||||||
|
painter.setFont(temp_font)
|
||||||
|
painter.setPen(QPen(color))
|
||||||
|
y2 = ly + id_line_h + temp_ascent
|
||||||
|
if side in ("top", "bottom"):
|
||||||
|
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)
|
||||||
|
elif side == "left":
|
||||||
|
painter.drawText(lx + (text_w - temp_w), y2, temp_text)
|
||||||
|
else:
|
||||||
|
painter.drawText(lx, y2, temp_text)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# ===== Wafer Sub-package =====
|
||||||
|
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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Load wafer sensor layouts from bundled YAML files.
|
||||||
|
|
||||||
|
YAML schema mirrors the replay app's wafer_desc.py format:
|
||||||
|
X/Y — sensor positions in mm relative to x_origin/y_origin
|
||||||
|
size — wafer diameter/edge in mm
|
||||||
|
shape — "round" or "square"
|
||||||
|
start_sn — first sensor number (usually 1)
|
||||||
|
x_origin — "left" | "right" | "center"
|
||||||
|
y_origin — "bottom" | "top" | "center"
|
||||||
|
|
||||||
|
Returned Sensor coords are center-origin mm (negative values toward the edge).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
_LAYOUTS_DIR = Path(__file__).parent.parent.parent / "assets" / "layouts"
|
||||||
|
|
||||||
|
|
||||||
|
class WaferLayout(list):
|
||||||
|
"""Subclass of list that carries wafer shape and size metadata."""
|
||||||
|
def __init__(self, sensors: list[Sensor], shape: str = "round", size: float = 300.0):
|
||||||
|
super().__init__(sensors)
|
||||||
|
self.shape = shape
|
||||||
|
self.size = size
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_shape_and_size(sensors: list[Sensor], wafer_id: str = "") -> tuple[str, float]:
|
||||||
|
"""Helper to determine the shape and size of a sensor layout.
|
||||||
|
|
||||||
|
Tries matching by wafer_id prefix, falling back to the number of sensors.
|
||||||
|
"""
|
||||||
|
if wafer_id:
|
||||||
|
prefix = wafer_id[0].upper()
|
||||||
|
for path in _LAYOUTS_DIR.glob("*.yaml"):
|
||||||
|
try:
|
||||||
|
data = _load_yaml(path)
|
||||||
|
if prefix in data.get("wafers", []):
|
||||||
|
return data.get("shape", "round"), float(data.get("size", 300.0))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fallback to sensor count mapping
|
||||||
|
n = len(sensors)
|
||||||
|
if n == 80:
|
||||||
|
return "square", 310.0
|
||||||
|
elif n == 65:
|
||||||
|
return "round", 300.0
|
||||||
|
elif n == 48:
|
||||||
|
return "round", 300.0
|
||||||
|
elif n == 29:
|
||||||
|
return "round", 200.0
|
||||||
|
elif n == 22:
|
||||||
|
return "round", 200.0
|
||||||
|
|
||||||
|
return "round", 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def _family_name(raw_name: str) -> str:
|
||||||
|
return raw_name.replace("wafer", "").strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_yaml(path: Path) -> dict:
|
||||||
|
with path.open(encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def available_families() -> list[str]:
|
||||||
|
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout(family: str) -> WaferLayout:
|
||||||
|
for path in _LAYOUTS_DIR.glob("*.yaml"):
|
||||||
|
data = _load_yaml(path)
|
||||||
|
if _family_name(data["name"]) == family:
|
||||||
|
return _to_sensors(data)
|
||||||
|
raise KeyError(f"Unknown wafer family: {family!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_layout_for_wafer_id(wafer_id: str) -> WaferLayout:
|
||||||
|
"""Match 'B00108' → bcdwafer by looking up the first char in each YAML's 'wafers' list."""
|
||||||
|
prefix = wafer_id[0].upper() if wafer_id else ""
|
||||||
|
for path in _LAYOUTS_DIR.glob("*.yaml"):
|
||||||
|
data = _load_yaml(path)
|
||||||
|
if prefix in data.get("wafers", []):
|
||||||
|
return _to_sensors(data)
|
||||||
|
raise KeyError(f"No layout found for wafer ID prefix {prefix!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_sensors(data: dict) -> WaferLayout:
|
||||||
|
xs: list[float] = data["X"]
|
||||||
|
ys: list[float] = data["Y"]
|
||||||
|
size: float = float(data["size"])
|
||||||
|
shape: str = data.get("shape", "round")
|
||||||
|
start_sn: int = data.get("start_sn", 1)
|
||||||
|
reverse_x: bool = data.get("reverse_x", False)
|
||||||
|
reverse_y: bool = data.get("reverse_y", False)
|
||||||
|
x_orig: str = data.get("x_origin", "left")
|
||||||
|
y_orig: str = data.get("y_origin", "bottom")
|
||||||
|
label_exceptions = data.get("label_exceptions", {}) or {}
|
||||||
|
|
||||||
|
x_shift = {"left": size / 2, "right": -(size / 2), "center": 0.0}.get(x_orig, 0.0)
|
||||||
|
y_shift = {"bottom": size / 2, "top": -(size / 2), "center": 0.0}.get(y_orig, 0.0)
|
||||||
|
|
||||||
|
sensors: list[Sensor] = []
|
||||||
|
for i, (x_mm, y_mm) in enumerate(zip(xs, ys)):
|
||||||
|
if reverse_x:
|
||||||
|
x_mm = -x_mm
|
||||||
|
if reverse_y:
|
||||||
|
y_mm = -y_mm
|
||||||
|
|
||||||
|
x_coord = x_mm - x_shift
|
||||||
|
y_coord = y_mm - y_shift
|
||||||
|
|
||||||
|
# Determine label position defaults based on quadrants
|
||||||
|
sn = start_sn + i
|
||||||
|
side = "right"
|
||||||
|
offset_x = 0.0
|
||||||
|
offset_y = 0.0
|
||||||
|
|
||||||
|
# Check explicit label exception from layout config
|
||||||
|
if sn in label_exceptions:
|
||||||
|
exc = label_exceptions[sn]
|
||||||
|
if exc:
|
||||||
|
side_key = list(exc.keys())[0]
|
||||||
|
val = exc[side_key]
|
||||||
|
side = side_key
|
||||||
|
if isinstance(val, list) and len(val) >= 2:
|
||||||
|
offset_x = float(val[0])
|
||||||
|
offset_y = float(val[1])
|
||||||
|
else:
|
||||||
|
# Smart default quadrant positioning to keep labels pointing inward
|
||||||
|
if x_coord > 30.0 and y_coord > 30.0:
|
||||||
|
side = "left"
|
||||||
|
offset_y = -0.5
|
||||||
|
elif x_coord < -30.0 and y_coord > 30.0:
|
||||||
|
side = "right"
|
||||||
|
offset_y = -0.5
|
||||||
|
elif x_coord < -30.0 and y_coord < -30.0:
|
||||||
|
side = "right"
|
||||||
|
offset_y = 0.5
|
||||||
|
elif x_coord > 30.0 and y_coord < -30.0:
|
||||||
|
side = "left"
|
||||||
|
offset_y = 0.5
|
||||||
|
elif x_coord > 15.0:
|
||||||
|
side = "left"
|
||||||
|
elif x_coord < -15.0:
|
||||||
|
side = "right"
|
||||||
|
|
||||||
|
sensors.append(Sensor(
|
||||||
|
label=str(sn),
|
||||||
|
x=x_coord,
|
||||||
|
y=y_coord,
|
||||||
|
side=side,
|
||||||
|
offset_x=offset_x,
|
||||||
|
offset_y=offset_y,
|
||||||
|
))
|
||||||
|
return WaferLayout(sensors, shape=shape, size=size)
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ class Sensor:
|
|||||||
label: str
|
label: str
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
|
side: str = "right"
|
||||||
|
offset_x: float = 0.0
|
||||||
|
offset_y: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
# ===== Data Row =====
|
# ===== Data Row =====
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple, Optional
|
from typing import Optional, Tuple
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from backend.zwafer_models import ZWaferData, Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor, ZWaferData
|
||||||
|
|
||||||
|
|
||||||
# ===== Z-Wafer CSV Parser =====
|
# ===== Z-Wafer CSV Parser =====
|
||||||
@@ -32,7 +32,13 @@ class ZWaferParser:
|
|||||||
|
|
||||||
# ===== Header Parsing =====
|
# ===== Header Parsing =====
|
||||||
def _process_header(self, file_obj) -> ZWaferData:
|
def _process_header(self, file_obj) -> ZWaferData:
|
||||||
"""Parse header and sensor layout from open file object."""
|
"""Parse header and sensor layout from open file object.
|
||||||
|
Handles two CSV row orderings:
|
||||||
|
1. Label/X/Y rows BEFORE 'data' (fixture test format)
|
||||||
|
2. 'data' row BEFORE Label/X/Y (some real CSV files)
|
||||||
|
The C# parser handles both by collecting sensor layout rows
|
||||||
|
regardless of whether 'data' has been seen.
|
||||||
|
"""
|
||||||
wafer_data = ZWaferData()
|
wafer_data = ZWaferData()
|
||||||
labels: Optional[list] = None
|
labels: Optional[list] = None
|
||||||
x_coords: Optional[list] = None
|
x_coords: Optional[list] = None
|
||||||
@@ -47,26 +53,25 @@ class ZWaferParser:
|
|||||||
parts = [p.strip() for p in line.split(",")]
|
parts = [p.strip() for p in line.split(",")]
|
||||||
first_part = parts[0].lower()
|
first_part = parts[0].lower()
|
||||||
|
|
||||||
|
# Collect sensor layout rows (Label, X (mm), Y (mm))
|
||||||
|
# regardless of whether 'data' has been seen.
|
||||||
|
# This matches the C# parser behavior.
|
||||||
|
if first_part == "label":
|
||||||
|
labels = parts[1:]
|
||||||
|
elif first_part == "x (mm)":
|
||||||
|
x_coords = parts[1:]
|
||||||
|
elif first_part == "y (mm)":
|
||||||
|
y_coords = parts[1:]
|
||||||
|
# Parse metadata (key=value pairs) — only when not a sensor layout row
|
||||||
|
elif first_part != "data":
|
||||||
|
self._parse_header_line(wafer_data, parts)
|
||||||
|
|
||||||
# Detect end of header section
|
# Detect end of header section
|
||||||
if first_part == "data":
|
if first_part == "data":
|
||||||
wafer_data.csv_headers = labels or []
|
wafer_data.csv_headers = labels or []
|
||||||
self._build_sensor_layout(wafer_data, labels, x_coords, y_coords)
|
self._build_sensor_layout(wafer_data, labels, x_coords, y_coords)
|
||||||
return wafer_data
|
return wafer_data
|
||||||
|
|
||||||
# Detect switch from metadata to sensor layout
|
|
||||||
# Parse metadata (key=value pairs)
|
|
||||||
if not self._parse_header_line(wafer_data, parts):
|
|
||||||
# If not metadata, it's part of sensor layout
|
|
||||||
label = parts[0].lower()
|
|
||||||
values = parts[1:]
|
|
||||||
|
|
||||||
if label == "label":
|
|
||||||
labels = values
|
|
||||||
elif label == "x (mm)":
|
|
||||||
x_coords = values
|
|
||||||
elif label == "y (mm)":
|
|
||||||
y_coords = values
|
|
||||||
|
|
||||||
return wafer_data # Incomplete file — return partial data
|
return wafer_data # Incomplete file — return partial data
|
||||||
|
|
||||||
# ===== Metadata Parsing =====
|
# ===== Metadata Parsing =====
|
||||||
@@ -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"]
|
||||||
@@ -6,13 +6,12 @@ QML via @Slot methods on a QObject controller.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import serial.tools.list_ports
|
import serial.tools.list_ports
|
||||||
|
|
||||||
from backend.local_settings import LocalSettings
|
from pygui.backend.data.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__)
|
||||||
|
|
||||||
@@ -37,7 +36,58 @@ class DeviceService:
|
|||||||
|
|
||||||
def enumerate_ports(self) -> list[str]:
|
def enumerate_ports(self) -> list[str]:
|
||||||
"""Return list of available serial port names."""
|
"""Return list of available serial port names."""
|
||||||
return [p.device for p in serial.tools.list_ports.comports()]
|
hardware_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||||
|
virtual_ports = []
|
||||||
|
|
||||||
|
# On macOS/Linux, also detect virtual loopback serial ports (PTYs) opened by socat
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
if platform.system() in ("Darwin", "Linux"):
|
||||||
|
try:
|
||||||
|
# Find PTYs opened by socat
|
||||||
|
result = subprocess.run(
|
||||||
|
["lsof", "-c", "socat"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) >= 9:
|
||||||
|
fd = parts[3]
|
||||||
|
# Exclude standard FDs (0, 1, 2) which are typically the controlling terminal
|
||||||
|
if fd.startswith(('0', '1', '2')):
|
||||||
|
continue
|
||||||
|
name = parts[-1]
|
||||||
|
match = re.search(r'(/dev/(?:ttys\d+|pts/\d+))\b', name)
|
||||||
|
if match:
|
||||||
|
port = match.group(1)
|
||||||
|
if port not in virtual_ports:
|
||||||
|
virtual_ports.append(port)
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("Error listing virtual ports via lsof: %s", e)
|
||||||
|
|
||||||
|
# Prioritize physical USB serial interfaces first -> generic hardware -> virtual ports
|
||||||
|
# Virtual ports (PTYs detected via lsof) should NOT appear in hardware_ports,
|
||||||
|
# so we filter them out explicitly.
|
||||||
|
virtual_set = set(virtual_ports)
|
||||||
|
ordered_ports = []
|
||||||
|
for p in hardware_ports:
|
||||||
|
if "usbserial" in p.lower() or "usb" in p.lower() or "ttyusb" in p.lower():
|
||||||
|
if p not in ordered_ports:
|
||||||
|
ordered_ports.append(p)
|
||||||
|
for p in hardware_ports:
|
||||||
|
if p not in ordered_ports and p not in virtual_set:
|
||||||
|
ordered_ports.append(p)
|
||||||
|
for p in virtual_ports:
|
||||||
|
if p not in ordered_ports:
|
||||||
|
ordered_ports.append(p)
|
||||||
|
|
||||||
|
return ordered_ports
|
||||||
|
|
||||||
|
|
||||||
def detect_wafer(self, port: str) -> Optional[WaferInfo]:
|
def detect_wafer(self, port: str) -> Optional[WaferInfo]:
|
||||||
"""Try to detect a wafer on the given port.
|
"""Try to detect a wafer on the given port.
|
||||||
@@ -46,15 +46,16 @@ class SerialPort:
|
|||||||
self._port_name = port_name
|
self._port_name = port_name
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Context manager for scoped port access
|
# Port opening (shared between scoped and long-lived use)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@contextmanager
|
@staticmethod
|
||||||
def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
|
def open_port(port_name: str, timeout: float | None = None) -> pyserial.Serial:
|
||||||
"""Open the serial port, yield it, then close on exit."""
|
"""Open a serial port with baud-rate fallback.
|
||||||
port = pyserial.Serial(
|
|
||||||
self._port_name,
|
Tries SerialPort.BAUDRATE first, falls back to 115200 on failure.
|
||||||
self.BAUDRATE,
|
The caller owns the lifecycle of the returned Serial object."""
|
||||||
|
kwargs = dict(
|
||||||
parity=pyserial.PARITY_NONE,
|
parity=pyserial.PARITY_NONE,
|
||||||
bytesize=8,
|
bytesize=8,
|
||||||
stopbits=pyserial.STOPBITS_ONE,
|
stopbits=pyserial.STOPBITS_ONE,
|
||||||
@@ -63,7 +64,22 @@ class SerialPort:
|
|||||||
dsrdtr=False,
|
dsrdtr=False,
|
||||||
)
|
)
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
port.timeout = timeout
|
kwargs["timeout"] = timeout
|
||||||
|
|
||||||
|
try:
|
||||||
|
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Baud rate %d failed on %s, falling back to 115200: %s", SerialPort.BAUDRATE, port_name, exc)
|
||||||
|
return pyserial.Serial(port_name, 115200, **kwargs)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Context manager for scoped port access
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
|
||||||
|
"""Open the serial port, yield it, then close on exit."""
|
||||||
|
port = self.open_port(self._port_name, timeout=timeout)
|
||||||
try:
|
try:
|
||||||
yield port
|
yield port
|
||||||
finally:
|
finally:
|
||||||
@@ -180,7 +196,6 @@ class SerialPort:
|
|||||||
bytes 1016-1019 : Runtime (2 bytes hex → decimal seconds)
|
bytes 1016-1019 : Runtime (2 bytes hex → decimal seconds)
|
||||||
bytes 1020-1023 : Cycle time (2 bytes hex → decimal cycles)
|
bytes 1020-1023 : Cycle time (2 bytes hex → decimal cycles)
|
||||||
"""
|
"""
|
||||||
raw = bytes.fromhex(hex_response)
|
|
||||||
|
|
||||||
family_code = self._hex_to_ascii(hex_response[0:2])
|
family_code = self._hex_to_ascii(hex_response[0:2])
|
||||||
serial_num = f"{family_code}{int(hex_response[2:6], 16):05d}"
|
serial_num = f"{family_code}{int(hex_response[2:6], 16):05d}"
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Continuous serial frame reader (Live-mode source)"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.serialcomm.data_parser import _convert_hex_to_temp
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ParseFrame = Callable[[bytes, int], Frame]
|
||||||
|
|
||||||
|
class StreamReader:
|
||||||
|
|
||||||
|
def __init__(self, transport, parse_frame: ParseFrame,
|
||||||
|
on_frame: Callable[[Frame], None],
|
||||||
|
on_error: Callable[[Exception], None] | None = None,
|
||||||
|
family_code: str = "A") -> None:
|
||||||
|
self._transport = transport
|
||||||
|
self._parse = parse_frame
|
||||||
|
self._on_frame = on_frame
|
||||||
|
self._on_error = on_error or (lambda e: None)
|
||||||
|
self._family_code = family_code or "A"
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self.error_count = 0
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon = True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _read_chunk(self, min_bytes: int = 1) -> bytes:
|
||||||
|
"""Read a chunk of available bytes from the transport.
|
||||||
|
|
||||||
|
Reads up to 256 bytes in one shot instead of byte-at-a-time,
|
||||||
|
falling back to single-byte reads with a yield when nothing is
|
||||||
|
immediately available."""
|
||||||
|
chunk = self._transport.read(max(min_bytes, 256))
|
||||||
|
if chunk:
|
||||||
|
return chunk
|
||||||
|
# Nothing available right now – yield briefly to avoid busy-spin.
|
||||||
|
b = self._transport.read(1)
|
||||||
|
if not b:
|
||||||
|
time.sleep(0.005)
|
||||||
|
return b
|
||||||
|
|
||||||
|
def _accumulate(self, needed: int) -> bytearray:
|
||||||
|
"""Accumulate *needed* bytes using buffered reads."""
|
||||||
|
buf = bytearray()
|
||||||
|
while len(buf) < needed and not self._stop.is_set():
|
||||||
|
chunk = self._transport.read(needed - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
buf.extend(chunk)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
try:
|
||||||
|
# Peek at the first few bytes to determine protocol.
|
||||||
|
peek_buf = bytearray()
|
||||||
|
while not self._stop.is_set() and len(peek_buf) < 32:
|
||||||
|
b = self._transport.read(1)
|
||||||
|
if not b:
|
||||||
|
continue
|
||||||
|
peek_buf.extend(b)
|
||||||
|
if b[0] == 10: # newline
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._stop.is_set() or not peek_buf:
|
||||||
|
return
|
||||||
|
|
||||||
|
raw = bytes(peek_buf)
|
||||||
|
|
||||||
|
# Binary sync bytes are the strongest signal.
|
||||||
|
if raw.startswith(b"\xaa\x88"):
|
||||||
|
self._run_binary(raw)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Text-lines (CSV) requires BOTH a newline AND commas in the
|
||||||
|
# pre-newline content. This avoids misclassifying a binary
|
||||||
|
# payload that happens to contain 0x2C as text.
|
||||||
|
has_newline = b"\n" in raw or b"\r" in raw
|
||||||
|
line_before_nl = raw.split(b"\n")[0].split(b"\r")[0]
|
||||||
|
if has_newline and b"," in line_before_nl:
|
||||||
|
self._run_text_lines(raw)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default: ASCII hex dump stream.
|
||||||
|
self._run_ascii(raw)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self._transport.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _run_binary(self, initial_bytes: bytes) -> None:
|
||||||
|
log.info("StreamReader: starting binary stream parsing")
|
||||||
|
buf = bytearray(initial_bytes)
|
||||||
|
|
||||||
|
while not self._stop.is_set():
|
||||||
|
# Find sync marker in buffer.
|
||||||
|
idx = 0
|
||||||
|
synced = False
|
||||||
|
while idx < len(buf) - 1:
|
||||||
|
if buf[idx] == 0xAA and buf[idx + 1] == 0x88:
|
||||||
|
synced = True
|
||||||
|
break
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if synced:
|
||||||
|
buf = buf[idx:]
|
||||||
|
# Accumulate header (5 bytes after sync).
|
||||||
|
while len(buf) < 7 and not self._stop.is_set():
|
||||||
|
chunk = self._read_chunk()
|
||||||
|
buf.extend(chunk)
|
||||||
|
|
||||||
|
if self._stop.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
|
payload_len = int.from_bytes(buf[5:7], byteorder='little')
|
||||||
|
total_packet_len = 2 + 5 + payload_len + 2
|
||||||
|
|
||||||
|
# Accumulate the rest of the packet in one buffered read.
|
||||||
|
remaining = total_packet_len - len(buf)
|
||||||
|
if remaining > 0:
|
||||||
|
extra = self._accumulate(remaining)
|
||||||
|
buf.extend(extra)
|
||||||
|
|
||||||
|
if self._stop.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
|
header = buf[2:7]
|
||||||
|
seq = int.from_bytes(header[1:3], byteorder='little')
|
||||||
|
payload = buf[7:7 + payload_len]
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = self._parse(payload, seq)
|
||||||
|
self._on_frame(frame)
|
||||||
|
except Exception as exc:
|
||||||
|
self.error_count += 1
|
||||||
|
if self.error_count <= 10:
|
||||||
|
log.warning("Parse error on binary payload: %s", exc)
|
||||||
|
self._on_error(exc)
|
||||||
|
|
||||||
|
buf = buf[total_packet_len:]
|
||||||
|
else:
|
||||||
|
# Resync: keep only trailing 0xAA if present, then read more.
|
||||||
|
if len(buf) > 0 and buf[-1] == 0xAA:
|
||||||
|
buf = bytearray([0xAA])
|
||||||
|
else:
|
||||||
|
buf.clear()
|
||||||
|
|
||||||
|
chunk = self._read_chunk()
|
||||||
|
buf.extend(chunk)
|
||||||
|
|
||||||
|
def _run_ascii(self, initial_bytes: bytes) -> None:
|
||||||
|
log.info("StreamReader: starting ASCII hex dump stream parsing")
|
||||||
|
|
||||||
|
valid_words = 80 if self._family_code == "X" else 244
|
||||||
|
block_words = 256
|
||||||
|
word_char_len = 4
|
||||||
|
chars_needed = block_words * word_char_len # total hex chars per block
|
||||||
|
|
||||||
|
hex_chars = bytearray()
|
||||||
|
if initial_bytes:
|
||||||
|
for b in initial_bytes:
|
||||||
|
if chr(b) in "0123456789ABCDEFabcdef":
|
||||||
|
hex_chars.append(b)
|
||||||
|
elif b == 0xAA:
|
||||||
|
hex_chars.append(b)
|
||||||
|
|
||||||
|
seq = 0
|
||||||
|
while not self._stop.is_set():
|
||||||
|
# Accumulate a full block of hex characters using buffered reads.
|
||||||
|
while len(hex_chars) < chars_needed and not self._stop.is_set():
|
||||||
|
chunk = self._read_chunk(min_bytes=chars_needed - len(hex_chars))
|
||||||
|
for byte in chunk:
|
||||||
|
if byte == 0xAA:
|
||||||
|
# Sync marker appeared – hand off to binary parser.
|
||||||
|
hex_chars.append(byte)
|
||||||
|
break
|
||||||
|
if chr(byte) in "0123456789ABCDEFabcdef":
|
||||||
|
hex_chars.append(byte)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
break # broke out of inner loop due to 0xAA
|
||||||
|
|
||||||
|
# Check for early termination (0xAA sync marker anywhere in buffer).
|
||||||
|
aa_idx = hex_chars.find(bytes([0xAA]))
|
||||||
|
if aa_idx >= 0:
|
||||||
|
log.info("StreamReader: Detected 0xAA at position %d, switching to binary mode.", aa_idx)
|
||||||
|
self._run_binary(hex_chars[aa_idx:])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse words from the accumulated hex characters.
|
||||||
|
word_buffer = []
|
||||||
|
for i in range(0, min(len(hex_chars), chars_needed), word_char_len):
|
||||||
|
word_str = hex_chars[i:i + word_char_len]
|
||||||
|
if len(word_str) < word_char_len:
|
||||||
|
break
|
||||||
|
word_buffer.append(bytes(word_str))
|
||||||
|
|
||||||
|
if not word_buffer:
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
|
||||||
|
hex_chars = hex_chars[len(word_buffer) * word_char_len:]
|
||||||
|
|
||||||
|
valid_hex_words = word_buffer[:valid_words]
|
||||||
|
values = []
|
||||||
|
for hex_val in valid_hex_words:
|
||||||
|
try:
|
||||||
|
swapped = hex_val[2:4] + hex_val[0:2]
|
||||||
|
t = _convert_hex_to_temp(swapped, self._family_code)
|
||||||
|
values.append(t)
|
||||||
|
except Exception:
|
||||||
|
values.append(0.0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = Frame(seq=seq, time=time.monotonic(), values=values)
|
||||||
|
self._on_frame(frame)
|
||||||
|
seq += 1
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Error emitting ASCII frame: %s", exc)
|
||||||
|
|
||||||
|
def _run_text_lines(self, initial_bytes: bytes) -> None:
|
||||||
|
log.info("StreamReader: starting line-by-line ASCII text parsing")
|
||||||
|
buf = bytearray(initial_bytes)
|
||||||
|
seq = 0
|
||||||
|
|
||||||
|
while not self._stop.is_set():
|
||||||
|
# Drain complete lines from the buffer.
|
||||||
|
while b"\n" in buf:
|
||||||
|
line_bytes, _, buf = buf.partition(b"\n")
|
||||||
|
line = line_bytes.decode('utf-8', errors='ignore').strip()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
frame = self._parse(line, seq)
|
||||||
|
if frame:
|
||||||
|
self._on_frame(frame)
|
||||||
|
seq += 1
|
||||||
|
except Exception as exc:
|
||||||
|
self.error_count += 1
|
||||||
|
self._on_error(exc)
|
||||||
|
|
||||||
|
# Read more data using buffered reads.
|
||||||
|
chunk = self._read_chunk()
|
||||||
|
buf.extend(chunk)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
self._thread.join(timeout = 2.0)
|
||||||
|
self._thread = None
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Shared pytest fixtures."""
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def qapp():
|
||||||
|
"""Provide a QApplication instance for the test session."""
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
app = QApplication([])
|
||||||
|
yield app
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
Wafer ID=A12
|
||||||
|
Acquisition Date=03/26/2025
|
||||||
|
Label,1,2,3
|
||||||
|
X (mm),0,10,-10
|
||||||
|
Y (mm),10,-10,-10
|
||||||
|
data
|
||||||
|
0.0,149.0,148.5,150.6
|
||||||
|
0.5,149.2,148.4,150.7
|
||||||
|
1.0,149.1,148.6,150.5
|
||||||
|
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for src/pygui/backend/cluster_average.py."""
|
||||||
|
import math
|
||||||
|
import pytest
|
||||||
|
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def test_members_take_cluster_mean():
|
||||||
|
vals = [10.0, 20.0, 100.0, 0.0]
|
||||||
|
clusters = [[0, 1], [2, 3]]
|
||||||
|
assert average_clusters(vals, clusters) == [15.0, 15.0, 50.0, 50.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unclustered_sensors_unchanged():
|
||||||
|
vals = [10.0, 20.0, 30.0]
|
||||||
|
assert average_clusters(vals, [[0, 1]]) == [15.0, 15.0, 30.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_clusters_is_identity():
|
||||||
|
assert average_clusters([1.0, 2.0], []) == [1.0, 2.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nan_handling_in_averaging():
|
||||||
|
vals = [10.0, float('nan'), 100.0, float('nan')]
|
||||||
|
clusters = [[0, 1], [2, 3]]
|
||||||
|
res = average_clusters(vals, clusters)
|
||||||
|
assert res[0] == 10.0
|
||||||
|
assert res[1] == 10.0
|
||||||
|
assert res[2] == 100.0
|
||||||
|
assert res[3] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_nan_cluster():
|
||||||
|
vals = [float('nan'), float('nan'), 5.0]
|
||||||
|
clusters = [[0, 1]]
|
||||||
|
res = average_clusters(vals, clusters)
|
||||||
|
assert math.isnan(res[0])
|
||||||
|
assert math.isnan(res[1])
|
||||||
|
assert res[2] == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_by_radius():
|
||||||
|
sensors = [
|
||||||
|
Sensor(label="1", x=0.0, y=0.0), # center (r=0)
|
||||||
|
Sensor(label="2", x=10.0, y=0.0), # r=10
|
||||||
|
Sensor(label="3", x=0.0, y=10.5), # r=10.5 (within 2mm)
|
||||||
|
Sensor(label="4", x=20.0, y=0.0), # r=20
|
||||||
|
]
|
||||||
|
# Center should be excluded or not grouped; other sensors grouped
|
||||||
|
clusters = group_sensors_by_radius(sensors, tolerance=2.0)
|
||||||
|
# Expected: 2 and 3 are in the same cluster because 10.5 - 10.0 <= 2.0.
|
||||||
|
assert [1, 2] in clusters
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Tests for src/pygui/backend/comparison.py."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from pygui.backend.comparison import compare_runs
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_series():
|
||||||
|
a = [20.0, 30.0, 40.0, 50.0, 60.0]
|
||||||
|
result = compare_runs(a, a)
|
||||||
|
assert result["distance"] == 0.0
|
||||||
|
assert len(result["index_a"]) == len(a)
|
||||||
|
assert result["index_a"] == result["index_b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_series():
|
||||||
|
result = compare_runs([], [])
|
||||||
|
assert result["distance"] == float("inf")
|
||||||
|
assert result["index_a"] == []
|
||||||
|
assert result["index_b"] == []
|
||||||
|
assert result["warping_path"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_empty():
|
||||||
|
result = compare_runs([10.0, 20.0], [])
|
||||||
|
assert result["distance"] == float("inf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_aligned_series():
|
||||||
|
a = [10.0, 20.0, 30.0]
|
||||||
|
b = [15.0, 25.0, 35.0]
|
||||||
|
result = compare_runs(a, b)
|
||||||
|
assert result["distance"] >= 0.0
|
||||||
|
assert len(result["index_a"]) > 0
|
||||||
|
assert len(result["index_b"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_element():
|
||||||
|
result = compare_runs([42.0], [42.0])
|
||||||
|
assert result["distance"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_lengths():
|
||||||
|
short = [10.0, 20.0]
|
||||||
|
long_ = [10.0, 15.0, 20.0, 25.0, 30.0]
|
||||||
|
result = compare_runs(short, long_)
|
||||||
|
assert result["distance"] >= 0.0
|
||||||
|
assert len(result["index_a"]) > 0
|
||||||
|
assert len(result["index_b"]) > 0
|
||||||
|
assert len(result["index_a"]) == len(result["index_b"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_structure():
|
||||||
|
a = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||||||
|
b = [2.0, 3.0, 4.0, 5.0, 6.0]
|
||||||
|
result = compare_runs(a, b)
|
||||||
|
assert "distance" in result
|
||||||
|
assert "index_a" in result
|
||||||
|
assert "index_b" in result
|
||||||
|
assert "warping_path" in result
|
||||||
|
assert isinstance(result["distance"], float)
|
||||||
|
assert isinstance(result["index_a"], list)
|
||||||
|
assert isinstance(result["index_b"], list)
|
||||||
|
assert isinstance(result["warping_path"], list)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
|
from pygui.backend.data.data_records import read_data_records
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_roundtrips_through_parser(tmp_path):
|
||||||
|
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
|
||||||
|
path = tmp_path / "rec.csv"
|
||||||
|
|
||||||
|
rec = CsvRecorder()
|
||||||
|
rec.start(str(path), sensors, serial="A12")
|
||||||
|
rec.write(Frame(seq=0, time=0.0, values=[149.0, 148.0]))
|
||||||
|
rec.write(Frame(seq=1, time=0.5, values=[149.5, 148.5]))
|
||||||
|
rec.stop()
|
||||||
|
|
||||||
|
assert path.exists()
|
||||||
|
data, _ = ZWaferParser().parse(str(path))
|
||||||
|
assert data is not None
|
||||||
|
assert[s.label for s in data.sensors] == ["1", "2"]
|
||||||
|
assert len(data.csv_headers) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_data_rows_are_readable(tmp_path):
|
||||||
|
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
|
||||||
|
path = tmp_path / "rec.csv"
|
||||||
|
|
||||||
|
rec = CsvRecorder()
|
||||||
|
rec.start(str(path), sensors, serial="A12")
|
||||||
|
rec.write(Frame(seq=0, time=0.0, values=[149.0, 148.0]))
|
||||||
|
rec.write(Frame(seq=1, time=0.5, values=[149.5, 148.5]))
|
||||||
|
rec.stop()
|
||||||
|
|
||||||
|
records = read_data_records(str(path))
|
||||||
|
assert len(records) == 2
|
||||||
|
assert records[0].time == 0.0
|
||||||
|
assert records[0].values == [149.0, 148.0]
|
||||||
|
assert records[1].time == 0.5
|
||||||
|
assert records[1].values == [149.5, 148.5]
|
||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Tests for src/pygui/backend/data_records.py."""
|
||||||
|
from pygui.backend.data.data_records import read_data_records
|
||||||
|
|
||||||
|
|
||||||
|
def test_reads_rows_after_data_marker(tmp_path):
|
||||||
|
p = tmp_path / "s.csv"
|
||||||
|
p.write_text(
|
||||||
|
"Wafer ID=A12\n"
|
||||||
|
"Label,1,2\nX (mm),0,10\nY (mm),10,-10\n"
|
||||||
|
"data\n"
|
||||||
|
"0.0,149.0,148.5\n"
|
||||||
|
"0.5,149.2,148.4\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
recs = read_data_records(str(p))
|
||||||
|
assert len(recs) == 2
|
||||||
|
assert recs[0].time == 0.0
|
||||||
|
assert recs[0].values == [149.0, 148.5]
|
||||||
|
assert recs[1].values == [149.2, 148.4]
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings():
|
||||||
|
s = LocalSettings()
|
||||||
|
s.save_data_dir = ""
|
||||||
|
s.connection_status = "Disconnected"
|
||||||
|
s.activity_log = []
|
||||||
|
s.last_wafer_info = {}
|
||||||
|
return s
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller(mock_settings):
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
yield DeviceController(mock_settings, temp_dir)
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
def test_refresh_ports(controller):
|
||||||
|
controller._service.enumerate_ports = MagicMock(return_value=["COM1", "COM2"])
|
||||||
|
controller.refreshPorts()
|
||||||
|
assert "COM1" in controller.availablePorts
|
||||||
|
|
||||||
|
def test_detect_wafer_spawns_thread(controller):
|
||||||
|
with patch("threading.Thread") as mock_thread:
|
||||||
|
info = WaferInfo(
|
||||||
|
family_code="P",
|
||||||
|
serial_number="P00001",
|
||||||
|
sensor_count=244,
|
||||||
|
mfg_date_hex="12345678",
|
||||||
|
sensor_assigned_hex="01",
|
||||||
|
locked_hex="00",
|
||||||
|
runtime=100,
|
||||||
|
cycle_count=10
|
||||||
|
)
|
||||||
|
controller._service.detect_all_ports = MagicMock(
|
||||||
|
return_value=("COM1", info)
|
||||||
|
)
|
||||||
|
controller.detectWafer()
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
assert controller.operationInProgress
|
||||||
|
|
||||||
|
def test_read_memory_without_detect_return_error(controller):
|
||||||
|
emitted_result = None
|
||||||
|
def on_read_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.readResult.connect(on_read_result)
|
||||||
|
controller.readMemoryAsync()
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert "error" in emitted_result
|
||||||
|
|
||||||
|
def test_setters_and_properties(controller):
|
||||||
|
controller.setSelectedPort("COM3")
|
||||||
|
assert controller.selectedPort == "COM3"
|
||||||
|
|
||||||
|
controller.setSaveDataDir("some/custom/dir")
|
||||||
|
assert controller.saveDataDir == "some/custom/dir"
|
||||||
|
|
||||||
|
assert controller.connectionStatus == "Disconnected"
|
||||||
|
assert controller.dataRowCount == 0
|
||||||
|
assert controller.dataColCount == 0
|
||||||
|
assert isinstance(controller.lastWaferInfo, list)
|
||||||
|
assert controller.dataModel is not None
|
||||||
|
assert controller.graphView is not None
|
||||||
|
|
||||||
|
def test_clear_activity_log(controller):
|
||||||
|
controller._activity_log.append("[12:00:00] Some message")
|
||||||
|
assert len(controller._activity_log) > 0
|
||||||
|
controller.clearActivityLog()
|
||||||
|
assert len(controller._activity_log) == 0
|
||||||
|
|
||||||
|
def test_clear_session(controller):
|
||||||
|
controller._connection_status = "Connected"
|
||||||
|
controller._selected_port = "COM1"
|
||||||
|
controller._last_wafer_info = {"serialNumber": "P00001"}
|
||||||
|
|
||||||
|
controller.clearSession()
|
||||||
|
assert controller.connectionStatus == "Disconnected"
|
||||||
|
assert controller.selectedPort == ""
|
||||||
|
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
|
||||||
|
|
||||||
|
def test_erase_memory(controller):
|
||||||
|
controller._service.erase_wafer = MagicMock(return_value=True)
|
||||||
|
emitted_result = None
|
||||||
|
def on_erase_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.eraseResult.connect(on_erase_result)
|
||||||
|
controller.eraseMemory("COM1")
|
||||||
|
assert emitted_result == {"success": True}
|
||||||
|
assert controller.connectionStatus == "Connected"
|
||||||
|
|
||||||
|
def test_read_debug(controller):
|
||||||
|
controller._service.read_wafer_data = MagicMock(return_value=bytes([12] * 512))
|
||||||
|
emitted_result = None
|
||||||
|
def on_debug_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.debugResult.connect(on_debug_result)
|
||||||
|
controller.readDebug("COM1")
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert emitted_result.get("success") is True
|
||||||
|
assert emitted_result.get("sensor_bytes") == 512
|
||||||
|
assert emitted_result.get("debug_bytes") == 512
|
||||||
|
|
||||||
|
def test_parse_and_save_data_and_get_chart_data(controller):
|
||||||
|
res = controller.getChartData()
|
||||||
|
assert res == {"success": False}
|
||||||
|
|
||||||
|
controller._raw_bytes = bytes([12] * 512)
|
||||||
|
emitted_result = None
|
||||||
|
def on_parsed(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.parsedDataReady.connect(on_parsed)
|
||||||
|
controller.parseAndSaveData("P", "COM1")
|
||||||
|
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert emitted_result.get("success") is True
|
||||||
|
assert controller.dataRowCount == 1
|
||||||
|
assert controller.dataColCount == 244
|
||||||
|
|
||||||
|
chart_res = controller.getChartData()
|
||||||
|
assert chart_res.get("success") is True
|
||||||
|
assert "Sensor1" in chart_res.get("sensor_names")
|
||||||
|
assert len(chart_res.get("series")) == 244
|
||||||
|
|
||||||
|
def test_logging_handler(controller):
|
||||||
|
logger = logging.getLogger("test_logger")
|
||||||
|
logger.info("Hello QML Activity Log")
|
||||||
|
assert any("Hello QML Activity Log" in line for line in controller._activity_log)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.models.frame_player import FramePlayer
|
||||||
|
|
||||||
|
def frames(n):
|
||||||
|
return [Frame(seq=i, time=float(i), values=[149.0 + i])for i in range(n)]
|
||||||
|
|
||||||
|
def test_loads_and_reports_total():
|
||||||
|
p = FramePlayer(); p.load(frames(3))
|
||||||
|
assert p.total == 3 and p.index == 0
|
||||||
|
current = p.current()
|
||||||
|
assert current is not None
|
||||||
|
assert current.values == [149.0]
|
||||||
|
|
||||||
|
def test_step_forward_and_back_clamps():
|
||||||
|
p = FramePlayer(); p.load(frames(3))
|
||||||
|
assert p.step(1).index == 1
|
||||||
|
assert p.step(1).index == 2
|
||||||
|
assert p.step(1).index == 2
|
||||||
|
assert p.step(-5).index == 0
|
||||||
|
|
||||||
|
def test_seek():
|
||||||
|
p = FramePlayer(); p.load(frames(5))
|
||||||
|
assert p.seek(3).index == 3
|
||||||
|
assert p.seek(99).index == 4
|
||||||
|
|
||||||
|
def test_at_end():
|
||||||
|
p = FramePlayer(); p.load(frames(2))
|
||||||
|
assert not p.at_end
|
||||||
|
p.seek(1)
|
||||||
|
assert p.at_end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user