Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+8
-2
@@ -5,6 +5,9 @@ __pycache__/
|
|||||||
*.pyd
|
*.pyd
|
||||||
*.so
|
*.so
|
||||||
*.a
|
*.a
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
# Virtual environment
|
# Virtual environment
|
||||||
.venv/
|
.venv/
|
||||||
@@ -14,10 +17,11 @@ env/
|
|||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.qtcreator/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
docs
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -34,4 +38,6 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
*.spec
|
*.spec
|
||||||
*.icns
|
*.icns
|
||||||
*.ico
|
*.ico
|
||||||
|
# Superpowers brainstorming visual companion
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -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 |
+110
-1
@@ -1,16 +1,125 @@
|
|||||||
|
# ===== 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_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 = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort (import sorting)
|
||||||
|
"N", # pep8-naming
|
||||||
|
]
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
# ===== 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
|
||||||
|
|||||||
@@ -24,7 +24,42 @@ Rectangle {
|
|||||||
property int selectedTabIndex: 0
|
property int selectedTabIndex: 0
|
||||||
property int selectedSideActionIndex: -1 // nothing active on startup
|
property int selectedSideActionIndex: -1 // nothing active on startup
|
||||||
|
|
||||||
// ===== 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"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Main Two-Column Layout ======
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
@@ -53,6 +88,16 @@ 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.memoryRead // OPEN CSV IN EXCEL
|
||||||
|
case 3: return root.waferDetected // ERASE MEMORY
|
||||||
|
case 4: return root.memoryRead // IMPORT DATA
|
||||||
|
default: return true // STORED DATA
|
||||||
|
}
|
||||||
|
}
|
||||||
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 +105,20 @@ 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()
|
root.memoryRead = false
|
||||||
else if (index === 2) deviceController.openCsvFile()
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.detectWafer()
|
||||||
|
}
|
||||||
|
else if (index === 1) {
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.readMemoryAsync()
|
||||||
|
}
|
||||||
|
else if (index === 2) {
|
||||||
|
deviceController.openCsvFile()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -158,11 +214,17 @@ 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"
|
||||||
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" : ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +238,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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 =====
|
||||||
@@ -29,6 +30,28 @@ ColumnLayout {
|
|||||||
property string csvPath: ""
|
property string csvPath: ""
|
||||||
property bool dataParsed: false
|
property bool dataParsed: false
|
||||||
|
|
||||||
|
function cleanFolderUrl(url) {
|
||||||
|
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 CSV Save Folder"
|
||||||
|
onAccepted: {
|
||||||
|
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||||
|
root.parseAndSavePendingRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Empty state — nothing shown until detect fires =====
|
// ===== Empty state — nothing shown until detect fires =====
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -47,11 +70,7 @@ ColumnLayout {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 86
|
Layout.preferredHeight: 86
|
||||||
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
|
||||||
@@ -187,27 +206,105 @@ 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
|
||||||
|
|
||||||
ScrollView {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
clip: true
|
spacing: 0
|
||||||
|
|
||||||
TextArea {
|
// Header
|
||||||
id: activityLog
|
Rectangle {
|
||||||
width: parent.width
|
Layout.fillWidth: true
|
||||||
text: ""
|
Layout.preferredHeight: 40
|
||||||
readOnly: true
|
color: Theme.subtleSectionBackground
|
||||||
font.family: "monospace"
|
|
||||||
font.pixelSize: 12
|
RowLayout {
|
||||||
wrapMode: TextArea.WordWrap
|
anchors.fill: parent
|
||||||
leftPadding: 4
|
anchors.leftMargin: Theme.panelPadding
|
||||||
rightPadding: 4
|
anchors.rightMargin: Theme.panelPadding
|
||||||
color: Theme.bodyColor
|
|
||||||
}
|
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 {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.margins: Theme.panelPadding
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: activityLog
|
||||||
|
width: parent.width
|
||||||
|
text: ""
|
||||||
|
readOnly: true
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: 12
|
||||||
|
wrapMode: TextArea.WordWrap
|
||||||
|
color: Theme.bodyColor
|
||||||
|
background: null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
@@ -271,6 +368,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,270 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
// Mode toggle
|
||||||
|
TabBar {
|
||||||
|
id: modeBar
|
||||||
|
currentIndex: 1 // 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: "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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onCurrentIndexChanged:
|
||||||
|
if (currentIndex === 0){
|
||||||
|
// Entering Live mode, only work when wafer detected/connected
|
||||||
|
streamController.setMode("live")
|
||||||
|
if (deviceController.connectionStatus === "Connected") {
|
||||||
|
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.toUpperCase()
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: 11
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body: 3 zones ─────────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal separation line with vertical padding
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 32
|
||||||
|
Rectangle {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransportBar { Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,295 @@
|
|||||||
|
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: 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,45 @@
|
|||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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,8 @@
|
|||||||
|
# ===== ISC Tabs Module =====
|
||||||
|
module ISC.Tabs
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -5,13 +6,21 @@ from PySide6.QtQml import QQmlApplicationEngine
|
|||||||
from PySide6.QtQuickControls2 import QQuickStyle
|
from PySide6.QtQuickControls2 import QQuickStyle
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from backend.device_controller import DeviceController
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
from backend.local_settings import LocalSettings
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
from backend.local_settings_model import LocalSettingsModel
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
from backend.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 registers the @QmlElement WaferMapItem (QML: import ISC.Wafer).
|
||||||
|
|
||||||
|
|
||||||
# ===== Application Entry Point =====
|
# ===== Application Entry Point =====
|
||||||
if __name__ == "__main__":
|
def main() -> int:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
# ===== UI Style Setup =====
|
# ===== UI Style Setup =====
|
||||||
# Use a non-native controls style so our custom QML button backgrounds are supported.
|
# Use a non-native controls style so our custom QML button backgrounds are supported.
|
||||||
QQuickStyle.setStyle("Basic")
|
QQuickStyle.setStyle("Basic")
|
||||||
@@ -34,12 +43,22 @@ if __name__ == "__main__":
|
|||||||
device_controller = DeviceController(raw_settings, data_dir)
|
device_controller = DeviceController(raw_settings, data_dir)
|
||||||
engine.rootContext().setContextProperty("deviceController", device_controller)
|
engine.rootContext().setContextProperty("deviceController", device_controller)
|
||||||
|
|
||||||
|
# ===== Session Controller (live/review wafer dashboard) =====
|
||||||
|
stream_controller = SessionController()
|
||||||
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
# ===== QML Startup =====
|
# ===== QML Startup =====
|
||||||
|
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
||||||
|
# package directory is the import path the engine searches for qmldir.
|
||||||
engine.addImportPath(Path(__file__).parent)
|
engine.addImportPath(Path(__file__).parent)
|
||||||
engine.loadFromModule("ISC", "Main")
|
engine.loadFromModule("ISC", "Main")
|
||||||
|
|
||||||
# ===== Exit Handling =====
|
# ===== Exit Handling =====
|
||||||
if not engine.rootObjects():
|
if not engine.rootObjects():
|
||||||
sys.exit(-1)
|
return -1
|
||||||
|
|
||||||
sys.exit(app.exec())
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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,28 @@
|
|||||||
|
# ===== 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.data_segment import DataSegment # 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.contour_models import ContourLine, ContourSegment # 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
|
||||||
@@ -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,5 @@
|
|||||||
|
# ===== Controllers Sub-package =====
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
|
||||||
|
__all__ = ["DeviceController", "SessionController"]
|
||||||
@@ -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,25 @@ 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.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__)
|
||||||
|
|
||||||
@@ -62,9 +62,8 @@ class DeviceController(QObject):
|
|||||||
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', "")
|
||||||
@@ -88,8 +87,8 @@ class DeviceController(QObject):
|
|||||||
self.statusRestored.emit()
|
self.statusRestored.emit()
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
# ---- Properties ----
|
# ---- Properties ----
|
||||||
@Property(list, notify=portsUpdated)
|
@Property(list, notify=portsUpdated)
|
||||||
@@ -204,6 +203,28 @@ 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()
|
||||||
|
def clearActivityLog(self) -> None:
|
||||||
|
"""Clear the activity log."""
|
||||||
|
self._activity_log.clear()
|
||||||
|
self.activityLogUpdated.emit("")
|
||||||
|
self._save_status()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def clearSession(self) -> None:
|
||||||
|
"""Clear the current session state, allowing a clean detect."""
|
||||||
|
self._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.
|
||||||
|
|
||||||
@@ -283,7 +304,8 @@ class DeviceController(QObject):
|
|||||||
"""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")
|
||||||
@@ -330,9 +352,7 @@ class DeviceController(QObject):
|
|||||||
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._connection_status = "Disconnected"
|
||||||
@@ -419,12 +439,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 +489,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 +523,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 ----
|
||||||
|
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"""QML-facing controller for the live/review wafer dashboard."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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.sensor_editor import SensorEditor
|
||||||
|
from pygui.backend.models.session_model import SessionModel
|
||||||
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
|
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()
|
||||||
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
|
_liveFrame = Signal(object) # Frame
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | 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._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]] = []
|
||||||
|
|
||||||
|
# ---- 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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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._loaded_file = file_path
|
||||||
|
self.loadedFileChanged.emit()
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def play(self) -> None:
|
||||||
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def pause(self) -> None: self._play_timer.stop()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._player.seek(0)
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def step(self, delta: int) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self._player.step(delta)
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def seek(self, i: int) -> None:
|
||||||
|
self._player.seek(i)
|
||||||
|
self._emit_current()
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
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)
|
||||||
|
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._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()
|
||||||
|
def stopStream(self) -> None:
|
||||||
|
self._repaint_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)
|
||||||
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
|
# 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
|
||||||
|
|
||||||
|
def _flush_repaint(self) -> None:
|
||||||
|
if not self._dirty:
|
||||||
|
return
|
||||||
|
self._dirty = False
|
||||||
|
self.frameUpdated.emit()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
# ---- recording ----
|
||||||
|
@Slot(str, str)
|
||||||
|
def startRecording(self, path: str, serial: str = "") -> None:
|
||||||
|
self._recorder.start(path, self._sensors, serial)
|
||||||
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def stopRecording(self) -> None:
|
||||||
|
if self._recorder.is_recording:
|
||||||
|
self._recorder.stop()
|
||||||
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
|
@Slot(int, float)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
def clearSensorEdit(self, index: int) -> None:
|
||||||
|
"""Remove all overrides for sensor `index`."""
|
||||||
|
self._sensor_editor.clear(index)
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def clearSensorEdits(self) -> None:
|
||||||
|
"""Remove all sensor overrides."""
|
||||||
|
self._sensor_editor.clear()
|
||||||
|
self._reprocess_current()
|
||||||
|
|
||||||
|
|
||||||
@@ -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,16 @@
|
|||||||
|
# ===== 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.data_segment import DataSegment
|
||||||
|
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",
|
||||||
|
"DataSegment",
|
||||||
|
"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,47 @@
|
|||||||
|
"""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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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,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,11 @@
|
|||||||
|
# ===== Visualization Sub-package =====
|
||||||
|
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
|
||||||
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"WaferMapItem", "GraphView",
|
||||||
|
"interpolate_field",
|
||||||
|
"ContourLine", "ContourSegment",
|
||||||
|
]
|
||||||
@@ -9,7 +9,7 @@ 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
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||||
from PySide6.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -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.visualization.contour_models import ContourLine, ContourSegment
|
||||||
|
|
||||||
|
|
||||||
# ===== Contour Generation =====
|
# ===== Contour Generation =====
|
||||||
@@ -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,517 @@
|
|||||||
|
"""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 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,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,14 @@ 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 +54,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:
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""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
|
||||||
|
c = chr(byte)
|
||||||
|
if c in "0123456789ABCDEFabcdef":
|
||||||
|
hex_chars.append(c)
|
||||||
|
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
|
||||||
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,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,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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import math
|
||||||
|
import pytest
|
||||||
|
from pygui.backend.models.frame_stats import compute_stats, Stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_stat():
|
||||||
|
s = compute_stats([148.0, 150.0, 149.0])
|
||||||
|
assert s.min == 148.0 and s.min_index == 0
|
||||||
|
assert s.max == 150.0 and s.max_index == 1
|
||||||
|
assert s.diff == pytest.approx(2.0)
|
||||||
|
assert s.avg == pytest.approx(149.0)
|
||||||
|
assert s.sigma == pytest.approx(math.sqrt(2 / 3))
|
||||||
|
assert s.three_sigma == pytest.approx(3 * math.sqrt(2 / 3))
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_values_returns_zeros():
|
||||||
|
s = compute_stats([])
|
||||||
|
assert s == Stats(0.0, -1, 0.0, -1, 0.0, 0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignores_nan():
|
||||||
|
s = compute_stats([149.0, float("nan"), 151.0])
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Tests for src/pygui/backend/rbf_heatmap.py."""
|
||||||
|
import numpy as np
|
||||||
|
from pygui.backend.visualization.rbf_heatmap import interpolate_field, BACKEND
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_shape_and_interpolates_at_sites():
|
||||||
|
xs = np.array([0.0, 10.0, -10.0, 0.0])
|
||||||
|
ys = np.array([10.0, -10.0, -10.0, 0.0])
|
||||||
|
vs = np.array([149.0, 150.0, 148.0, 149.5])
|
||||||
|
field = interpolate_field(xs, ys, vs, width=64, height=64,
|
||||||
|
extent=(-15, 15, -15, 15))
|
||||||
|
assert field.shape == (64, 64)
|
||||||
|
assert np.isfinite(field).all()
|
||||||
|
assert field.min() <= 150.0 and field.max() >= 148.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_round_clip_produces_nans_outside_circle():
|
||||||
|
xs = np.array([0.0, 5.0, -5.0])
|
||||||
|
ys = np.array([5.0, -5.0, 0.0])
|
||||||
|
vs = np.array([149.0, 150.0, 148.0])
|
||||||
|
field = interpolate_field(xs, ys, vs, width=64, height=64,
|
||||||
|
extent=(-15, 15, -15, 15), round_clip=True)
|
||||||
|
assert np.isnan(field).any()
|
||||||
|
# center pixel must be finite
|
||||||
|
assert np.isfinite(field[32, 32])
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_reported():
|
||||||
|
assert BACKEND in ("cupy", "numpy")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Tests for src/pygui/backend/sensor_editor.py."""
|
||||||
|
import pytest
|
||||||
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_overrides_raw_value():
|
||||||
|
ed = SensorEditor()
|
||||||
|
ed.set_replacement(2, 150.0) # index 2 forced to 150.0 regardless of raw
|
||||||
|
assert ed.apply([149.0, 149.0, 99.0]) == [149.0, 149.0, 150.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_shifts_raw_value():
|
||||||
|
ed = SensorEditor()
|
||||||
|
ed.set_offset(0, +0.5)
|
||||||
|
assert ed.apply([149.0, 149.0]) == [149.5, 149.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_and_offset_apply_in_order():
|
||||||
|
"""Replacement wins over raw, then offset is added on top."""
|
||||||
|
ed = SensorEditor()
|
||||||
|
ed.set_replacement(2, 150.0)
|
||||||
|
ed.set_offset(2, +1.0) # sensor 2: replace 99→150, then +1 → 151
|
||||||
|
ed.set_offset(0, +0.5)
|
||||||
|
out = ed.apply([149.0, 149.0, 99.0, 149.0])
|
||||||
|
assert out == [149.5, 149.0, 151.0, 149.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_single_index_restores_only_that_sensor():
|
||||||
|
ed = SensorEditor()
|
||||||
|
ed.set_replacement(0, 0.0)
|
||||||
|
ed.set_replacement(1, 0.0)
|
||||||
|
ed.clear(0) # clear only index 0
|
||||||
|
assert ed.apply([149.0, 149.0]) == [149.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_restores_all():
|
||||||
|
ed = SensorEditor()
|
||||||
|
ed.set_offset(0, 5.0)
|
||||||
|
ed.set_replacement(1, 200.0)
|
||||||
|
ed.clear()
|
||||||
|
assert ed.apply([149.0, 149.0]) == [149.0, 149.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_overrides_returns_copy():
|
||||||
|
ed = SensorEditor()
|
||||||
|
original = [149.0, 149.0]
|
||||||
|
result = ed.apply(original)
|
||||||
|
assert result == original
|
||||||
|
assert result is not original
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.backend.models.threshold_classifier import ThresholdConfig, BAND_HIGH, BAND_IN, BAND_LOW
|
||||||
|
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_banding_and_stats():
|
||||||
|
model = SessionModel(ThresholdConfig(set_point=149.0, margin=1.0, auto=False))
|
||||||
|
update = model.process(Frame(seq=7, time=1.0, values=[151.0, 149.0, 147.0]))
|
||||||
|
assert update.seq == 7
|
||||||
|
assert update.bands == [BAND_HIGH, BAND_IN, BAND_LOW]
|
||||||
|
assert update.stats.avg == 149.0
|
||||||
|
assert (update.target, update.margin) == (149.0, 1.0)
|
||||||
|
assert update.state in ("idle", "ramp", "set")
|
||||||
|
|
||||||
|
def test_auto_banding_uses_mean_and_sigma():
|
||||||
|
model = SessionModel(ThresholdConfig(auto=True))
|
||||||
|
# Tight cluster around 149 -> small σ -> the 200.0 outlier reads HIGH
|
||||||
|
update = model.process(Frame(seq=0, time=0.0, values=[149.0, 149.0, 149.0, 200.0]))
|
||||||
|
assert update.bands[3] == BAND_HIGH
|
||||||
|
assert update.target == update.stats.avg
|
||||||
|
|
||||||
|
def test_set_thresholds_reband_on_next_frame():
|
||||||
|
m = SessionModel(ThresholdConfig(set_point=149.0, margin=1.0, auto=False))
|
||||||
|
m.set_thresholds(ThresholdConfig(set_point=149.0, margin=5.0, auto=False))
|
||||||
|
upd = m.process(Frame(seq=0, time=0.0, values=[151.0]))
|
||||||
|
assert upd.bands == [BAND_IN]
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from pygui.backend.models.stability_detector import (
|
||||||
|
StabilityDetector, STATE_IDLE, STATE_RAMP, STATE_SET,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SET_POINT = 149.0
|
||||||
|
|
||||||
|
def make():
|
||||||
|
return StabilityDetector(idle_below = 50.0, tolerance=1.0, settle_seconds=2.0)
|
||||||
|
|
||||||
|
def test_idle_when_cold():
|
||||||
|
d = make()
|
||||||
|
assert d.update(avg=25.0, time=0.0, set_point=SET_POINT) == STATE_IDLE
|
||||||
|
|
||||||
|
def test_ramp_while_far_from_setpoint():
|
||||||
|
d = make()
|
||||||
|
d.update(avg=100.0, time=0.0, set_point=SET_POINT)
|
||||||
|
|
||||||
|
def test_ramp_until_settle_time_elapses():
|
||||||
|
d = make()
|
||||||
|
assert d.update(avg=149.2, time=0.0, set_point=SET_POINT) == STATE_RAMP
|
||||||
|
assert d.update(avg=148.9, time=0.0, set_point=SET_POINT) == STATE_RAMP
|
||||||
|
|
||||||
|
def test_set_after_holding_near_setpoint():
|
||||||
|
d = make()
|
||||||
|
d.update(avg=149.2, time=0.0, set_point=SET_POINT)
|
||||||
|
d.update(avg=148.9, time=1.0, set_point=SET_POINT)
|
||||||
|
assert d.update(avg=149.0, time=2.5, set_point=SET_POINT) == STATE_SET
|
||||||
|
|
||||||
|
|
||||||
|
def test_back_to_ramp_on_disturbance():
|
||||||
|
d = make()
|
||||||
|
for t in (0.0, 1.0, 2.5):
|
||||||
|
d.update(149.0, t, set_point=SET_POINT)
|
||||||
|
assert d.update(avg=160.0, time=3.0, set_point=SET_POINT) == STATE_RAMP
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import time
|
||||||
|
from pygui.backend.models.frame import Frame
|
||||||
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
"""Yield canned data then blocks (returns b'')."""
|
||||||
|
def __init__(self, lines):
|
||||||
|
self._data = b"".join(lines)
|
||||||
|
self._pos = 0
|
||||||
|
self.closed = False
|
||||||
|
def read(self, n=1):
|
||||||
|
time.sleep(0.001)
|
||||||
|
if self._pos >= len(self._data):
|
||||||
|
return b""
|
||||||
|
res = self._data[self._pos:self._pos+n]
|
||||||
|
self._pos += n
|
||||||
|
return res
|
||||||
|
def readline(self):
|
||||||
|
time.sleep(0.001)
|
||||||
|
if self._pos >= len(self._data):
|
||||||
|
return b""
|
||||||
|
idx = self._data.find(b"\n", self._pos)
|
||||||
|
if idx == -1:
|
||||||
|
res = self._data[self._pos:]
|
||||||
|
self._pos = len(self._data)
|
||||||
|
return res
|
||||||
|
res = self._data[self._pos:idx+1]
|
||||||
|
self._pos = idx + 1
|
||||||
|
return res
|
||||||
|
def close(self): self.closed = True
|
||||||
|
|
||||||
|
def parse_line(raw: str, seq: int) -> Frame:
|
||||||
|
parts = [float(x) for x in raw.split(",")]
|
||||||
|
return Frame(seq = seq, time = parts[0], values = parts[1:])
|
||||||
|
|
||||||
|
def test_reads_frames_and_counts_errors():
|
||||||
|
got, errors = [], []
|
||||||
|
transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"])
|
||||||
|
r = StreamReader(transport, parse_line,
|
||||||
|
on_frame = got.append, on_error = lambda e: errors.append(e))
|
||||||
|
|
||||||
|
r.start()
|
||||||
|
time.sleep(0.1)
|
||||||
|
r.stop()
|
||||||
|
assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]]
|
||||||
|
assert r.error_count == 1
|
||||||
|
assert transport.closed
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from pygui.backend.models.threshold_classifier import (
|
||||||
|
ThresholdConfig, classify, classify_all, resolve_bounds,
|
||||||
|
BAND_IN, BAND_HIGH, BAND_LOW
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_about_target_margin():
|
||||||
|
assert classify(149.0, target=149.0, margin=1.0) ==BAND_IN # exactly
|
||||||
|
assert classify(149.9, target=149.0, margin=1.0) ==BAND_IN # within
|
||||||
|
assert classify(150.5, target=149.0, margin=1.0) ==BAND_HIGH # 1.5 above
|
||||||
|
assert classify(147.5, target=149.0, margin=1.0) ==BAND_LOW # 1.5 below
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_bounds_use_set_point_and_margin():
|
||||||
|
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
|
||||||
|
assert resolve_bounds([200.0, 0.0], cfg) == (149.0, 1.0)
|
||||||
|
|
||||||
|
def test_auto_bounds_use_mean_and_sigma():
|
||||||
|
cfg = ThresholdConfig(auto=True)
|
||||||
|
target, margin = resolve_bounds([148.0, 150.0, 149.0], cfg)
|
||||||
|
assert target == 149.0
|
||||||
|
assert margin == math.sqrt(2 / 3)
|
||||||
|
|
||||||
|
def test_classify_all_manual():
|
||||||
|
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
|
||||||
|
assert classify_all([149.0, 151.0, 147.0], cfg) == [BAND_IN, BAND_HIGH, BAND_LOW]
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
||||||
|
import pytest
|
||||||
|
from pygui.backend.wafer.wafer_layouts import load_layout, available_families
|
||||||
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def test_lists_bundled_families():
|
||||||
|
fams = available_families()
|
||||||
|
assert "aep" in fams and "x" in fams
|
||||||
|
|
||||||
|
|
||||||
|
def test_loads_sensors_in_mm():
|
||||||
|
sensors = load_layout("aep")
|
||||||
|
assert sensors and all(isinstance(s, Sensor) for s in sensors)
|
||||||
|
assert any(s.x < 0 for s in sensors)
|
||||||
|
assert max(abs(s.x) for s in sensors) < 200.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_family_raises():
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
load_layout("nope")
|
||||||
Reference in New Issue
Block a user