Compare commits
64 Commits
b417211476
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3bec9031 | |||
| fed4d9b590 | |||
| 4bb855a940 | |||
| 94f917b116 | |||
| 25fa7507ce | |||
| 034f13b717 | |||
| 69753e35f9 | |||
| 278a48a2e4 | |||
| e2ce8778e0 | |||
| bdc667b2ed | |||
| b6249c2b8e | |||
| 2fe8aef805 | |||
| 4ee139ad8f | |||
| 7b20ffa376 | |||
| ac4448ed44 | |||
| ca158a7946 | |||
| 637b51b999 | |||
| bb9b5d709a | |||
| b6903af625 | |||
| 92f130b3bd | |||
| 1e03227788 | |||
| 7df7fd4c6f | |||
| e2d05d2c33 | |||
| 799155f249 | |||
| 015642eea0 | |||
| d63332d619 | |||
| 88b0214582 | |||
| 0014ccc184 | |||
| 1989ba8e5a | |||
| 915720085b | |||
| e2d71662ff | |||
| 52cc747536 | |||
| 185e66ea3a | |||
| 3b919f576f | |||
| f604308466 | |||
| 0f813b2376 | |||
| 6996df73f8 | |||
| 3cc10ea7fe | |||
| ecab3d81b1 | |||
| 9e28a25695 | |||
| da43aeb1f6 | |||
| 0a2d6b78ba | |||
| 0d5cf9e4ff | |||
| 27292c3bdb | |||
| d7962f2850 | |||
| 5105ab1ab6 | |||
| db21f201f6 | |||
| c9cb7ab8d8 | |||
| 5d079174e0 | |||
| a54d536efe | |||
| c622a4cc23 | |||
| 3fef31efff | |||
| f9737259c1 | |||
| bf201d6afb | |||
| 831457941a | |||
| cfadbdf1c3 | |||
| f4621f1faf | |||
| 2cbc143c20 | |||
| 470f8acba3 | |||
| 62ce5a9c37 | |||
| 20a34e22ee | |||
| 7206334a27 | |||
| 5514653b94 | |||
| 93868bcde4 |
@@ -22,6 +22,7 @@ env/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
docs
|
docs
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -39,5 +40,21 @@ dist/
|
|||||||
*.spec
|
*.spec
|
||||||
*.icns
|
*.icns
|
||||||
*.ico
|
*.ico
|
||||||
|
|
||||||
|
# Runtime scratch data
|
||||||
|
tmp/
|
||||||
|
|
||||||
# Superpowers brainstorming visual companion
|
# Superpowers brainstorming visual companion
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.llm-wiki/
|
||||||
|
.agents/
|
||||||
|
.pi/
|
||||||
|
|
||||||
|
# Planning / personal docs (root level)
|
||||||
|
CLAUDE.md
|
||||||
|
LEARNINGS.md
|
||||||
|
MIGRATION.md
|
||||||
|
AGENTS.md
|
||||||
|
CONVENTIONS.md
|
||||||
|
specs/
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
install:
|
install:
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
test:
|
test: lint
|
||||||
uv run pytest tests/
|
uv run pytest tests/
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
uv run ruff check src/
|
uv run ruff check src/ tests/
|
||||||
|
|
||||||
fix:
|
fix:
|
||||||
uv run ruff check src/ --fix
|
uv run ruff check src/ tests/ --fix
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
uv run mypy src/
|
uv run mypy src/
|
||||||
|
|||||||
@@ -2,6 +2,38 @@
|
|||||||
|
|
||||||
Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI shell.
|
Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI shell.
|
||||||
|
|
||||||
|
## Key App Features
|
||||||
|
|
||||||
|
The `ISenseCloud` application provides a real-time monitor and analysis dashboard for silicon wafer temperature profiles:
|
||||||
|
|
||||||
|
- **Real-Time Data Streaming & Coalescing**: Decodes binary frame streams from thermal sensors over serial/COM connections at ~20 Hz with low CPU overhead.
|
||||||
|
- **RBF Heatmap Interpolation**: A Radial Basis Function (RBF) interpolation engine paints a smooth 2D temperature gradient across the wafer surface based on physical sensor locations.
|
||||||
|
- **Interactive Review & Playback**: A `FramePlayer` component supports variable-speed playback (play, pause, step, seek) of recorded runs.
|
||||||
|
- **Interactive Trend Charts**: Renders multi-series temperature trends using a custom QtQuick painted item (`GraphQuickItem`) with automatic scaling, custom legends, and grid layouts.
|
||||||
|
- **Tabular Data Inspection**: Direct inspection of tabular dataset grids for frame-by-frame diagnostic checkups.
|
||||||
|
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
|
||||||
|
|
||||||
|
### App Showcase
|
||||||
|
|
||||||
|

|
||||||
|
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **Take Screenshots for Visual Placeholders:**
|
||||||
|
>
|
||||||
|
> - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here.
|
||||||
|
> - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here.
|
||||||
|
|
||||||
|
## Key Architectural & Design Decisions
|
||||||
|
|
||||||
|
During the refactoring from the flat-layout prototype (`SettingTab` branch) to the unified package application (`main`), the following structural changes were made:
|
||||||
|
|
||||||
|
1. **Package Layout Restructuring**: Reorganized flat imports into a clean `src/` layout under `pygui.*` sub-packages (`controllers/`, `models/`, `data/`, `visualization/`, `wafer/`) for better module isolation.
|
||||||
|
2. **QML Component Decoupling**: Monolithic pages were refactored into reusable component blocks (e.g., `ReadoutPanel.qml`, `WaferMapView.qml`, `TransportBar.qml` under `src/pygui/ISC/Tabs/components/`), easing tab maintenance.
|
||||||
|
3. **Decoupled Controller Architecture**: Introduced QML-bound controllers (`DeviceController`, `SessionController`) acting as bridges between UI triggers and backend model states (`FramePlayer`, `SessionModel`), keeping UI files strictly visual.
|
||||||
|
4. **Robust Slot Error Boundaries**: Decorated QML-callable slots with a custom `@slot_error_boundary` wrapper to intercept runtime errors gracefully without terminating the Qt environment.
|
||||||
|
5. **Modernized Rendering Engine**: Replaced outdated contour algorithms with Radial Basis Function (RBF) interpolation (`rbf_heatmap.py`) to render high-fidelity, real-time thermal profiles.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.11+ (tested with Python 3.14 in this workspace)
|
- Python 3.11+ (tested with Python 3.14 in this workspace)
|
||||||
@@ -12,12 +44,15 @@ Small PySide6 + Qt Quick application scaffold for the `ISenseCloud` desktop UI s
|
|||||||
From the project root:
|
From the project root:
|
||||||
|
|
||||||
### Option A: Using `uv` (Recommended)
|
### Option A: Using `uv` (Recommended)
|
||||||
|
|
||||||
If you have [uv](https://docs.astral.sh/uv/) installed:
|
If you have [uv](https://docs.astral.sh/uv/) installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option B: Using `pip`
|
### Option B: Using `pip`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
@@ -28,6 +63,8 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
### Launch PySide6 QML App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
@@ -41,7 +78,7 @@ The project uses `uv` for dependency management and tooling, `Ruff` for linting
|
|||||||
A `Makefile` is provided to simplify common development and verification tasks:
|
A `Makefile` is provided to simplify common development and verification tasks:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| --------- | ------------- |
|
||||||
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
|
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
|
||||||
| `make run` | Launch the ISenseCloud application |
|
| `make run` | Launch the ISenseCloud application |
|
||||||
| `make test` | Run the `pytest` test suite |
|
| `make test` | Run the `pytest` test suite |
|
||||||
@@ -53,6 +90,7 @@ A `Makefile` is provided to simplify common development and verification tasks:
|
|||||||
### Ruff Configuration
|
### Ruff Configuration
|
||||||
|
|
||||||
Ruff is configured in `pyproject.toml` to enforce:
|
Ruff is configured in `pyproject.toml` to enforce:
|
||||||
|
|
||||||
- **E/W**: Pycodestyle errors and warnings
|
- **E/W**: Pycodestyle errors and warnings
|
||||||
- **F**: Pyflakes linter rules
|
- **F**: Pyflakes linter rules
|
||||||
- **I**: Import sorting (isort parity)
|
- **I**: Import sorting (isort parity)
|
||||||
@@ -78,6 +116,41 @@ The application lives under a `src/` layout as the `pygui` package:
|
|||||||
- `tests/`: pytest suite.
|
- `tests/`: pytest suite.
|
||||||
- `packaging/`: PyInstaller spec (`isc.spec`) and app icons.
|
- `packaging/`: PyInstaller spec (`isc.spec`) and app icons.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ QML Layer (src/pygui/ISC/) │
|
||||||
|
│ HomePage.qml ─ WaferMapTab.qml ─ DataTab.qml │
|
||||||
|
│ StatusTab.qml ─ SettingsTab.qml ─ components/* │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│ Q_PROPERTY / @Slot / Signal
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Controller Layer (backend/controllers/) │
|
||||||
|
│ DeviceController ─ SessionController │
|
||||||
|
│ (serial ops, status) (streaming, playback) │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Model Layer (backend/models/) │
|
||||||
|
│ FramePlayer ─ SessionModel ─ ReplayStatsTracker │
|
||||||
|
│ ThresholdClassifier ─ SensorEditor ─ ClusterAverage │
|
||||||
|
│ TemperatureTableModel │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Serial / Data Layer (serialcomm/ + data/) │
|
||||||
|
│ SerialPort ─ DeviceService ─ StreamReader ─ data_parser │
|
||||||
|
│ LocalSettings ─ FileBrowser ─ CsvRecorder │
|
||||||
|
└─────────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────────▼──────────────────────────────────┐
|
||||||
|
│ Visualization (backend/visualization/) │
|
||||||
|
│ WaferMapItem ─ GraphView ─ TrendChartItem │
|
||||||
|
│ RBFHeatmap ─ WaferMapView (QML wrapper) │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
## Window Configuration
|
## Window Configuration
|
||||||
|
|
||||||
Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
|
Window dimensions and constraints are defined in `src/pygui/ISC/Main.qml`:
|
||||||
@@ -109,11 +182,13 @@ Window {
|
|||||||
- If the app does not start, verify the virtual environment is active and dependencies are installed:
|
- If the app does not start, verify the virtual environment is active and dependencies are installed:
|
||||||
|
|
||||||
*Using `uv`:*
|
*Using `uv`:*
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
*Using `pip`:*
|
*Using `pip`:*
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@@ -122,17 +197,20 @@ Window {
|
|||||||
- If no window appears, ensure you are running in a desktop session with GUI access.
|
- If no window appears, ensure you are running in a desktop session with GUI access.
|
||||||
|
|
||||||
- **Windows COM Port Connection Issues:**
|
- **Windows COM Port Connection Issues:**
|
||||||
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
- Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||||
|
|
||||||
### Step-by-Step Windows Simulator Connection Guide
|
### Step-by-Step Windows Simulator Connection Guide
|
||||||
|
|
||||||
#### Step 1: Create the Virtual Serial Port Bridge
|
#### Step 1: Create the Virtual Serial Port Bridge
|
||||||
|
|
||||||
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Step 2: Configure and Start the Wafer Simulator
|
#### Step 2: Configure and Start the Wafer Simulator
|
||||||
|
|
||||||
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
||||||
|
|
||||||
1. Set the **Serial Port** to **`COM5`**.
|
1. Set the **Serial Port** to **`COM5`**.
|
||||||
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
|
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
|
||||||
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
|
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
|
||||||
@@ -141,6 +219,7 @@ Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
|
|||||||

|

|
||||||
|
|
||||||
#### Step 3: Run the UI and Connect
|
#### Step 3: Run the UI and Connect
|
||||||
|
|
||||||
Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green).
|
Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green).
|
||||||
|
|
||||||

|

|
||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"scipy>=1.17.1",
|
"scipy>=1.17.1",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"dtw-python",
|
"dtw-python",
|
||||||
|
"cryptography>=41.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -49,7 +50,7 @@ files = [
|
|||||||
"src/pygui/ISC/Tabs/qmldir",
|
"src/pygui/ISC/Tabs/qmldir",
|
||||||
"src/pygui/__main__.py",
|
"src/pygui/__main__.py",
|
||||||
"src/pygui/backend/contour_models.py",
|
"src/pygui/backend/contour_models.py",
|
||||||
"src/pygui/backend/crypto_helper.py",
|
"src/pygui/backend/crypto/crypto_helper.py",
|
||||||
"src/pygui/backend/csv_file_metadata.py",
|
"src/pygui/backend/csv_file_metadata.py",
|
||||||
"src/pygui/backend/data_model.py",
|
"src/pygui/backend/data_model.py",
|
||||||
"src/pygui/backend/data_segment.py",
|
"src/pygui/backend/data_segment.py",
|
||||||
@@ -103,7 +104,7 @@ environments = [
|
|||||||
# ===== Ruff Linter and Formatter =====
|
# ===== Ruff Linter and Formatter =====
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
line-length = 100
|
line-length = 125
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
@@ -113,7 +114,11 @@ select = [
|
|||||||
"I", # isort (import sorting)
|
"I", # isort (import sorting)
|
||||||
"N", # pep8-naming
|
"N", # pep8-naming
|
||||||
]
|
]
|
||||||
ignore = []
|
ignore = [
|
||||||
|
"N802", # Function name should be lowercase (standard for Qt Slots and Properties)
|
||||||
|
"N815", # Class attribute should not be mixedCase (standard for Qt Properties and Signals)
|
||||||
|
"E702", # Multiple statements on one line (semicolon, standard for inline Qt property setters with self.update())
|
||||||
|
]
|
||||||
|
|
||||||
# ===== Mypy Static Type Checker =====
|
# ===== Mypy Static Type Checker =====
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: root
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: 600
|
||||||
|
height: 500
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
onOpened: licenseModel.refresh()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: licenseFileDialog
|
||||||
|
title: "Load License"
|
||||||
|
nameFilters: ["License files (*.bin)"]
|
||||||
|
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 28
|
||||||
|
spacing: 20
|
||||||
|
|
||||||
|
// ── Title ──
|
||||||
|
Label {
|
||||||
|
text: "About"
|
||||||
|
font.pixelSize: Theme.font2xl
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App info ──
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "ISenseCloud"
|
||||||
|
font.pixelSize: Theme.fontXl
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Version " + appVersion
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Separator ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── License grid ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumHeight: 150
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.panelBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "LICENSE"
|
||||||
|
color: Theme.panelTitleText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.5
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: loadFailedLabel
|
||||||
|
visible: false
|
||||||
|
text: "Invalid license file"
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid header
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
|
||||||
|
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||||
|
Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||||
|
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.softBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
visible: licenseModel.licenses.length > 0
|
||||||
|
model: licenseModel.licenses
|
||||||
|
delegate: RowLayout {
|
||||||
|
width: ListView.view.width
|
||||||
|
spacing: 8
|
||||||
|
Label { text: modelData.serial; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 150 }
|
||||||
|
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
|
||||||
|
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
|
||||||
|
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
visible: licenseModel.licenses.length === 0
|
||||||
|
text: "No license loaded"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buttons ──
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Load License"
|
||||||
|
Layout.preferredWidth: 140
|
||||||
|
Layout.preferredHeight: 36
|
||||||
|
onClicked: licenseFileDialog.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Close"
|
||||||
|
Layout.preferredWidth: 100
|
||||||
|
Layout.preferredHeight: 36
|
||||||
|
onClicked: root.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
|
||||||
// ===== Home Workspace Shell =====
|
// ===== Home Workspace Shell =====
|
||||||
|
// Unified left rail: pill tab bar → context panel → status footer → utility buttons.
|
||||||
|
// Workspace fills the remaining area with the active tab's content.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -12,272 +17,646 @@ Rectangle {
|
|||||||
border.color: Theme.outerFrameBorder
|
border.color: Theme.outerFrameBorder
|
||||||
border.width: Theme.borderStrong
|
border.width: Theme.borderStrong
|
||||||
|
|
||||||
// ===== Navigation Model =====
|
|
||||||
// Primary navigation shown in the left rail.
|
|
||||||
property var sideActions: ["DETECT WAFER", "READ MEMORY", "OPEN CSV IN EXCEL", "ERASE MEMORY", "IMPORT DATA", "STORED DATA"]
|
|
||||||
|
|
||||||
// ===== Footer Tab Model =====
|
|
||||||
// Footer tabs drive the active workspace section.
|
|
||||||
property var bottomTabs: ["Status", "Graph", "Data", "Wafer Map", "Compare", "Split", "Settings", "About"]
|
|
||||||
|
|
||||||
// ===== View State =====
|
// ===== View State =====
|
||||||
property int selectedTabIndex: 0
|
property int selectedTabIndex: 0
|
||||||
property int selectedSideActionIndex: -1 // nothing active on startup
|
onSelectedTabIndexChanged: {
|
||||||
|
if (selectedTabIndex === 2) {
|
||||||
|
file_browser.refreshFiles();
|
||||||
|
streamController.unloadFile()
|
||||||
|
} else if (streamController.mode === "live") {
|
||||||
|
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||||
|
streamController.setMode("review")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
property bool memoryRead: false
|
property bool memoryRead: false
|
||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
|
// TODO P2.2: add `property bool waferLicensed: false`; set it in
|
||||||
|
// onDetectResult below via deviceController.waferLicensed() (slot from
|
||||||
|
// P1.2 — the detectResult payload is opaque in QML, can't read serial
|
||||||
|
// from it). Then READ/ERASE buttons get
|
||||||
|
// `enabled: root.waferDetected && root.waferLicensed`.
|
||||||
|
// See docs/pending/license-gating-plan.md §2.2.
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onDetectResult(result){
|
function onDetectResult(result){
|
||||||
//result is a waferInfo dict on success, None on failure
|
// detectResult payload is Signal(object): a dict may cross into QML
|
||||||
root.waferDetected = (result != null && result!= undefined)
|
// as an opaque wrapper whose fields read as undefined, so only
|
||||||
|
// test truthiness (None on failure, dict on success).
|
||||||
|
root.waferDetected = !!result
|
||||||
}
|
}
|
||||||
function onParsedDataReady(result) {
|
function onParsedDataReady(result) {
|
||||||
if (result.success && result.csv_path) {
|
if (result.success && result.csv_path) {
|
||||||
// Load the freshly read CSV into the player
|
|
||||||
streamController.loadFile(result.csv_path)
|
streamController.loadFile(result.csv_path)
|
||||||
|
// TODO P3.1: only auto-jump to Map when unlocked:
|
||||||
// Automatically switch to the "Wafer Map Tab" (index 3)
|
// `&& licenseModel.licenses.length > 0` — otherwise a
|
||||||
root.selectedTabIndex = 3
|
// successful read walks straight into the locked tab.
|
||||||
|
// See plan §3.1.
|
||||||
|
if (root.selectedTabIndex === 0) {
|
||||||
|
root.selectedTabIndex = 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onReadResult(result) {
|
function onReadResult(result) {
|
||||||
// result has "success" key on success, "error" key on failure
|
root.memoryRead = (result !== null &&
|
||||||
root.memoryRead = (result !== null &&
|
result !== undefined &&
|
||||||
result !== undefined &&
|
result.success === true)
|
||||||
result.success === true)
|
if (root.memoryRead) {
|
||||||
|
console.log("[P1.1] Memory read complete:", result.bytes, "bytes")
|
||||||
if (root.memoryRead) {
|
} else {
|
||||||
console.log(`[P1.1] Memory read complete: ${result.bytes} bytes`)
|
console.log("[P1.1] Read failed:", result.error || "unknown")
|
||||||
} else {
|
|
||||||
console.log(`[P1.1] Read failed: ${result.error || "unknown"}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Main Two-Column Layout ======
|
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// ===== Left Action Rail =====
|
|
||||||
// Left control rail.
|
|
||||||
Rectangle {
|
|
||||||
id: sideRail
|
|
||||||
Layout.preferredWidth: Theme.sideRailWidth
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.sideRailBackground
|
|
||||||
border.color: Theme.workspaceBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
|
|
||||||
readonly property int actionCount: root.sideActions.length
|
|
||||||
readonly property real computedButtonHeight: Math.min(Theme.sideButtonHeight, (height - (Theme.panelPadding * 2) - (Theme.sideRailSpacing * Math.max(0, actionCount - 1))) / Math.max(1, actionCount))
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: Theme.sideRailSpacing
|
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: root.sideActions
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: control
|
|
||||||
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
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: Math.max(Theme.sideButtonMinHeight, sideRail.computedButtonHeight)
|
|
||||||
hoverEnabled: true
|
|
||||||
onClicked: {
|
|
||||||
root.selectedSideActionIndex = index
|
|
||||||
root.selectedTabIndex = 0 // always jump to Status tab
|
|
||||||
if (index === 0) {
|
|
||||||
root.memoryRead = false
|
|
||||||
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 {
|
|
||||||
color: {
|
|
||||||
if (control.down) {
|
|
||||||
return Theme.buttonPressed;
|
|
||||||
}
|
|
||||||
if (control.isActive) {
|
|
||||||
return Theme.sideActiveBackground;
|
|
||||||
}
|
|
||||||
return "transparent";
|
|
||||||
}
|
|
||||||
border.color: control.isActive ? Theme.cardBorder : "transparent"
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.bottom: parent.bottom
|
|
||||||
width: 3
|
|
||||||
visible: control.isActive
|
|
||||||
color: Theme.primaryAccent
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentItem: Text {
|
|
||||||
text: control.text
|
|
||||||
color: control.isActive ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.bold: control.isActive
|
|
||||||
font.pixelSize: 18
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Main workspace and footer navigation.
|
function cleanFolderUrl(url) {
|
||||||
Rectangle {
|
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
||||||
Layout.fillWidth: true
|
}
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.workspaceBackground
|
function _doDetect() {
|
||||||
border.color: Theme.workspaceBorder
|
root.memoryRead = false
|
||||||
border.width: Theme.borderThin
|
root.waferDetected = false
|
||||||
|
streamController.setMode("review")
|
||||||
|
streamController.stopStream()
|
||||||
|
deviceController.detectWafer()
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderDialog {
|
||||||
|
id: saveDirDialog
|
||||||
|
title: "Choose a folder to save Data"
|
||||||
|
onAccepted: {
|
||||||
|
var dir = root.cleanFolderUrl(selectedFolder)
|
||||||
|
deviceController.setSaveDataDir(dir)
|
||||||
|
file_browser.setCurrentDirectory(dir)
|
||||||
|
root._doDetect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Split Dialog (Threshold Segmentation) =====
|
||||||
|
SplitDialog {
|
||||||
|
id: splitDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Settings Popup =====
|
||||||
|
Popup {
|
||||||
|
id: settingsPopup
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: Math.min(root.width - 80, 800)
|
||||||
|
height: Math.min(root.height - 80, 600)
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Rectangle {
|
||||||
|
color: Theme.pageBackground
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Theme.mainAreaPadding
|
spacing: 0
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
// ===== Active Tab Content Area =====
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.preferredHeight: 40
|
||||||
Layout.minimumHeight: 220
|
color: Theme.panelBackground
|
||||||
color: Theme.responseBackground
|
border.color: Theme.cardBorder
|
||||||
border.color: Theme.responseBorder
|
|
||||||
border.width: 1
|
border.width: 1
|
||||||
radius: Theme.radiusMd
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
StackLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
currentIndex: root.selectedTabIndex
|
|
||||||
|
|
||||||
// ===== Tab Content Routing =====
|
|
||||||
Repeater {
|
|
||||||
model: root.bottomTabs
|
|
||||||
|
|
||||||
Item {
|
|
||||||
property string tabName: modelData
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
anchors.fill: parent
|
|
||||||
active: parent.tabName === "Settings"
|
|
||||||
source: parent.tabName === "Settings" ? "Tabs/SettingsTab.qml" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
anchors.fill: parent
|
|
||||||
active: parent.tabName === "Status"
|
|
||||||
source: parent.tabName === "Status" ? "Tabs/StatusTab.qml" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader {
|
|
||||||
anchors.fill: parent
|
|
||||||
active: parent.tabName === "Data"
|
|
||||||
source: parent.tabName === "Data" ? "Tabs/DataTab.qml" : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
visible: parent.tabName !== "Settings" && parent.tabName !== "Status" && parent.tabName !== "Data" && parent.tabName !== "Wafer Map"
|
|
||||||
text: parent.tabName + " content"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: 20
|
|
||||||
}
|
|
||||||
|
|
||||||
Loader{
|
|
||||||
anchors.fill: parent
|
|
||||||
active: parent.tabName === "Wafer Map"
|
|
||||||
source: parent.tabName === "Wafer Map" ? "Tabs/WaferMapTab.qml" : ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the tab strip evenly distributed across the footer.
|
|
||||||
// ===== Footer Tab Strip =====
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: Theme.tabBarHeight + (Theme.tabBarPadding * 2)
|
|
||||||
color: Theme.tabBarBackground
|
|
||||||
border.color: Theme.workspaceBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Theme.tabBarPadding
|
anchors.leftMargin: 14
|
||||||
spacing: Theme.tabSpacing
|
anchors.rightMargin: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Settings"
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "\u2715"
|
||||||
|
flat: true
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
onClicked: settingsPopup.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.rightMargin: 6
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
anchors.fill: parent
|
||||||
|
source: "Tabs/SettingsTab.qml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== About Dialog =====
|
||||||
|
AboutDialog {
|
||||||
|
id: aboutDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Main Layout: Rail + Workspace =====
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// ── LEFT RAIL ──────────────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
id: rail
|
||||||
|
Layout.preferredWidth: Theme.sideRailWidth
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.sideRailBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.sideRailMargin
|
||||||
|
spacing: Theme.sideRailSpacing
|
||||||
|
|
||||||
|
// ── BOX 1: Pill Tab Bar ─────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
id: pillBar
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sidePillHeight
|
||||||
|
radius: 999
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
ListModel {
|
||||||
|
id: pillModel
|
||||||
|
ListElement {
|
||||||
|
label: "STATUS"
|
||||||
|
icon: "Tabs/icons/status.svg"
|
||||||
|
expandW: 86
|
||||||
|
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "DATA"
|
||||||
|
icon: "Tabs/icons/data.svg"
|
||||||
|
expandW: 66
|
||||||
|
desc: "Manage, review, compare (DTW), and split historical sensor data records."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "MAP"
|
||||||
|
icon: "Tabs/icons/map.svg"
|
||||||
|
expandW: 58
|
||||||
|
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
|
||||||
|
}
|
||||||
|
ListElement {
|
||||||
|
label: "GRAPH"
|
||||||
|
icon: "Tabs/icons/bar-chart.svg"
|
||||||
|
expandW: 70
|
||||||
|
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 3
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.bottomTabs
|
model: pillModel
|
||||||
|
delegate: Button {
|
||||||
Button {
|
id: pillBtn
|
||||||
id: botTabBtn
|
checked: root.selectedTabIndex === index
|
||||||
property bool isActive: index === root.selectedTabIndex
|
flat: true
|
||||||
|
|
||||||
text: modelData
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: Theme.tabBarHeight
|
Layout.fillHeight: true
|
||||||
Layout.minimumWidth: Theme.tabButtonMinWidth
|
Layout.preferredWidth: pillBtn.checked ? model.expandW : 44
|
||||||
onClicked: root.selectedTabIndex = index
|
Behavior on Layout.preferredWidth {
|
||||||
hoverEnabled: true
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
color: botTabBtn.isActive ? Theme.tabActiveBackground : (botTabBtn.hovered ? Theme.tabHoverBackground : Theme.tabBackground)
|
|
||||||
border.color: Theme.tabBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.tabRadius
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contentItem: Text {
|
background: Rectangle {
|
||||||
text: botTabBtn.text
|
radius: 50
|
||||||
color: botTabBtn.isActive ? Theme.tabActiveText : Theme.tabText
|
color: pillBtn.checked ? Theme.sidePanelBackground : "transparent"
|
||||||
font.pixelSize: Theme.tabFontSize
|
Behavior on color {
|
||||||
font.bold: botTabBtn.isActive
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
horizontalAlignment: Text.AlignHCenter
|
}
|
||||||
verticalAlignment: Text.AlignVCenter
|
}
|
||||||
elide: Text.ElideRight
|
|
||||||
|
contentItem: Row {
|
||||||
|
id: pillContent
|
||||||
|
spacing: pillBtn.checked ? 6 : 0
|
||||||
|
width: pillIcon.width + (pillBtn.checked ? pillContent.spacing + pillLabel.width : 0)
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: (parent.width - width) / 2
|
||||||
|
Behavior on x {
|
||||||
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
id: pillIcon
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16; height: 16
|
||||||
|
source: model.icon
|
||||||
|
sourceSize.width: 16; sourceSize.height: 16
|
||||||
|
color: pillBtn.checked ? Theme.headingColor
|
||||||
|
: pillBtn.hovered ? Qt.lighter(Theme.sideMutedText, 1.7)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
opacity: pillBtn.checked ? 0.9 : pillBtn.hovered ? 0.7 : 0.4
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: pillLabel
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: pillBtn.checked ? implicitWidth : 0
|
||||||
|
text: model.label
|
||||||
|
color: pillBtn.checked ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
opacity: pillBtn.checked ? 1.0 : 0.0
|
||||||
|
clip: true
|
||||||
|
Behavior on width {
|
||||||
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: pillBtn.hovered
|
||||||
|
text: "<b>" + model.label + "</b>: " + model.desc
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: root.selectedTabIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
|
||||||
|
Rectangle {
|
||||||
|
id: runComparisonBox
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: runCompCol.implicitHeight + 28
|
||||||
|
visible: root.selectedTabIndex === 1
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
readonly property var dataTab: dataTabLoader.item
|
||||||
|
|
||||||
|
component RunSlot: Rectangle {
|
||||||
|
id: slotBox
|
||||||
|
property string title
|
||||||
|
property string filePath: ""
|
||||||
|
property color accent: Theme.primaryAccent
|
||||||
|
property bool active: false
|
||||||
|
property bool locked: false
|
||||||
|
property string placeholderText: "Click to select"
|
||||||
|
signal cleared()
|
||||||
|
signal armed()
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 58
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
opacity: slotBox.locked ? 0.65 : 1.0
|
||||||
|
border.width: slotBox.active ? 2 : 1
|
||||||
|
border.color: slotBox.active ? accent : Theme.sideBorder
|
||||||
|
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !slotBox.locked
|
||||||
|
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||||
|
onClicked: slotBox.armed()
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 8; height: 8; radius: 4
|
||||||
|
color: slotBox.accent
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: slotBox.title
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: slotBox.filePath !== ""
|
||||||
|
? slotBox.filePath.split("/").pop()
|
||||||
|
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||||
|
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
visible: slotBox.filePath !== "" && !slotBox.locked
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 24; implicitHeight: 24
|
||||||
|
onClicked: slotBox.cleared()
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "Tabs/icons/x.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.bodyColor
|
||||||
|
anchors.centerIn: parent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: runCompCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "RUN COMPARISON (DTW)"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: resetRunBtn
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 32
|
||||||
|
implicitHeight: 32
|
||||||
|
visible: true
|
||||||
|
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "Tabs/icons/rotate-ccw.svg"
|
||||||
|
width: 18; height: 18
|
||||||
|
sourceSize.width: 18
|
||||||
|
sourceSize.height: 18
|
||||||
|
color: Theme.bodyColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
AppToolTip {
|
||||||
|
visible: resetRunBtn.hovered
|
||||||
|
text: "Reset comparison"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: useMasterCheck
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Compare vs Master File"
|
||||||
|
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||||
|
onToggled: {
|
||||||
|
if (!runComparisonBox.dataTab) return
|
||||||
|
runComparisonBox.dataTab.useMasterFile = checked
|
||||||
|
if (checked) runComparisonBox.dataTab.activeBox = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RunSlot {
|
||||||
|
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||||
|
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
|
||||||
|
accent: Theme.primaryAccent
|
||||||
|
locked: useMaster
|
||||||
|
placeholderText: useMaster
|
||||||
|
? (runComparisonBox.dataTab.runBWaferType !== ""
|
||||||
|
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
|
||||||
|
: "Select Run B to resolve master")
|
||||||
|
: "Click to select"
|
||||||
|
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
|
||||||
|
filePath: runComparisonBox.dataTab
|
||||||
|
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
|
||||||
|
: ""
|
||||||
|
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
|
||||||
|
onCleared: {
|
||||||
|
if (!runComparisonBox.dataTab) return
|
||||||
|
runComparisonBox.dataTab.compareFileA = ""
|
||||||
|
runComparisonBox.dataTab.activeBox = 1
|
||||||
|
runComparisonBox.dataTab.clearResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RunSlot {
|
||||||
|
title: "SESSION (RUN B)"
|
||||||
|
accent: Theme.themeSkill
|
||||||
|
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
|
||||||
|
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
|
||||||
|
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
|
||||||
|
onCleared: {
|
||||||
|
if (!runComparisonBox.dataTab) return
|
||||||
|
runComparisonBox.dataTab.compareFileB = ""
|
||||||
|
runComparisonBox.dataTab.activeBox = 2
|
||||||
|
runComparisonBox.dataTab.clearResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: compareRunBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
readonly property var dt: runComparisonBox.dataTab
|
||||||
|
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
||||||
|
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
||||||
|
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
||||||
|
readonly property string gateError: !!dt ? dt.familyGateError : ""
|
||||||
|
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
|
||||||
|
AppToolTip {
|
||||||
|
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
|
||||||
|
text: compareRunBtn.masterMissing
|
||||||
|
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||||
|
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
|
||||||
|
: "Choose two different runs to compare"
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
dt.comparing = true
|
||||||
|
dt.clearResults()
|
||||||
|
streamController.compareFiles(fileA, dt.compareFileB)
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: compareRunBtn.enabled
|
||||||
|
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||||
|
: Theme.buttonNeutralBackground
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
}
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 8
|
||||||
|
anchors.centerIn: parent
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: !compareRunBtn.dt ? "Select both runs"
|
||||||
|
: compareRunBtn.dt.comparing
|
||||||
|
? "Comparing…"
|
||||||
|
: compareRunBtn.enabled
|
||||||
|
? "Run DTW Comparison"
|
||||||
|
: (compareRunBtn.masterMissing ? "No master for this type"
|
||||||
|
: compareRunBtn.sameFile ? "Choose different runs"
|
||||||
|
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
|
||||||
|
: "Select both runs")
|
||||||
|
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
BusyIndicator {
|
||||||
|
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||||
|
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||||
|
width: 14; height: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||||
|
StackLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumHeight: 120
|
||||||
|
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||||
|
|
||||||
|
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||||
|
StatusActionsPanel {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map tab: Source file browser ──────────────
|
||||||
|
Rectangle {
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
SourcePanel {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||||
|
ConnectionFooter {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||||
|
UtilityFooter {
|
||||||
|
id: utilFooter
|
||||||
|
Layout.fillWidth: true
|
||||||
|
settingsPopup: settingsPopup
|
||||||
|
aboutDialog: aboutDialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WORKSPACE ─────────────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.workspaceBackground
|
||||||
|
|
||||||
|
StackLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.mainAreaPadding
|
||||||
|
currentIndex: root.selectedTabIndex
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/StatusTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
|
Loader {
|
||||||
|
id: dataTabLoader
|
||||||
|
source: "Tabs/DataTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/WaferMapTab.qml"
|
||||||
|
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
|
||||||
|
// so the tab never instantiates while locked. See plan §3.1.
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
|
}
|
||||||
|
Loader {
|
||||||
|
source: "Tabs/GraphTab.qml"
|
||||||
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ISC
|
|||||||
Window {
|
Window {
|
||||||
// ===== Window Dimensions =====
|
// ===== Window Dimensions =====
|
||||||
width: 1920
|
width: 1920
|
||||||
height: 1080
|
height: 1080
|
||||||
minimumWidth: 1100
|
minimumWidth: 1100
|
||||||
minimumHeight: 700
|
minimumHeight: 700
|
||||||
visible: true
|
visible: true
|
||||||
|
|||||||
@@ -1,151 +1,373 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
// ===== Data Tab =====
|
// ===== Data Tab (Compare Content Only) =====
|
||||||
// Displays parsed temperature data in a scrollable table.
|
// Per docs/design/navigation-mock.html: the Data tab shows only the Compare
|
||||||
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ...
|
// Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts)
|
||||||
|
// is intentionally not displayed in the application.
|
||||||
|
// Layout: left column = aligned-curves chart with alignment scrubber + wafer
|
||||||
|
// overlap map; right side panel = run selection dropdowns, overlap settings,
|
||||||
|
// and DTW readout cards.
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
readonly property int rowCount: deviceController.dataRowCount
|
// Properties for DTW Comparison
|
||||||
|
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
|
||||||
|
property string compareFileA: ""
|
||||||
|
property string compareFileB: ""
|
||||||
|
property bool useMasterFile: false // Run A comes from settingsModel.masters[runBWaferType] instead of manual selection
|
||||||
|
|
||||||
// Re-layout columns whenever the model is reset
|
function waferTypeForFile(path) {
|
||||||
|
if (!path) return ""
|
||||||
|
// A file registered as a master carries that slot's family — this is
|
||||||
|
// the only way a live recording gets a family (its filename/metadata
|
||||||
|
// don't encode one reliably).
|
||||||
|
for (var fam in settingsModel.masters)
|
||||||
|
if (settingsModel.masters[fam] === path) return fam.toUpperCase()
|
||||||
|
var rows = file_browser.files
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
if (rows[i].fileName === path) {
|
||||||
|
if (rows[i].isRecording) return (rows[i].masterType || "").toUpperCase()
|
||||||
|
return (rows[i].waferType || "").toUpperCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var base = path.split("/").pop()
|
||||||
|
if (base.toLowerCase().indexOf("live_") === 0) return ""
|
||||||
|
return base ? base.charAt(0).toUpperCase() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master file must match Run B's wafer family — looking it up by that
|
||||||
|
// family is itself the comparability gate (a mismatched-family master
|
||||||
|
// simply won't exist under this key).
|
||||||
|
readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB)
|
||||||
|
readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : ""
|
||||||
|
readonly property string runAWaferType: root.useMasterFile ? root.runBWaferType : root.waferTypeForFile(root.compareFileA)
|
||||||
|
// Family gate: both runs must resolve to the same wafer family. An empty
|
||||||
|
// family means an unassigned live recording — comparable only after it is
|
||||||
|
// registered as a master file (Settings → Master Files).
|
||||||
|
readonly property string familyGateError: {
|
||||||
|
var fileA = root.useMasterFile ? root.masterFileForRunB : root.compareFileA
|
||||||
|
if (fileA === "" || root.compareFileB === "") return ""
|
||||||
|
if (root.runAWaferType === "" || root.runBWaferType === "")
|
||||||
|
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
|
||||||
|
if (root.runAWaferType !== root.runBWaferType)
|
||||||
|
return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
property real warpingDistance: -1
|
||||||
|
property int frameOffset: 0
|
||||||
|
property real frameOffsetSeconds: 0
|
||||||
|
property real maxSensorDeviation: -1
|
||||||
|
property bool comparing: false
|
||||||
|
property var trendDataA: []
|
||||||
|
property var trendDataB: []
|
||||||
|
property var timeDataA: []
|
||||||
|
property var timeDataB: []
|
||||||
|
property int frameCountA: 0
|
||||||
|
property int frameCountB: 0
|
||||||
|
property string errorMessage: ""
|
||||||
|
property string mismatchMessage: ""
|
||||||
|
|
||||||
|
// Wafer overlap properties
|
||||||
|
property var compareSensorLayout: []
|
||||||
|
property var compareSensorDiff: []
|
||||||
|
property string compareWaferShape: "round"
|
||||||
|
property real compareWaferSize: 300.0
|
||||||
|
|
||||||
|
readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1
|
||||||
|
readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length)
|
||||||
|
|
||||||
|
function diffBands(diffs) {
|
||||||
|
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortName(path) {
|
||||||
|
return path ? path.split("/").pop() : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrubTimeLabel(idx) {
|
||||||
|
var t = idx < root.timeDataA.length ? root.timeDataA[idx]
|
||||||
|
: (idx < root.timeDataB.length ? root.timeDataB[idx] : null)
|
||||||
|
return t !== null ? " (" + t.toFixed(1) + "s)" : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(secondsValue) {
|
||||||
|
var mins = Math.floor(secondsValue / 60)
|
||||||
|
var secs = secondsValue % 60
|
||||||
|
return mins + ":" + (secs < 10 ? "0" : "") + secs.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameForTime(t) {
|
||||||
|
var times = root.timeDataA.length > 0 ? root.timeDataA : root.timeDataB
|
||||||
|
if (times.length === 0) return 0
|
||||||
|
var closestIdx = 0
|
||||||
|
var minDiff = Math.abs(times[0] - t)
|
||||||
|
for (var i = 1; i < times.length; i++) {
|
||||||
|
var diff = Math.abs(times[i] - t)
|
||||||
|
if (diff < minDiff) {
|
||||||
|
minDiff = diff
|
||||||
|
closestIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closestIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
root.warpingDistance = -1
|
||||||
|
root.frameOffset = 0
|
||||||
|
root.frameOffsetSeconds = 0
|
||||||
|
root.maxSensorDeviation = -1
|
||||||
|
root.trendDataA = []
|
||||||
|
root.trendDataB = []
|
||||||
|
root.timeDataA = []
|
||||||
|
root.timeDataB = []
|
||||||
|
root.frameCountA = 0
|
||||||
|
root.frameCountB = 0
|
||||||
|
root.compareSensorLayout = []
|
||||||
|
root.compareSensorDiff = []
|
||||||
|
root.errorMessage = ""
|
||||||
|
root.mismatchMessage = ""
|
||||||
|
mismatchHideTimer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetComparison() {
|
||||||
|
root.compareFileA = ""
|
||||||
|
root.compareFileB = ""
|
||||||
|
root.useMasterFile = false
|
||||||
|
root.comparing = false
|
||||||
|
root.activeBox = 1
|
||||||
|
clearResults()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: file_browser.refreshFiles()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: mismatchHideTimer
|
||||||
|
interval: 6000
|
||||||
|
onTriggered: root.mismatchMessage = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clicking a file in the left rail's browser populates the armed run slot
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: streamController
|
||||||
function onParsedDataReady(result) {
|
function onLoadedFileChanged() {
|
||||||
|
if (streamController.loadedFile && root.activeBox > 0) {
|
||||||
|
if (root.activeBox === 1) {
|
||||||
|
root.compareFileA = streamController.loadedFile
|
||||||
|
root.activeBox = 2 // Auto-advance to box 2
|
||||||
|
} else if (root.activeBox === 2) {
|
||||||
|
root.compareFileB = streamController.loadedFile
|
||||||
|
root.activeBox = 0 // Finished selecting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onComparisonResult(result) {
|
||||||
|
root.comparing = false
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
dataTable.forceLayout()
|
root.warpingDistance = result.distance
|
||||||
|
root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0
|
||||||
|
root.frameOffsetSeconds = result.frame_offset_seconds !== undefined ? result.frame_offset_seconds : 0
|
||||||
|
root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
|
||||||
|
root.trendDataA = result.series_a || []
|
||||||
|
root.trendDataB = result.series_b || []
|
||||||
|
root.timeDataA = result.time_a || []
|
||||||
|
root.timeDataB = result.time_b || []
|
||||||
|
root.frameCountA = result.frame_count_a || 0
|
||||||
|
root.frameCountB = result.frame_count_b || 0
|
||||||
|
root.compareSensorLayout = result.sensor_layout || []
|
||||||
|
root.compareSensorDiff = result.sensor_diff || []
|
||||||
|
root.compareWaferShape = result.wafer_shape || "round"
|
||||||
|
root.compareWaferSize = result.wafer_size || 300.0
|
||||||
|
root.errorMessage = ""
|
||||||
|
chartCard.frameIndex = Math.floor(root.seriesLen / 2)
|
||||||
|
chartCard.focusScrubber()
|
||||||
|
if (root.frameCountA !== root.frameCountB) {
|
||||||
|
var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
|
||||||
|
var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
|
||||||
|
root.mismatchMessage = "Run A: " + root.frameCountA + " frames (" + timeA.toFixed(1) + "s) · "
|
||||||
|
+ "Run B: " + root.frameCountB + " frames (" + timeB.toFixed(1) + "s). "
|
||||||
|
+ "Deviation beyond the shorter run shows as \"none\"."
|
||||||
|
mismatchHideTimer.restart()
|
||||||
|
} else {
|
||||||
|
root.mismatchMessage = ""
|
||||||
|
mismatchHideTimer.stop()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root.clearResults()
|
||||||
|
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Empty State =====
|
// ── Main Layout ───────────────────────────────────────────────
|
||||||
Column {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: 16
|
|
||||||
visible: root.rowCount === 0
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "No Data"
|
|
||||||
font.pixelSize: 24
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.headingColor
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Read and parse wafer data to see temperature readings here."
|
|
||||||
font.pixelSize: 14
|
|
||||||
color: Theme.bodyColor
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Data Table =====
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Theme.panelPadding
|
anchors.margins: Theme.panelPadding
|
||||||
spacing: Theme.rightPaneGap
|
spacing: Theme.rightPaneGap
|
||||||
visible: root.rowCount > 0
|
|
||||||
|
|
||||||
// --- Header row ---
|
|
||||||
RowLayout {
|
|
||||||
|
// ── Error Banner ───────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: Theme.rightPaneGap
|
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||||
|
visible: root.errorMessage !== ""
|
||||||
|
color: Theme.errorSurface
|
||||||
|
border.color: Theme.statusErrorColor
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
Label {
|
RowLayout {
|
||||||
text: "Temperature Data"
|
id: errorRow
|
||||||
font.pixelSize: 18
|
anchors.fill: parent
|
||||||
font.bold: true
|
anchors.margins: 8
|
||||||
color: Theme.headingColor
|
spacing: 8
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
IconImage {
|
||||||
|
source: "icons/triangle-alert.svg"
|
||||||
|
width: 16; height: 16
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.errorTextSoft
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: deviceController.dataRowCount + " rows × " +
|
id: errorLabel
|
||||||
deviceController.dataColCount + " sensors"
|
text: root.errorMessage
|
||||||
font.pixelSize: 13
|
color: Theme.errorTextSoft
|
||||||
color: Theme.bodyColor
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Table container ---
|
// ── Body: main column | side panel ─────────────────────────
|
||||||
Rectangle {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.panelBackground
|
spacing: 16
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
|
// --- Left Column: chart + scrubber, wafer overlap map ---
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
Layout.fillWidth: true
|
||||||
anchors.margins: 1
|
Layout.fillHeight: true
|
||||||
spacing: 0
|
Layout.minimumWidth: 350
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
// Column header strip
|
// Wafer Map Overlap View card
|
||||||
HorizontalHeaderView {
|
OverlapMapCard {
|
||||||
id: headerView
|
|
||||||
Layout.fillWidth: true
|
|
||||||
syncView: dataTable
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
|
||||||
implicitHeight: 28
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
// 'display' is the Qt.DisplayRole value from headerData()
|
|
||||||
text: display !== undefined ? display : ""
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: 11
|
|
||||||
font.bold: true
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
TableView {
|
|
||||||
id: dataTable
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
model: deviceController.dataModel
|
Layout.minimumHeight: 220
|
||||||
clip: true
|
sensorLayout: root.compareSensorLayout
|
||||||
reuseItems: true
|
diffValues: streamController.getSensorDiffAt(Math.round(chartCard.frameIndex))
|
||||||
|
diffBands: root.diffBands
|
||||||
|
waferShape: root.compareWaferShape
|
||||||
|
waferSize: root.compareWaferSize
|
||||||
|
overlapEnabled: sidePanel.overlapEnabled
|
||||||
|
blendAmount: sidePanel.blendAmount
|
||||||
|
}
|
||||||
|
|
||||||
columnWidthProvider: function(col) {
|
// Chart card with alignment scrubber
|
||||||
if (col === 0) return 52 // row index column
|
ComparisonChartCard {
|
||||||
const sensorCols = Math.max(1, dataTable.columns - 1)
|
id: chartCard
|
||||||
const available = dataTable.width - 52
|
Layout.fillWidth: true
|
||||||
return Math.max(52, Math.min(90, available / sensorCols))
|
trendDataA: root.trendDataA
|
||||||
}
|
trendDataB: root.trendDataB
|
||||||
|
seriesLen: root.seriesLen
|
||||||
|
hasSeries: root.hasSeries
|
||||||
|
comparing: root.comparing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rowHeightProvider: function() { return 24 }
|
// --- Right Column: run selection, overlap settings, readout ---
|
||||||
|
ComparisonSidePanel {
|
||||||
|
id: sidePanel
|
||||||
|
Layout.fillWidth: false
|
||||||
|
Layout.preferredWidth: Theme.rightRailWidth
|
||||||
|
Layout.minimumWidth: Theme.rightRailWidth
|
||||||
|
Layout.maximumWidth: Theme.rightRailWidth
|
||||||
|
Layout.fillHeight: true
|
||||||
|
warpingDistance: root.warpingDistance
|
||||||
|
frameOffset: root.frameOffset
|
||||||
|
frameOffsetSeconds: root.frameOffsetSeconds
|
||||||
|
maxSensorDeviation: root.maxSensorDeviation
|
||||||
|
seriesLen: root.seriesLen
|
||||||
|
hasSeries: root.hasSeries
|
||||||
|
trendDataA: root.trendDataA
|
||||||
|
trendDataB: root.trendDataB
|
||||||
|
timeDataA: root.timeDataA
|
||||||
|
timeDataB: root.timeDataB
|
||||||
|
frameForTime: root.frameForTime
|
||||||
|
frameIndex: chartCard.frameIndex
|
||||||
|
onFrameIndexRequested: (idx) => chartCard.frameIndex = idx
|
||||||
|
onScrubberFocusRequested: chartCard.focusScrubber()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
delegate: Rectangle {
|
// ── Frame Mismatch Banner (amber, floats over content, auto-hides) ──
|
||||||
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
|
Rectangle {
|
||||||
|
id: mismatchBanner
|
||||||
|
visible: root.mismatchMessage !== ""
|
||||||
|
z: 100
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.topMargin: 12
|
||||||
|
width: Math.min(parent.width - 48, 640)
|
||||||
|
height: mismatchRow.implicitHeight + 16
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.statusWarningColor
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
Text {
|
RowLayout {
|
||||||
anchors.centerIn: parent
|
id: mismatchRow
|
||||||
text: display !== undefined ? display : ""
|
anchors.fill: parent
|
||||||
color: Theme.bodyColor
|
anchors.margins: 8
|
||||||
font.pixelSize: 11
|
spacing: 8
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
|
IconImage {
|
||||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
source: "icons/triangle-alert.svg"
|
||||||
|
width: 16; height: 16
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: root.mismatchMessage
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 24
|
||||||
|
implicitHeight: 24
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
|
onClicked: {
|
||||||
|
root.mismatchMessage = ""
|
||||||
|
mismatchHideTimer.stop()
|
||||||
|
}
|
||||||
|
background: Rectangle { color: "transparent" }
|
||||||
|
contentItem: Label {
|
||||||
|
text: "✕"
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
|
// ===== Graph Tab =====
|
||||||
|
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
|
||||||
|
// snapshot recomputed whenever a file is loaded — not live-updating during
|
||||||
|
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
|
||||||
|
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
|
||||||
|
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!streamController.loadedFile) {
|
||||||
|
chart.seriesData = []
|
||||||
|
chart.sensorNames = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chart.sensorNames = streamController.graphSensorNames
|
||||||
|
? streamController.graphSensorNames.split(",") : []
|
||||||
|
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onLoadedFileChanged() { root.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
RunChart {
|
||||||
|
id: chart
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
title: "Sensor Temperature Over Time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Dialogs
|
import QtQuick.Dialogs
|
||||||
import ISC
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -27,8 +29,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Settings Data Helpers =====
|
// ===== Settings Data Helpers =====
|
||||||
readonly property var masterFamilies: ["A", "B", "C", "D", "E", "F", "P", "X", "Z"]
|
// ListModel for master families defined inline in the ColumnLayout below
|
||||||
|
|
||||||
function masterPath(family) {
|
function masterPath(family) {
|
||||||
const table = settingsModel.masters;
|
const table = settingsModel.masters;
|
||||||
return table && table[family] ? table[family] : "";
|
return table && table[family] ? table[family] : "";
|
||||||
@@ -58,7 +59,7 @@ Item {
|
|||||||
label: Label {
|
label: Label {
|
||||||
text: settingsGroup.title
|
text: settingsGroup.title
|
||||||
color: Theme.panelTitleText
|
color: Theme.panelTitleText
|
||||||
font.pixelSize: 16
|
font.pixelSize: Theme.fontLg
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,12 @@ Item {
|
|||||||
color: Theme.fieldBackground
|
color: Theme.fieldBackground
|
||||||
border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
border.width: textInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
border.color: textInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +118,24 @@ Item {
|
|||||||
border.width: Theme.borderThin
|
border.width: Theme.borderThin
|
||||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||||
|
|
||||||
Text {
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "✓"
|
source: "icons/check.svg"
|
||||||
font.pixelSize: 14
|
width: 12; height: 12
|
||||||
font.bold: true
|
sourceSize.width: 12
|
||||||
|
sourceSize.height: 12
|
||||||
color: Theme.panelBackground
|
color: Theme.panelBackground
|
||||||
visible: toggle.checked
|
opacity: toggle.checked ? 1.0 : 0.0
|
||||||
// Small nudge to center the checkmark perfectly
|
Behavior on opacity {
|
||||||
verticalAlignment: Text.AlignVCenter
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
horizontalAlignment: Text.AlignHCenter
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +144,7 @@ Item {
|
|||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
leftPadding: toggle.indicator.width + toggle.spacing
|
leftPadding: toggle.indicator.width + toggle.spacing
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +174,7 @@ Item {
|
|||||||
anchors.top: settingsScroll.top
|
anchors.top: settingsScroll.top
|
||||||
anchors.right: settingsScroll.right
|
anchors.right: settingsScroll.right
|
||||||
anchors.bottom: settingsScroll.bottom
|
anchors.bottom: settingsScroll.bottom
|
||||||
|
anchors.leftMargin: 6
|
||||||
policy: ScrollBar.AsNeeded
|
policy: ScrollBar.AsNeeded
|
||||||
|
|
||||||
contentItem: Rectangle {
|
contentItem: Rectangle {
|
||||||
@@ -174,16 +190,19 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gap reserved between the centered content and the right-edge scrollbar.
|
||||||
|
readonly property int scrollGap: 100
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: container
|
id: container
|
||||||
width: Math.min(settingsScroll.availableWidth, Theme.settingsPanelMaxWidth)
|
width: Math.min(settingsScroll.availableWidth - settingsScroll.scrollGap, Theme.settingsPanelMaxWidth)
|
||||||
x: (settingsScroll.availableWidth - width) / 2
|
x: (settingsScroll.availableWidth - width) / 2
|
||||||
spacing: Theme.settingsSectionSpacing
|
spacing: Theme.settingsSectionSpacing
|
||||||
|
|
||||||
// ===== Page Title =====
|
// ===== Page Title =====
|
||||||
Label {
|
Label {
|
||||||
text: "Settings"
|
text: "Settings"
|
||||||
font.pixelSize: 30
|
font.pixelSize: Theme.font3xl
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
@@ -225,7 +244,7 @@ Item {
|
|||||||
Layout.preferredWidth: 90
|
Layout.preferredWidth: 90
|
||||||
Layout.preferredHeight: Theme.settingsButtonHeight
|
Layout.preferredHeight: Theme.settingsButtonHeight
|
||||||
onClicked: {
|
onClicked: {
|
||||||
settingsModel.setChamberId(chamberField.text);
|
settingsModel.ChamberId = chamberField.text;
|
||||||
settingsModel.saveSettings();
|
settingsModel.saveSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,11 +260,24 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: Theme.settingsGridSpacing
|
spacing: Theme.settingsGridSpacing
|
||||||
|
|
||||||
|
ListModel {
|
||||||
|
id: masterModel
|
||||||
|
ListElement { letter: "A" }
|
||||||
|
ListElement { letter: "B" }
|
||||||
|
ListElement { letter: "C" }
|
||||||
|
ListElement { letter: "D" }
|
||||||
|
ListElement { letter: "E" }
|
||||||
|
ListElement { letter: "F" }
|
||||||
|
ListElement { letter: "P" }
|
||||||
|
ListElement { letter: "X" }
|
||||||
|
ListElement { letter: "Z" }
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.masterFamilies
|
model: masterModel
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
property string family: modelData
|
property string family: model.letter
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: Theme.settingsRowSpacing
|
spacing: Theme.settingsRowSpacing
|
||||||
@@ -296,7 +328,7 @@ Item {
|
|||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
text: "Reverse Z Wafer"
|
text: "Reverse Z Wafer"
|
||||||
checked: settingsModel.reverseZWafer
|
checked: settingsModel.reverseZWafer
|
||||||
onToggled: settingsModel.setReverseZWafer(checked)
|
onToggled: settingsModel.reverseZWafer = checked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +366,7 @@ Item {
|
|||||||
width: 22
|
width: 22
|
||||||
height: 22
|
height: 22
|
||||||
radius: height / 2
|
radius: height / 2
|
||||||
color: "white"
|
color: Theme.toggleThumb
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
x: Theme.isDarkMode ? parent.width - width - 3 : 3
|
x: Theme.isDarkMode ? parent.width - width - 3 : 3
|
||||||
|
|
||||||
@@ -361,7 +393,7 @@ Item {
|
|||||||
Label {
|
Label {
|
||||||
text: Theme.isDarkMode ? "Dark" : "Light"
|
text: Theme.isDarkMode ? "Dark" : "Light"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
@@ -420,12 +452,9 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Row {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "↓ Scroll for more"
|
spacing: 4
|
||||||
font.pixelSize: 13
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.bodyColor
|
|
||||||
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
|
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
@@ -433,6 +462,22 @@ Item {
|
|||||||
duration: 400
|
duration: 400
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
source: "icons/chevron-down.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Scroll for more"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,306 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Dialogs
|
import QtQuick.Dialogs
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Status Tab =====
|
// ===== Status Tab — Bento Grid Dashboard =====
|
||||||
// Blank on startup. Fills in after Detect Wafer fires from the left rail.
|
// ROW 1: Connection Status (65%) | Cycles Completed (35%)
|
||||||
|
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
||||||
|
// ROW 3: Activity Log (100%, fills remaining)
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Theme.panelPadding
|
anchors.margins: Theme.panelPadding
|
||||||
spacing: Theme.rightPaneGap
|
spacing: 8
|
||||||
|
|
||||||
property alias waferFamilyCode: waferInfoFamily.text
|
// ── Reusable Inline Components ──
|
||||||
property alias waferSerialNumber: waferSerial.text
|
component StatCard : Rectangle {
|
||||||
property alias waferSensorCount: waferSensors.text
|
property alias value: valueText.text
|
||||||
property alias waferRuntime: waferRuntime.text
|
property alias label: labelText.text
|
||||||
property alias waferCycles: waferCycles.text
|
property bool active: root.waferDetected
|
||||||
property bool waferDetected: false
|
property int valSize: active ? Theme.font3xl : Theme.font2xl
|
||||||
|
|
||||||
// Latches true once any operation starts (or a previous session is
|
Layout.fillWidth: true
|
||||||
// restored) and never resets, so the status panel stays visible even
|
Layout.fillHeight: true
|
||||||
// when a detect finds no wafer.
|
color: Theme.cardBackground
|
||||||
property bool statusActive: false
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: valueText
|
||||||
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
font.pixelSize: valSize
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.fillHeight: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
fontSizeMode: Text.HorizontalFit
|
||||||
|
minimumPixelSize: Theme.fontMd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stacked stat block: small label over big value, centered in its grid
|
||||||
|
// quadrant so the card's height is used instead of leaving dead space.
|
||||||
|
component GridCell : ColumnLayout {
|
||||||
|
property alias label: labelText.text
|
||||||
|
property alias value: valueText.text
|
||||||
|
property bool active: root.waferDetected
|
||||||
|
|
||||||
|
spacing: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: valueText
|
||||||
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXl
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aliases for external wiring ──
|
||||||
|
property alias waferFamilyCode: waferInfoFamilyCell.value
|
||||||
|
property alias waferSerialNumber: waferSerialCell.value
|
||||||
|
property alias waferSensorCount: waferSensorsCell.value
|
||||||
|
property alias waferRuntime: runtimeCard.value
|
||||||
|
property alias waferCycles: waferCyclesCell.value
|
||||||
|
property bool waferDetected: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return info && info.length > 0 && info[0] !== "";
|
||||||
|
}
|
||||||
|
|
||||||
// Data summary after parse
|
|
||||||
property int dataRows: 0
|
property int dataRows: 0
|
||||||
property int dataCols: 0
|
property int dataCols: 0
|
||||||
property string csvPath: ""
|
property string csvPath: ""
|
||||||
property bool dataParsed: false
|
property bool dataParsed: false
|
||||||
|
|
||||||
|
readonly property bool isConnected: deviceController.connectionStatus === "Connected"
|
||||||
|
readonly property bool isBusy: deviceController.connectionStatus.endsWith("...")
|
||||||
|
readonly property string portName: deviceController.selectedPort || "—"
|
||||||
|
|
||||||
function cleanFolderUrl(url) {
|
function cleanFolderUrl(url) {
|
||||||
return decodeURIComponent(String(url).replace(/^file:\/\//, ""))
|
return decodeURIComponent(String(url).replace(/^file:\/\//, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentFamilyCode() {
|
function currentFamilyCode() {
|
||||||
var info = deviceController.lastWaferInfo
|
var info = deviceController.lastWaferInfo;
|
||||||
return info && info.length > 0 ? (info[0] || "") : ""
|
return info && info.length > 0 ? (info[0] || "") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAndSavePendingRead() {
|
function parseAndSavePendingRead() {
|
||||||
deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "")
|
deviceController.parseAndSaveData(root.currentFamilyCode(), deviceController.selectedPort || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
FolderDialog {
|
FolderDialog {
|
||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose CSV Save Folder"
|
title: "Choose a folder to save."
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
root.parseAndSavePendingRead()
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
|
root.parseAndSavePendingRead();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Empty state — nothing shown until detect fires =====
|
// Directory-only picker for the DIRECTORY card button — sets the save dir
|
||||||
Item {
|
// without kicking off a parse/save of pending read data.
|
||||||
Layout.fillWidth: true
|
FolderDialog {
|
||||||
Layout.fillHeight: true
|
id: dirOnlyDialog
|
||||||
visible: !root.statusActive
|
title: "Choose a folder to save Data"
|
||||||
|
onAccepted: {
|
||||||
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Results area (shown once detect/operation runs) =====
|
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
||||||
ColumnLayout {
|
// Cancel keeps the CSV, skips the sidecar — matching C# behavior loosely.
|
||||||
Layout.fillWidth: true
|
Dialog {
|
||||||
Layout.fillHeight: true
|
id: editCsvDialog
|
||||||
visible: root.statusActive
|
parent: Overlay.overlay
|
||||||
spacing: Theme.rightPaneGap
|
modal: true
|
||||||
|
title: "Edit CSV Metadata"
|
||||||
|
width: 460
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Save | Dialog.Cancel
|
||||||
|
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
|
||||||
|
|
||||||
// --- Connection Status ---
|
ColumnLayout {
|
||||||
Rectangle {
|
anchors.fill: parent
|
||||||
Layout.fillWidth: true
|
spacing: 6
|
||||||
Layout.preferredHeight: 86
|
|
||||||
color: Theme.cardBackground
|
Text {
|
||||||
border.color: {
|
text: root.csvPath.split("/").pop()
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
color: Theme.sideMutedText
|
||||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
font.pixelSize: Theme.fontXs
|
||||||
return Theme.cardBorder
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
|
Label { text: "Wafer" }
|
||||||
|
TextField { id: metaWafer; Layout.fillWidth: true }
|
||||||
|
Label { text: "Date" }
|
||||||
|
TextField { id: metaDate; Layout.fillWidth: true }
|
||||||
|
Label { text: "Chamber" }
|
||||||
|
TextField { id: metaChamber; Layout.fillWidth: true }
|
||||||
|
Label { text: "Notes" }
|
||||||
|
TextField { id: metaNotes; Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// ROWS 1-2 (3-column bento):
|
||||||
|
// [Connection Status] [Total Runtime ] [Wafer & Sensor]
|
||||||
|
// [Directory (span 2) ] [ (rowspan) ]
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 188
|
||||||
|
Layout.maximumHeight: 188
|
||||||
|
columns: 3
|
||||||
|
rowSpacing: 8
|
||||||
|
columnSpacing: 8
|
||||||
|
|
||||||
|
// ── Connection Status: two cells wide, top-left ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "CONNECTION STATUS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 22
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 5
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
|
Rectangle {
|
||||||
|
width: 4; height: 4; radius: 2
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||||
|
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: root.isConnected || root.isBusy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.3; duration: 1400 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 1400 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: statusPillText.implicitWidth + 16
|
||||||
|
height: 20
|
||||||
|
radius: 10
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.statusErrorColor)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: statusPillText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
||||||
|
color: Theme.statusBadgeText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: 4
|
||||||
|
Rectangle {
|
||||||
|
width: 4; height: 4; radius: 2
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : (root.isBusy ? Theme.statusWarningColor : Theme.sideMutedText)
|
||||||
|
opacity: (root.isConnected || root.isBusy) ? 1.0 : 0.3
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: root.isConnected || root.isBusy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 0.3; duration: 1400 }
|
||||||
|
NumberAnimation { to: 1.0; duration: 1400 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.portName
|
||||||
|
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wafer & Sensor Data: far right, spans both rows ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.rowSpan: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
@@ -84,293 +309,310 @@ ColumnLayout {
|
|||||||
spacing: 4
|
spacing: 4
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: deviceController.connectionStatus
|
text: "WAFER & SENSOR DATA"
|
||||||
color: {
|
color: Theme.sideMutedText
|
||||||
if (deviceController.connectionStatus === "Connected") return Theme.statusSuccessColor
|
font.pixelSize: Theme.fontSm
|
||||||
if (deviceController.connectionStatus === "Disconnected") return Theme.statusErrorColor
|
font.weight: Font.Bold
|
||||||
return Theme.bodyColor
|
font.family: Theme.uiFontFamily
|
||||||
}
|
font.letterSpacing: 0.6
|
||||||
font.bold: true
|
Layout.bottomMargin: 2
|
||||||
font.pixelSize: 16
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
GridLayout {
|
||||||
text: "Port: " + (deviceController.selectedPort || "None selected")
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: 14
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
Layout.fillHeight: true
|
||||||
|
columns: 2
|
||||||
|
rowSpacing: 6
|
||||||
|
columnSpacing: 16
|
||||||
|
|
||||||
Text {
|
GridCell {
|
||||||
text: "Save dir: " + deviceController.saveDataDir
|
id: waferInfoFamilyCell
|
||||||
color: Theme.bodyColor
|
label: "Family Code"
|
||||||
font.pixelSize: 12
|
value: {
|
||||||
opacity: 0.7
|
var info = deviceController.lastWaferInfo;
|
||||||
elide: Text.ElideMiddle
|
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
||||||
Layout.fillWidth: true
|
}
|
||||||
}
|
}
|
||||||
}
|
GridCell {
|
||||||
}
|
id: waferSerialCell
|
||||||
|
label: "Serial"
|
||||||
// --- Wafer Info ---
|
value: {
|
||||||
GroupBox {
|
var info = deviceController.lastWaferInfo;
|
||||||
title: "Wafer Information"
|
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||||
visible: root.waferDetected
|
}
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Family Code"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferInfoFamily; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 4
|
Layout.preferredHeight: 1
|
||||||
Text { text: "Serial Number"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
color: Theme.cardBorder
|
||||||
Text { id: waferSerial; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
opacity: 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
GridCell {
|
||||||
Layout.fillWidth: true
|
id: waferCyclesCell
|
||||||
spacing: 4
|
label: "Cycles Completed"
|
||||||
Text { text: "Sensor Count"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
value: {
|
||||||
Text { id: waferSensors; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||||
|
}
|
||||||
|
active: root.waferDetected
|
||||||
}
|
}
|
||||||
}
|
GridCell {
|
||||||
|
id: waferSensorsCell
|
||||||
RowLayout {
|
label: "Sensors"
|
||||||
Layout.fillWidth: true
|
value: {
|
||||||
spacing: Theme.rightPaneGap
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
||||||
ColumnLayout {
|
}
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Runtime"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferRuntime; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Cycles"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { id: waferCycles; text: "—"; color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Data Summary (shown after parse) ---
|
// ── Directory: bottom-left cell ──
|
||||||
GroupBox {
|
|
||||||
title: "Data Summary"
|
|
||||||
visible: root.dataParsed
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: Theme.rightPaneGap
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Rows"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { text: String(root.dataRows); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "Sensors"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { text: String(root.dataCols); color: Theme.bodyColor; font.pixelSize: 16; font.bold: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 4
|
|
||||||
Text { text: "CSV"; color: Theme.subheadingColor; font.pixelSize: 12 }
|
|
||||||
Text { text: root.csvPath; color: Theme.bodyColor; font.pixelSize: 12; elide: Text.ElideMiddle }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Activity Log ---
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
radius: Theme.radiusMd
|
border.width: 1
|
||||||
clip: true
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
// Header
|
RowLayout {
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 40
|
spacing: 8
|
||||||
color: Theme.subtleSectionBackground
|
|
||||||
|
|
||||||
RowLayout {
|
Text {
|
||||||
anchors.fill: parent
|
text: "DIRECTORY"
|
||||||
anchors.leftMargin: Theme.panelPadding
|
color: Theme.sideMutedText
|
||||||
anchors.rightMargin: Theme.panelPadding
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
Label {
|
font.letterSpacing: 1.5
|
||||||
text: "ACTIVITY LOG"
|
font.family: Theme.uiFontFamily
|
||||||
color: Theme.bodyColor
|
Layout.fillWidth: true
|
||||||
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 {
|
Rectangle {
|
||||||
anchors.bottom: parent.bottom
|
visible: root.csvPath !== ""
|
||||||
anchors.left: parent.left
|
implicitWidth: sessionSavedLabel.implicitWidth + 12
|
||||||
anchors.right: parent.right
|
implicitHeight: 18
|
||||||
height: 1
|
radius: 4
|
||||||
color: Theme.cardBorder
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: sessionSavedLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "SESSION SAVED"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollView {
|
Item { Layout.fillHeight: true }
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
Layout.margins: Theme.panelPadding
|
|
||||||
clip: true
|
|
||||||
|
|
||||||
TextArea {
|
RowLayout {
|
||||||
id: activityLog
|
Layout.fillWidth: true
|
||||||
width: parent.width
|
spacing: 12
|
||||||
text: ""
|
|
||||||
readOnly: true
|
// Path pinned bottom-left
|
||||||
font.family: "monospace"
|
Text {
|
||||||
font.pixelSize: 12
|
text: deviceController.saveDataDir || "Not configured"
|
||||||
wrapMode: TextArea.WordWrap
|
color: Theme.bodyColor
|
||||||
color: Theme.bodyColor
|
font.pixelSize: Theme.fontMd
|
||||||
background: null
|
font.family: Theme.uiFontFamily
|
||||||
}
|
font.italic: true
|
||||||
|
opacity: 0.7
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: browseBtn
|
||||||
|
text: "Select Folder"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
implicitHeight: 24
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: browseBtn.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: browseBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: dirOnlyDialog.open()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
// ── Total Runtime: bottom center ──
|
||||||
target: deviceController
|
StatCard {
|
||||||
function onActivityLogUpdated(newLog) {
|
id: runtimeCard
|
||||||
activityLog.text = newLog
|
label: "TOTAL RUNTIME"
|
||||||
}
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
|
||||||
|
var secs = parseFloat(info[4]);
|
||||||
|
if (isNaN(secs)) return info[4] + "s";
|
||||||
|
var m = Math.floor(secs / 60);
|
||||||
|
var s = Math.round(secs % 60);
|
||||||
|
return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Signal Handlers ---
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
Connections {
|
// ROW 3: Activity Log — ALWAYS visible (fills remaining height)
|
||||||
target: deviceController
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
// Any operation start (detect/read/erase/debug) latches the panel on.
|
Rectangle {
|
||||||
function onPortsUpdated() {
|
Layout.fillWidth: true
|
||||||
if (deviceController.operationInProgress)
|
Layout.fillHeight: true
|
||||||
root.statusActive = true
|
color: Theme.cardBackground
|
||||||
}
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
clip: true
|
||||||
|
|
||||||
function onDetectResult(result) {
|
ColumnLayout {
|
||||||
root.statusActive = true
|
anchors.fill: parent
|
||||||
if (result && result.familyCode) {
|
spacing: 0
|
||||||
root.waferDetected = true
|
|
||||||
waferInfoFamily.text = result.familyCode
|
|
||||||
waferSerial.text = result.serialNumber
|
|
||||||
waferSensors.text = String(result.sensorCount)
|
|
||||||
waferRuntime.text = result.runtime + "s"
|
|
||||||
waferCycles.text = String(result.cycleCount)
|
|
||||||
}
|
|
||||||
// On failure, keep the last displayed wafer info and do not change waferDetected.
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStatusRestored() {
|
Rectangle {
|
||||||
// Show restored wafer info if available
|
Layout.fillWidth: true
|
||||||
var info = deviceController.lastWaferInfo
|
Layout.preferredHeight: 36
|
||||||
if (info && info.length > 0) {
|
color: Theme.subtleSectionBackground
|
||||||
root.statusActive = true
|
radius: Theme.radiusMd
|
||||||
root.waferDetected = true
|
|
||||||
waferInfoFamily.text = info[0] || "—"
|
// Cover bottom corners so only top-left and top-right are rounded
|
||||||
waferSerial.text = info[1] || "—"
|
Rectangle {
|
||||||
waferSensors.text = String(info[2] || 0)
|
anchors.bottom: parent.bottom
|
||||||
waferRuntime.text = (info[3] || 0) + "s"
|
anchors.left: parent.left
|
||||||
waferCycles.text = String(info[4] || 0)
|
anchors.right: parent.right
|
||||||
}
|
height: parent.radius
|
||||||
// Show data summary if data was previously parsed
|
color: parent.color
|
||||||
if (deviceController.dataRowCount > 0) {
|
}
|
||||||
root.statusActive = true
|
|
||||||
root.dataParsed = true
|
RowLayout {
|
||||||
root.dataRows = deviceController.dataRowCount
|
anchors.fill: parent
|
||||||
root.dataCols = deviceController.dataColCount
|
anchors.leftMargin: Theme.panelPadding
|
||||||
root.csvPath = "" // CSV path not persisted as full path, just show it was parsed
|
anchors.rightMargin: Theme.panelPadding
|
||||||
}
|
spacing: 8
|
||||||
}
|
|
||||||
}
|
IconImage {
|
||||||
|
source: "icons/file-text.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
visible: root.csvPath !== ""
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// Bound to backend so log survives tab switches (Loader
|
||||||
|
// destroys this tab); clears only via Clear Log / restart.
|
||||||
|
text: deviceController.activityLog || qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
||||||
|
readOnly: true
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: TextArea.WordWrap
|
||||||
|
color: Theme.bodyColor
|
||||||
|
background: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SIGNAL HANDLERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
Connections {
|
||||||
|
target: deviceController
|
||||||
|
function onLogMessage(message) {
|
||||||
|
if (message == "CHOOSE_SAVE_DIR") {
|
||||||
|
saveDirDialog.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPortsUpdated() {}
|
||||||
|
function onDetectResult(result) {
|
||||||
|
if (!result) {
|
||||||
|
root.dataParsed = false;
|
||||||
|
root.dataRows = 0;
|
||||||
|
root.dataCols = 0;
|
||||||
|
root.csvPath = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onStatusRestored() {
|
||||||
|
if (deviceController.dataRowCount > 0) {
|
||||||
|
root.dataParsed = true;
|
||||||
|
root.dataRows = deviceController.dataRowCount;
|
||||||
|
root.dataCols = deviceController.dataColCount;
|
||||||
|
root.csvPath = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onReadResult(result) {
|
function onReadResult(result) {
|
||||||
root.dataParsed = false
|
root.dataParsed = false;
|
||||||
root.dataRows = 0
|
root.dataRows = 0;
|
||||||
root.dataCols = 0
|
root.dataCols = 0;
|
||||||
root.csvPath = ""
|
root.csvPath = "";
|
||||||
|
|
||||||
if (result && result.success === true)
|
if (result && result.success === true)
|
||||||
saveDirDialog.open()
|
saveDirDialog.open();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,12 +620,19 @@ ColumnLayout {
|
|||||||
target: deviceController
|
target: deviceController
|
||||||
function onParsedDataReady(result) {
|
function onParsedDataReady(result) {
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
root.dataParsed = true
|
root.dataParsed = true;
|
||||||
root.dataRows = result.rows
|
root.dataRows = result.rows;
|
||||||
root.dataCols = result.cols
|
root.dataCols = result.cols;
|
||||||
root.csvPath = result.csv_path || ""
|
root.csvPath = result.csv_path || "";
|
||||||
|
if (root.csvPath) {
|
||||||
|
metaWafer.text = result.serialNumber || "";
|
||||||
|
metaDate.text = Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss");
|
||||||
|
metaChamber.text = settingsModel.chamberId || "";
|
||||||
|
metaNotes.text = "";
|
||||||
|
editCsvDialog.open();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
root.dataParsed = false
|
root.dataParsed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
import ISC.Tabs.components
|
import ISC.Tabs.components
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
// Live elapsed-time counter
|
// Wire streamController.trendDelta (Signal(str) carrying one new
|
||||||
property int _liveSecs: 0
|
// [elapsed_s, value] pair per live frame) into the trend chart, which
|
||||||
Timer {
|
// accumulates its own bounded 60s window. trendReset clears it when a
|
||||||
id: liveTimer
|
// new stream starts. Parsing happens on the Python TrendChartItem side
|
||||||
interval: 1000
|
// so malformed payloads are logged there instead of crashing the QML
|
||||||
repeat: true
|
// binding.
|
||||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
property string loadErrorMessage: ""
|
||||||
onTriggered: root._liveSecs++
|
Connections {
|
||||||
onRunningChanged: if (!running) root._liveSecs = 0
|
target: streamController
|
||||||
}
|
function onTrendDelta(deltaJson) {
|
||||||
function fmtTime(s) {
|
trendChart.appendDelta(deltaJson);
|
||||||
var m = Math.floor(s / 60)
|
}
|
||||||
var ss = s % 60
|
function onTrendReset() {
|
||||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss
|
trendChart.clearTrend();
|
||||||
|
}
|
||||||
|
function onLoadedFileChanged() {
|
||||||
|
root.loadErrorMessage = "";
|
||||||
|
}
|
||||||
|
function onLoadFileError(message) {
|
||||||
|
root.loadErrorMessage = message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
@@ -34,104 +43,15 @@ Item {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 10
|
spacing: 10
|
||||||
|
|
||||||
// Mode toggle
|
Item {
|
||||||
TabBar {
|
Layout.fillWidth: true
|
||||||
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
|
// SET badge + avg temp
|
||||||
Rectangle {
|
Rectangle {
|
||||||
visible: streamController.state === "set"
|
visible: streamController.state === "set"
|
||||||
height: 22; radius: Theme.radiusSm
|
height: 22
|
||||||
|
radius: Theme.radiusSm
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
border.color: Theme.liveColor
|
border.color: Theme.liveColor
|
||||||
border.width: 1
|
border.width: 1
|
||||||
@@ -139,10 +59,9 @@ Item {
|
|||||||
Label {
|
Label {
|
||||||
id: setLabel
|
id: setLabel
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "SET " + (streamController.stats.avg !== undefined
|
text: "SET " + (streamController.stats.avg !== undefined ? streamController.stats.avg + " avg" : "")
|
||||||
? streamController.stats.avg + " avg" : "")
|
|
||||||
color: Theme.liveColor
|
color: Theme.liveColor
|
||||||
font.pixelSize: 10
|
font.pixelSize: Theme.fontXs
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
font.letterSpacing: 0.8
|
font.letterSpacing: 0.8
|
||||||
}
|
}
|
||||||
@@ -153,116 +72,150 @@ Item {
|
|||||||
spacing: 5
|
spacing: 5
|
||||||
visible: streamController.recording
|
visible: streamController.recording
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 7; height: 7; radius: 4
|
width: 7
|
||||||
|
height: 7
|
||||||
|
radius: 4
|
||||||
color: Theme.recordColor
|
color: Theme.recordColor
|
||||||
SequentialAnimation on opacity {
|
SequentialAnimation on opacity {
|
||||||
running: streamController.recording
|
running: streamController.recording
|
||||||
loops: Animation.Infinite
|
loops: Animation.Infinite
|
||||||
NumberAnimation { to: 0.2; duration: 600 }
|
NumberAnimation {
|
||||||
NumberAnimation { to: 1.0; duration: 600 }
|
to: 0.2
|
||||||
|
duration: 600
|
||||||
|
}
|
||||||
|
NumberAnimation {
|
||||||
|
to: 1.0
|
||||||
|
duration: 600
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: "REC"
|
text: "REC"
|
||||||
color: Theme.recordColor
|
color: Theme.recordColor
|
||||||
font.pixelSize: 10
|
font.pixelSize: Theme.fontXs
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
font.letterSpacing: 1.2
|
font.letterSpacing: 1.2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load error banner ───────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||||
|
visible: root.loadErrorMessage !== ""
|
||||||
|
color: Theme.errorSurface
|
||||||
|
border.color: Theme.statusErrorColor
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
// LIVE timer
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
spacing: 5
|
id: errorRow
|
||||||
visible: streamController.mode === "live" && streamController.state !== "idle"
|
anchors.fill: parent
|
||||||
Rectangle {
|
anchors.margins: 8
|
||||||
width: 7; height: 7; radius: 4
|
spacing: 8
|
||||||
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)
|
IconImage {
|
||||||
Label {
|
source: "icons/triangle-alert.svg"
|
||||||
visible: streamController.mode === "review" || streamController.state === "idle"
|
width: 16; height: 16
|
||||||
text: streamController.state.toUpperCase()
|
sourceSize.width: 16
|
||||||
color: Theme.bodyColor
|
sourceSize.height: 16
|
||||||
font.pixelSize: 11
|
color: Theme.errorTextSoft
|
||||||
font.weight: Font.Medium
|
Layout.alignment: Qt.AlignTop
|
||||||
font.letterSpacing: 1.2
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: root.loadErrorMessage
|
||||||
|
color: Theme.errorTextSoft
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Body: 3 zones ─────────────────────────────────────────────────
|
// ── Body: 2 columns (wafer + trend + transport | readout) ──────────
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
spacing: 16
|
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 {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
spacing: 8
|
spacing: 16
|
||||||
|
|
||||||
WaferMapView {
|
WaferMapView {
|
||||||
id: waferView
|
id: waferView
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
|
Layout.preferredHeight: 400
|
||||||
|
Layout.minimumHeight: 280
|
||||||
blend: readoutPanel.heatmapBlend
|
blend: readoutPanel.heatmapBlend
|
||||||
showLabels: readoutPanel.showLabels
|
showLabels: readoutPanel.showLabels
|
||||||
|
showExtremes: readoutPanel.showExtremes
|
||||||
|
showThickness: readoutPanel.showThickness
|
||||||
}
|
}
|
||||||
|
Rectangle {
|
||||||
// Horizontal separation line with vertical padding
|
id: trendPane
|
||||||
Item {
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 32
|
Layout.fillHeight: false
|
||||||
Rectangle {
|
Layout.preferredHeight: 220
|
||||||
anchors.centerIn: parent
|
Layout.minimumHeight: 120
|
||||||
width: parent.width
|
visible: trendChart.hasData && streamController.mode === "live"
|
||||||
height: 1
|
color: Theme.cardBackground
|
||||||
color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
TrendChartItem {
|
||||||
|
id: trendChart
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TransportBar { Layout.fillWidth: true }
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: transportBar.hasContent
|
||||||
|
implicitHeight: trendPane.visible ? 52 : 16
|
||||||
|
}
|
||||||
|
TransportBar {
|
||||||
|
id: transportBar
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readout panel — bordered card
|
// Readout panel — scrolls instead of clipping so the last card
|
||||||
Rectangle {
|
// (Thresholds) is always reachable even if content outgrows
|
||||||
Layout.preferredWidth: 220
|
// the available height (e.g. once E-C delta rows appear).
|
||||||
|
ScrollView {
|
||||||
|
Layout.preferredWidth: Theme.rightRailWidth
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
clip: true
|
clip: true
|
||||||
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||||
|
|
||||||
ReadoutPanel {
|
ReadoutPanel {
|
||||||
id: readoutPanel
|
id: readoutPanel
|
||||||
anchors.fill: parent
|
width: parent.width
|
||||||
anchors.margins: 10
|
hasThicknessData: waferView.hasThickness
|
||||||
|
onExportRequested: function(filePath, extra) {
|
||||||
|
waferView.exportImage(filePath, extra);
|
||||||
|
}
|
||||||
|
onThicknessFileChosen: function(filePath) {
|
||||||
|
var err = waferView.loadThickness(filePath);
|
||||||
|
if (err !== "") {
|
||||||
|
root.loadErrorMessage = err;
|
||||||
|
readoutPanel.showThickness = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Single seam for tooltip chrome — every hover tip in the app instantiates
|
||||||
|
// this instead of styling QtQuick.Controls ToolTip.background/contentItem
|
||||||
|
// inline at each call site.
|
||||||
|
ToolTip {
|
||||||
|
id: root
|
||||||
|
delay: 400
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
text: root.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: root.font
|
||||||
|
}
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Curve overlay chart card with the alignment timeline scrubber.
|
||||||
|
PanelBox {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
required property var trendDataA
|
||||||
|
required property var trendDataB
|
||||||
|
required property int seriesLen
|
||||||
|
required property bool hasSeries
|
||||||
|
required property bool comparing
|
||||||
|
|
||||||
|
// Scrubber position — the internal Slider is private; consumers
|
||||||
|
// read/write frameIndex and call focusScrubber().
|
||||||
|
property alias frameIndex: scrubber.value
|
||||||
|
|
||||||
|
function focusScrubber() { scrubber.forceActiveFocus() }
|
||||||
|
|
||||||
|
implicitHeight: chartCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: chartCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "CURVE OVERLAY"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 16
|
||||||
|
visible: card.hasSeries
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||||
|
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 200
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
id: overlayCanvas
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: card.hasSeries
|
||||||
|
|
||||||
|
readonly property real padLeft: 50
|
||||||
|
readonly property real padRight: 16
|
||||||
|
readonly property real padTop: 24
|
||||||
|
readonly property real padBottom: 36
|
||||||
|
|
||||||
|
// Nice tick step (1/2/5 × 10^k) — same scheme as TrendChartItem,
|
||||||
|
// so labels land on round numbers instead of raw data min/max.
|
||||||
|
function niceStep(rawStep) {
|
||||||
|
var mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
|
||||||
|
return [1, 2, 5, 10].map(function (s) { return s * mag })
|
||||||
|
.find(function (s) { return s >= rawStep })
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(ctx, minV, maxV, yStep, dataLen) {
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var plotH = height - padTop - padBottom
|
||||||
|
var range = (maxV - minV) || 1
|
||||||
|
|
||||||
|
ctx.strokeStyle = Theme.chartGridLine
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
ctx.fillStyle = Theme.chartAxisText
|
||||||
|
ctx.font = "10px " + Theme.uiFontFamily
|
||||||
|
ctx.textAlign = "right"
|
||||||
|
|
||||||
|
var ySteps = Math.max(1, Math.round(range / yStep))
|
||||||
|
var yDec = Math.max(0, -Math.floor(Math.log10(yStep)))
|
||||||
|
for (var yi = 0; yi <= ySteps; yi++) {
|
||||||
|
var yFrac = yi / ySteps
|
||||||
|
var yPx = padTop + plotH * yFrac
|
||||||
|
var yVal = maxV - yFrac * range
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(padLeft, yPx)
|
||||||
|
ctx.lineTo(padLeft + plotW, yPx)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
var xMax = dataLen - 1
|
||||||
|
if (xMax > 0) {
|
||||||
|
var xStep = niceStep(xMax / 5 || 1)
|
||||||
|
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
|
||||||
|
var xPx = padLeft + (xIdx / xMax) * plotW
|
||||||
|
ctx.fillText(xIdx.toString(), xPx, height - 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(14, padTop + plotH / 2)
|
||||||
|
ctx.rotate(-Math.PI / 2)
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.font = "12px " + Theme.uiFontFamily
|
||||||
|
ctx.fillText("Celcius (°C)", 0, 0)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
ctx.textAlign = "center"
|
||||||
|
ctx.font = "12px " + Theme.uiFontFamily
|
||||||
|
ctx.fillText("Frame", padLeft + plotW / 2, height - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSeries(ctx, series, minV, range, color, dataLen) {
|
||||||
|
// x is mapped against the shared frame domain (dataLen), not the
|
||||||
|
// series' own length — otherwise a shorter run's line stretches to
|
||||||
|
// fill the whole plot instead of stopping where its data ends.
|
||||||
|
if (series.length < 2) return
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var plotH = height - padTop - padBottom
|
||||||
|
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
for (var i = 0; i < series.length; i++) {
|
||||||
|
var x = padLeft + (i / (dataLen - 1)) * plotW
|
||||||
|
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
|
||||||
|
if (i === 0) ctx.moveTo(x, y)
|
||||||
|
else ctx.lineTo(x, y)
|
||||||
|
}
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawScrubMarker(ctx) {
|
||||||
|
if (card.seriesLen < 2) return
|
||||||
|
var plotW = width - padLeft - padRight
|
||||||
|
var x = padLeft + (scrubber.value / (card.seriesLen - 1)) * plotW
|
||||||
|
ctx.strokeStyle = Theme.primaryAccent
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.setLineDash([3, 3])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, padTop)
|
||||||
|
ctx.lineTo(x, height - padBottom)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
}
|
||||||
|
|
||||||
|
onPaint: {
|
||||||
|
var ctx = getContext("2d")
|
||||||
|
ctx.reset()
|
||||||
|
if (!card.hasSeries) return
|
||||||
|
var all = card.trendDataA.concat(card.trendDataB)
|
||||||
|
var minV = Math.min.apply(null, all)
|
||||||
|
var maxV = Math.max.apply(null, all)
|
||||||
|
var padding = (maxV - minV) * 0.05
|
||||||
|
minV -= padding
|
||||||
|
maxV += padding
|
||||||
|
// Snap bounds to nice-step multiples -> stable round-number axis
|
||||||
|
var yStep = niceStep((maxV - minV) / 5 || 1)
|
||||||
|
minV = Math.floor(minV / yStep) * yStep
|
||||||
|
maxV = Math.ceil(maxV / yStep) * yStep
|
||||||
|
var range = (maxV - minV) || 1
|
||||||
|
|
||||||
|
drawGrid(ctx, minV, maxV, yStep, card.seriesLen)
|
||||||
|
drawSeries(ctx, card.trendDataA, minV, range, Theme.primaryAccent, card.seriesLen)
|
||||||
|
drawSeries(ctx, card.trendDataB, minV, range, Theme.themeSkill, card.seriesLen)
|
||||||
|
drawScrubMarker(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: card
|
||||||
|
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
|
||||||
|
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
Connections {
|
||||||
|
target: scrubber
|
||||||
|
function onValueChanged() { overlayCanvas.requestPaint() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: !card.hasSeries
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: !card.comparing
|
||||||
|
source: "../icons/bar-chart.svg"
|
||||||
|
width: 32; height: 32
|
||||||
|
sourceSize.width: 32
|
||||||
|
sourceSize.height: 32
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: card.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
visible: !card.comparing
|
||||||
|
text: "Click a run box on the right, then select a file from the left panel."
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alignment Timeline Scrubber ────────────
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
visible: card.hasSeries
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle {
|
||||||
|
text: "TIMELINE SCRUBBER"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider {
|
||||||
|
id: scrubber
|
||||||
|
Layout.fillWidth: true
|
||||||
|
leftPadding: overlayCanvas.padLeft
|
||||||
|
rightPadding: overlayCanvas.padRight
|
||||||
|
from: 0
|
||||||
|
to: Math.max(1, card.seriesLen - 1)
|
||||||
|
stepSize: 1
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Right-column bento stack: DTW readout, overlap settings, frame/time
|
||||||
|
// controls, and scrubber readout. Pure view — all comparison state lives
|
||||||
|
// on DataTab; scrubber writes go up via frameIndexRequested.
|
||||||
|
ColumnLayout {
|
||||||
|
id: panel
|
||||||
|
|
||||||
|
required property real warpingDistance
|
||||||
|
required property int frameOffset
|
||||||
|
required property real frameOffsetSeconds
|
||||||
|
required property real maxSensorDeviation
|
||||||
|
required property int seriesLen
|
||||||
|
required property bool hasSeries
|
||||||
|
required property var trendDataA
|
||||||
|
required property var trendDataB
|
||||||
|
required property var timeDataA
|
||||||
|
required property var timeDataB
|
||||||
|
required property var frameForTime // function(seconds) -> frame index
|
||||||
|
required property real frameIndex // bound from ComparisonChartCard.frameIndex
|
||||||
|
|
||||||
|
// Overlap settings outputs (consumed by OverlapMapCard via DataTab)
|
||||||
|
property alias overlapEnabled: enableOverlapCheck.checked
|
||||||
|
property alias blendAmount: blendSlider.value
|
||||||
|
|
||||||
|
signal frameIndexRequested(real idx)
|
||||||
|
signal scrubberFocusRequested()
|
||||||
|
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Bento 3: DTW Comparison Readout
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: readoutCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "DTW READOUT" }
|
||||||
|
|
||||||
|
ReadoutStat {
|
||||||
|
label: "DTW ALIGNMENT DISTANCE"
|
||||||
|
value: panel.warpingDistance >= 0 ? panel.warpingDistance.toFixed(2) : "--"
|
||||||
|
valueColor: panel.warpingDistance >= 0
|
||||||
|
? (panel.warpingDistance < 50 ? Theme.metricGood : panel.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "TEMPORAL FRAME OFFSET"
|
||||||
|
value: panel.warpingDistance >= 0
|
||||||
|
? ((panel.frameOffset >= 0 ? "+" : "") + panel.frameOffset + " frames / "
|
||||||
|
+ (panel.frameOffsetSeconds >= 0 ? "+" : "") + panel.frameOffsetSeconds.toFixed(1) + "s")
|
||||||
|
: "--"
|
||||||
|
valueColor: panel.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "MAX SENSOR DEVIATION"
|
||||||
|
value: panel.maxSensorDeviation >= 0 ? panel.maxSensorDeviation.toFixed(2) + "°C" : "--"
|
||||||
|
valueColor: panel.maxSensorDeviation >= 0
|
||||||
|
? (panel.maxSensorDeviation < 1.0 ? Theme.metricGood : panel.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
|
||||||
|
: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento 2: Overlap Map Settings
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: overlapCol.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: overlapCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "OVERLAP SETTINGS" }
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: enableOverlapCheck
|
||||||
|
Layout.fillWidth: true
|
||||||
|
checked: true
|
||||||
|
text: "Enable Overlap Map"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
visible: enableOverlapCheck.checked
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
Label {
|
||||||
|
text: "Blend"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.bodyColor
|
||||||
|
Layout.preferredWidth: 40
|
||||||
|
}
|
||||||
|
PanelSlider {
|
||||||
|
id: blendSlider
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: 0; to: 100; value: 50
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: Math.round(blendSlider.value) + "%"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
Layout.preferredWidth: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff color legend
|
||||||
|
Row {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "B cooler"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "Match"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
spacing: 3
|
||||||
|
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
|
||||||
|
Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento: Frame / Time Controls
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: frameTimeCol.implicitHeight + 32
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: frameTimeCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 20
|
||||||
|
|
||||||
|
SectionTitle { text: "FRAME / TIME" }
|
||||||
|
|
||||||
|
EditableScrubStat {
|
||||||
|
label: "Frame"
|
||||||
|
value: scrubReadout.scrubIdx
|
||||||
|
unit: "/ " + Math.max(0, panel.seriesLen - 1) + (Math.max(0, panel.seriesLen - 1) <= 1 ? " frame" : " frames")
|
||||||
|
editable: panel.hasSeries
|
||||||
|
onValueEdited: (newValue) => {
|
||||||
|
var maxVal = Math.max(0, panel.seriesLen - 1)
|
||||||
|
var val = Math.max(0, Math.min(newValue, maxVal))
|
||||||
|
panel.frameIndexRequested(val)
|
||||||
|
}
|
||||||
|
onEditingDone: panel.scrubberFocusRequested()
|
||||||
|
}
|
||||||
|
|
||||||
|
EditableScrubStat {
|
||||||
|
label: "Time"
|
||||||
|
value: scrubReadout.scrubTime
|
||||||
|
unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s"
|
||||||
|
editable: panel.hasSeries
|
||||||
|
onValueEdited: (newValue) => {
|
||||||
|
var closestIdx = panel.frameForTime(newValue)
|
||||||
|
panel.frameIndexRequested(closestIdx)
|
||||||
|
}
|
||||||
|
onEditingDone: panel.scrubberFocusRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bento: Scrub Readout
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: scrubReadout.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: scrubReadout
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
readonly property int scrubIdx: Math.round(panel.frameIndex)
|
||||||
|
readonly property bool hasA: scrubIdx < panel.trendDataA.length
|
||||||
|
readonly property bool hasB: scrubIdx < panel.trendDataB.length
|
||||||
|
readonly property real tempA: hasA ? panel.trendDataA[scrubIdx] : 0
|
||||||
|
readonly property real tempB: hasB ? panel.trendDataB[scrubIdx] : 0
|
||||||
|
readonly property real scrubTime: {
|
||||||
|
var t = scrubIdx < panel.timeDataA.length ? panel.timeDataA[scrubIdx]
|
||||||
|
: (scrubIdx < panel.timeDataB.length ? panel.timeDataB[scrubIdx] : 0)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
readonly property real maxTime: {
|
||||||
|
var ta = panel.timeDataA.length > 0 ? panel.timeDataA[panel.timeDataA.length - 1] : 0
|
||||||
|
var tb = panel.timeDataB.length > 0 ? panel.timeDataB[panel.timeDataB.length - 1] : 0
|
||||||
|
return Math.max(ta, tb)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle { text: "SCRUBBER READOUT" }
|
||||||
|
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Temp (Run A)"
|
||||||
|
value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
|
||||||
|
valueColor: Theme.primaryAccent
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Temp (Run B)"
|
||||||
|
value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
|
||||||
|
valueColor: Theme.themeSkill
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Difference"
|
||||||
|
value: (scrubReadout.hasA && scrubReadout.hasB)
|
||||||
|
? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sideFooterConnection
|
||||||
|
visible: root.selectedTabIndex !== 1
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
// Row 1: Title
|
||||||
|
Label {
|
||||||
|
text: "CONNECTION FLOW"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: Port / descriptive text
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: {
|
||||||
|
if (deviceController.connectionStatus === "Connected")
|
||||||
|
return deviceController.selectedPort || "Connected"
|
||||||
|
if (deviceController.selectedPort)
|
||||||
|
return deviceController.selectedPort
|
||||||
|
return "No port selected"
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
// Row 3: Mode chip left, status badge bottom-right
|
||||||
|
RowLayout {
|
||||||
|
spacing: 6
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: modeChipLabel.implicitWidth + 12
|
||||||
|
height: 18
|
||||||
|
radius: 4
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: modeChipLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: streamController.mode
|
||||||
|
? streamController.mode.toUpperCase() : "REVIEW"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: 18
|
||||||
|
width: badgeLabel.implicitWidth + 14
|
||||||
|
radius: 9
|
||||||
|
color: {
|
||||||
|
var s = deviceController.connectionStatus
|
||||||
|
if (s === "Connected") return Theme.statusSuccessColor
|
||||||
|
if (s === "Disconnected") return Theme.statusErrorColor
|
||||||
|
return Theme.statusWarningColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: badgeLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: deviceController.connectionStatus || "Disconnected"
|
||||||
|
color: Theme.statusBadgeText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Editable stat box for scrubber readout (styled to match DTW ReadoutCard)
|
||||||
|
Rectangle {
|
||||||
|
id: edStat
|
||||||
|
property string label
|
||||||
|
property real value
|
||||||
|
property string unit
|
||||||
|
property string extraText: ""
|
||||||
|
property bool editable: true
|
||||||
|
signal valueEdited(real newValue)
|
||||||
|
// Fires when the input field commits (Enter/focus loss), even if the
|
||||||
|
// entered text was invalid — callers use it to hand focus back to the
|
||||||
|
// timeline scrubber, matching the pre-extraction behavior.
|
||||||
|
signal editingDone()
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: edStatRow.implicitHeight + 16
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: (edStat.editable && inputField.activeFocus) ? Theme.fieldBorderFocus : Theme.cardBorder
|
||||||
|
border.width: (edStat.editable && inputField.activeFocus) ? Theme.borderStrong : Theme.borderThin
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: edStatRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: edStat.label.toUpperCase()
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.2
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
RowLayout {
|
||||||
|
spacing: 4
|
||||||
|
TextField {
|
||||||
|
id: inputField
|
||||||
|
readOnly: !edStat.editable
|
||||||
|
hoverEnabled: edStat.editable
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
horizontalAlignment: TextInput.AlignHCenter
|
||||||
|
background: Rectangle {
|
||||||
|
visible: edStat.editable
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: inputField.activeFocus ? Theme.fieldBorderFocus : (inputField.hovered ? Theme.fieldBorderFocus : Theme.fieldBorder)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
padding: 0
|
||||||
|
leftPadding: edStat.editable ? 6 : 0
|
||||||
|
rightPadding: edStat.editable ? 6 : 0
|
||||||
|
topPadding: edStat.editable ? 5 : 0
|
||||||
|
bottomPadding: edStat.editable ? 5 : 0
|
||||||
|
selectByMouse: edStat.editable
|
||||||
|
inputMethodHints: edStat.label === "Time" ? Qt.ImhFormattedNumbersOnly : Qt.ImhDigitsOnly
|
||||||
|
Layout.preferredWidth: edStat.editable ? Math.max(36, contentWidth + 12) : contentWidth
|
||||||
|
Layout.preferredHeight: edStat.editable ? 32 : implicitHeight
|
||||||
|
|
||||||
|
text: edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||||
|
|
||||||
|
onEditingFinished: {
|
||||||
|
var val = parseFloat(text)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
edStat.valueEdited(val)
|
||||||
|
}
|
||||||
|
text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||||
|
edStat.editingDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: edStat
|
||||||
|
function onValueChanged() {
|
||||||
|
if (!inputField.activeFocus) {
|
||||||
|
inputField.text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: edStat.unit
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
visible: edStat.extraText !== ""
|
||||||
|
text: edStat.extraText
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: label
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: value
|
||||||
|
color: valueColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// Wafer overlap map card — renders per-sensor Run A/Run B temperature
|
||||||
|
// differences at the current scrub position.
|
||||||
|
PanelBox {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
required property var sensorLayout
|
||||||
|
required property var diffValues
|
||||||
|
required property var diffBands // function(values) -> band thresholds
|
||||||
|
required property string waferShape
|
||||||
|
required property real waferSize
|
||||||
|
required property bool overlapEnabled
|
||||||
|
required property real blendAmount // 0–100, percent
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "WAFER OVERLAP"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
WaferMapItem {
|
||||||
|
id: overlapMapItem
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: card.sensorLayout.length > 0 && card.overlapEnabled
|
||||||
|
sensors: card.sensorLayout
|
||||||
|
values: card.diffValues
|
||||||
|
bands: card.diffBands(overlapMapItem.values)
|
||||||
|
shape: card.waferShape
|
||||||
|
size: card.waferSize
|
||||||
|
target: 0
|
||||||
|
margin: 1.0
|
||||||
|
blend: card.blendAmount / 100
|
||||||
|
showLabels: true
|
||||||
|
ringColor: Theme.waferRingColor
|
||||||
|
axisColor: Theme.waferAxisColor
|
||||||
|
lowColor: Theme.sensorLow
|
||||||
|
inRangeColor: Theme.sensorInRange
|
||||||
|
highColor: Theme.sensorHigh
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: card.sensorLayout.length === 0 || !card.overlapEnabled
|
||||||
|
spacing: 4
|
||||||
|
IconImage {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
source: "../icons/map.svg"
|
||||||
|
width: 24; height: 24
|
||||||
|
sourceSize.width: 24
|
||||||
|
sourceSize.height: 24
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: !card.overlapEnabled ? "Overlap map disabled"
|
||||||
|
: "No sensor map available"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Card container for the Data tab bento boxes.
|
||||||
|
Rectangle {
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
|
||||||
|
// indicator + check icon) instead of the platform-default CheckBox look.
|
||||||
|
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
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
source: "../icons/check.svg"
|
||||||
|
width: 12; height: 12
|
||||||
|
sourceSize.width: 12
|
||||||
|
sourceSize.height: 12
|
||||||
|
color: Theme.panelBackground
|
||||||
|
opacity: toggle.checked ? 1.0 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
text: toggle.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
leftPadding: toggle.indicator.width + toggle.spacing
|
||||||
|
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Slider styled to match MapTab's right-rail heatmap slider (circular
|
||||||
|
// accent handle that grows slightly on press).
|
||||||
|
Slider {
|
||||||
|
id: panelSlider
|
||||||
|
handle: Rectangle {
|
||||||
|
x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width)
|
||||||
|
y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
radius: 9
|
||||||
|
color: Theme.primaryAccent
|
||||||
|
scale: panelSlider.pressed ? 1.15 : 1.0
|
||||||
|
Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Rail Action Button =====
|
||||||
|
// Reusable pill-style button for rail action panels.
|
||||||
|
// Icon left, uppercase label, fixed height, hover/active backgrounds.
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property string iconSource: ""
|
||||||
|
property bool destructive: false
|
||||||
|
// Set true to show the button as "active" (e.g. a one-shot toggle
|
||||||
|
// that's mid-operation), independent of hover/press state.
|
||||||
|
property bool toggled: false
|
||||||
|
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||||
|
: root.destructive ? Theme.statusErrorColor
|
||||||
|
: Theme.headingColor
|
||||||
|
|
||||||
|
signal clicked()
|
||||||
|
|
||||||
|
implicitHeight: Theme.sideButtonHeight
|
||||||
|
radius: 8
|
||||||
|
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
border.width: 0
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 18
|
||||||
|
height: 18
|
||||||
|
source: root.iconSource
|
||||||
|
sourceSize.width: 18
|
||||||
|
sourceSize.height: 18
|
||||||
|
color: root._textColor
|
||||||
|
opacity: root.enabled ? 0.85 : 0.35
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.label
|
||||||
|
color: root._textColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: root.enabled ? 1.0 : 0.4
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: root.enabled
|
||||||
|
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: {
|
||||||
|
if (root.enabled) root.clicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,295 +1,298 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
|
// ===== Readout Panel =====
|
||||||
|
// Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
|
||||||
|
// THRESHOLDS. Uses the same PanelBox / SectionTitle / ReadoutStat /
|
||||||
|
// PanelCheckBox / PanelSlider components as the Data tab's
|
||||||
|
// ComparisonSidePanel, so both right rails share one visual language.
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
spacing: 0
|
id: root
|
||||||
|
spacing: 6
|
||||||
property var s: streamController.stats
|
property var s: streamController.stats
|
||||||
property alias showLabels: labelsToggle.checked
|
property alias showLabels: labelsToggle.checked
|
||||||
|
property alias showExtremes: extremesToggle.checked
|
||||||
property alias heatmapBlend: heatmapSlider.value
|
property alias heatmapBlend: heatmapSlider.value
|
||||||
|
property alias showThickness: thicknessToggle.checked
|
||||||
|
property bool hasThicknessData: false
|
||||||
|
|
||||||
component PanelCheckBox: CheckBox {
|
signal exportRequested(string filePath, string extra)
|
||||||
id: toggle
|
signal thicknessFileChosen(string filePath)
|
||||||
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 {
|
component BadgePill: Rectangle {
|
||||||
anchors.centerIn: parent
|
implicitWidth: badgeLabel.implicitWidth + 10
|
||||||
text: "✓"
|
implicitHeight: 10
|
||||||
font.pixelSize: 13
|
radius: 9
|
||||||
font.bold: true
|
color: Theme.fieldBackground
|
||||||
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.color: Theme.cardBorder
|
||||||
border.width: 1
|
border.width: 1
|
||||||
|
|
||||||
|
property alias text: badgeLabel.text
|
||||||
|
Label {
|
||||||
|
id: badgeLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamControlPanel {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
onExportRequested: function(filePath, extra) {
|
||||||
|
root.exportRequested(filePath, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── READOUT ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: statsLayout
|
id: readoutCol
|
||||||
anchors { fill: parent; margins: 10 }
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
// Min Temp Row
|
SectionTitle { text: "READOUT" }
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
ReadoutStat {
|
||||||
Label { text: "Min Temp"; color: Theme.bodyColor; font.pixelSize: 11 }
|
label: "Min Temp"
|
||||||
Item { Layout.fillWidth: true }
|
value: s.min !== undefined
|
||||||
RowLayout {
|
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
|
||||||
spacing: 4
|
: "—"
|
||||||
Label {
|
valueColor: Theme.sensorLow
|
||||||
text: s.min !== undefined ? s.min : "—"
|
}
|
||||||
color: Theme.sensorLow
|
ReadoutStat {
|
||||||
font.pixelSize: 13; font.weight: Font.Bold
|
label: "Max Temp"
|
||||||
}
|
value: s.max !== undefined
|
||||||
Label {
|
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||||
text: s.minIndex !== undefined ? "#" + s.minIndex : ""
|
: "—"
|
||||||
color: Theme.bodyColor
|
valueColor: Theme.sensorHigh
|
||||||
font.pixelSize: 10
|
}
|
||||||
}
|
ReadoutStat {
|
||||||
|
label: "Diff"
|
||||||
|
value: s.diff !== undefined ? s.diff : "—"
|
||||||
|
valueColor: Theme.diffAccent
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Average"
|
||||||
|
value: s.avg !== undefined ? s.avg : "—"
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "Sigma (Σ)"
|
||||||
|
value: s.sigma !== undefined ? s.sigma : "—"
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
label: "3Σ Value"
|
||||||
|
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
visible: s.ecMinDelta !== undefined
|
||||||
|
label: "E-C Δ Min"
|
||||||
|
value: s.ecMinDelta !== undefined
|
||||||
|
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
ReadoutStat {
|
||||||
|
visible: s.ecMaxDelta !== undefined
|
||||||
|
label: "E-C Δ Max"
|
||||||
|
value: s.ecMaxDelta !== undefined
|
||||||
|
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DISPLAY ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: displayCard.implicitHeight + 24
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: displayCard
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SectionTitle { text: "DISPLAY" }
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: thicknessToggle
|
||||||
|
text: "Show Thickness"
|
||||||
|
checked: false
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
// First check with no data prompts for the customer CSV;
|
||||||
|
// unchecking only hides the overlay, data stays loaded.
|
||||||
|
onToggled: {
|
||||||
|
if (checked && !root.hasThicknessData)
|
||||||
|
thicknessDialog.open();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max Temp Row
|
FileDialog {
|
||||||
RowLayout {
|
id: thicknessDialog
|
||||||
Layout.fillWidth: true
|
title: "Select Thickness Data File"
|
||||||
Label { text: "Max Temp"; color: Theme.bodyColor; font.pixelSize: 11 }
|
nameFilters: ["CSV files (*.csv)"]
|
||||||
Item { Layout.fillWidth: true }
|
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
||||||
RowLayout {
|
onRejected: thicknessToggle.checked = false
|
||||||
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 }
|
PanelCheckBox {
|
||||||
|
id: labelsToggle
|
||||||
|
text: "Labels"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: extremesToggle
|
||||||
|
text: "Highlight Min/Max"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: clusterAverageToggle
|
||||||
|
text: "Average Clusters"
|
||||||
|
checked: streamController.clusterAveragingEnabled
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
// Differential Row
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Label { text: "Differential"; color: Theme.bodyColor; font.pixelSize: 11 }
|
spacing: 6
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
Label {
|
||||||
text: s.diff !== undefined ? s.diff : "—"
|
text: "Heatmap"
|
||||||
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
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 12; font.weight: Font.Medium
|
font.pixelSize: Theme.fontSm
|
||||||
|
Layout.preferredWidth: 52
|
||||||
|
}
|
||||||
|
PanelSlider {
|
||||||
|
id: heatmapSlider
|
||||||
|
from: 0
|
||||||
|
to: 1
|
||||||
|
value: 0
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: heatmapSlider.hovered
|
||||||
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item { height: 12 }
|
// ── THRESHOLDS ──────────────────────────────────────────────────
|
||||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
PanelBox {
|
||||||
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
|
Layout.fillWidth: true
|
||||||
spacing: 6
|
Layout.preferredHeight: thresholdCard.implicitHeight + 24
|
||||||
Label {
|
|
||||||
text: "Heatmap"
|
ColumnLayout {
|
||||||
color: Theme.bodyColor
|
id: thresholdCard
|
||||||
font.pixelSize: 11
|
anchors.fill: parent
|
||||||
Layout.preferredWidth: 52
|
anchors.margins: 12
|
||||||
}
|
spacing: 6
|
||||||
Slider {
|
|
||||||
id: heatmapSlider
|
SectionTitle { text: "THRESHOLDS" }
|
||||||
from: 0; to: 1; value: 0
|
|
||||||
Layout.fillWidth: true
|
Label {
|
||||||
ToolTip.visible: hovered
|
text: "Set Point (°C)"
|
||||||
ToolTip.text: Math.round(value * 100) + "%"
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: spField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "149.0"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
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
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEditingFinished: pushThresholds()
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Margin (±°C)"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: mgField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "1.0"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
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
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEditingFinished: pushThresholds()
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: autoCheck
|
||||||
|
text: "Auto range (mean ± 1σ)"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
onCheckedChanged: pushThresholds()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item { height: 12 }
|
Item {
|
||||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
Layout.fillHeight: true
|
||||||
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() {
|
function pushThresholds() {
|
||||||
var sp = parseFloat(spField.text) || 149.0
|
var sp = parseFloat(spField.text) || 149.0;
|
||||||
var mg = parseFloat(mgField.text) || 1.0
|
var mg = parseFloat(mgField.text) || 1.0;
|
||||||
streamController.setThresholds(sp, mg, autoCheck.checked)
|
streamController.setThresholds(sp, mg, autoCheck.checked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Small labeled value tile — DTW readout panel, scrubber readout, and any
|
||||||
|
// other compact label/value stat display share this one shape.
|
||||||
|
Rectangle {
|
||||||
|
id: readoutStat
|
||||||
|
property string label
|
||||||
|
property string value
|
||||||
|
property color valueColor: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 52
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 2
|
||||||
|
Label {
|
||||||
|
text: readoutStat.label.toUpperCase()
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.2
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: readoutStat.value
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: readoutStat.valueColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ Dialog {
|
|||||||
standardButtons: Dialog.NoButton
|
standardButtons: Dialog.NoButton
|
||||||
width: 300
|
width: 300
|
||||||
|
|
||||||
|
// Anchor to the top-left of the window instead of the default centered position.
|
||||||
|
x: 24
|
||||||
|
y: 24
|
||||||
|
|
||||||
// called by WaferMapView's TapHandler
|
// called by WaferMapView's TapHandler
|
||||||
property int sensorIndex: -1
|
property int sensorIndex: -1
|
||||||
property string sensorLabel: ""
|
property string sensorLabel: ""
|
||||||
@@ -17,14 +21,15 @@ Dialog {
|
|||||||
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
|
property bool hasOverride: streamController.overriddenSensors.indexOf(sensorIndex) >= 0
|
||||||
|
|
||||||
function openFor(index) {
|
function openFor(index) {
|
||||||
sensorIndex = index
|
sensorIndex = index;
|
||||||
var dot = streamController.sensorDots[index]
|
var dot = streamController.sensorDots[index];
|
||||||
if (!dot) return
|
if (!dot)
|
||||||
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1)
|
return;
|
||||||
currentValue = dot.value
|
sensorLabel = dot.label !== undefined ? String(dot.label) : String(index + 1);
|
||||||
valueField.text = ""
|
currentValue = dot.value;
|
||||||
offsetField.text = ""
|
valueField.text = "";
|
||||||
open()
|
offsetField.text = "";
|
||||||
|
open();
|
||||||
}
|
}
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -42,11 +47,10 @@ Dialog {
|
|||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Label {
|
Label {
|
||||||
text: "Sensor #" + (root.sensorIndex + 1) +
|
text: "Sensor #" + (root.sensorIndex + 1) + (root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
|
||||||
(root.sensorLabel ? " (" + root.sensorLabel + ")" : "")
|
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -60,8 +64,8 @@ Dialog {
|
|||||||
id: overrideTag
|
id: overrideTag
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "OVERRIDE"
|
text: "OVERRIDE"
|
||||||
color: "white"
|
color: Theme.statusBadgeText
|
||||||
font.pixelSize: 10
|
font.pixelSize: Theme.fontXs
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.letterSpacing: 1
|
font.letterSpacing: 1
|
||||||
}
|
}
|
||||||
@@ -71,13 +75,21 @@ Dialog {
|
|||||||
Label {
|
Label {
|
||||||
text: "Live: " + root.currentValue.toFixed(2)
|
text: "Live: " + root.currentValue.toFixed(2)
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
// ── replace field ─────────────────────────────────────────
|
// ── replace field ─────────────────────────────────────────
|
||||||
Label { text: "Replace with value"; color: Theme.bodyColor; font.pixelSize: 11 }
|
Label {
|
||||||
|
text: "Replace with value"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
TextField {
|
TextField {
|
||||||
id: valueField
|
id: valueField
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -92,11 +104,21 @@ Dialog {
|
|||||||
color: Theme.fieldBackground
|
color: Theme.fieldBackground
|
||||||
border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
border.width: valueField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
border.color: valueField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── offset field ──────────────────────────────────────────
|
// ── offset field ──────────────────────────────────────────
|
||||||
Label { text: "Or add offset (± delta)"; color: Theme.bodyColor; font.pixelSize: 11 }
|
Label {
|
||||||
|
text: "Or add offset (± delta)"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
TextField {
|
TextField {
|
||||||
id: offsetField
|
id: offsetField
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -111,10 +133,20 @@ Dialog {
|
|||||||
color: Theme.fieldBackground
|
color: Theme.fieldBackground
|
||||||
border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
border.width: offsetField.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
border.color: offsetField.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { Layout.fillWidth: true; height: 1; color: Theme.cardBorder }
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
// ── buttons ───────────────────────────────────────────────
|
// ── buttons ───────────────────────────────────────────────
|
||||||
RowLayout {
|
RowLayout {
|
||||||
@@ -140,20 +172,20 @@ Dialog {
|
|||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (valueField.text !== "") {
|
if (valueField.text !== "") {
|
||||||
var v = parseFloat(valueField.text)
|
var v = parseFloat(valueField.text);
|
||||||
if (!isNaN(v))
|
if (!isNaN(v))
|
||||||
streamController.replaceSensor(root.sensorIndex, v)
|
streamController.replaceSensor(root.sensorIndex, v);
|
||||||
}
|
}
|
||||||
if (offsetField.text !== "") {
|
if (offsetField.text !== "") {
|
||||||
var d = parseFloat(offsetField.text)
|
var d = parseFloat(offsetField.text);
|
||||||
if (!isNaN(d))
|
if (!isNaN(d))
|
||||||
streamController.offsetSensor(root.sensorIndex, d)
|
streamController.offsetSensor(root.sensorIndex, d);
|
||||||
}
|
}
|
||||||
root.close()
|
root.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
@@ -175,11 +207,11 @@ Dialog {
|
|||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
onClicked: {
|
onClicked: {
|
||||||
streamController.clearSensorEdit(root.sensorIndex)
|
streamController.clearSensorEdit(root.sensorIndex);
|
||||||
root.close()
|
root.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
@@ -198,7 +230,7 @@ Dialog {
|
|||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
onClicked: root.close()
|
onClicked: root.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
// ===== Run Chart =====
|
||||||
|
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
|
||||||
|
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
|
||||||
|
// the strip below shows the aggregate min/max/avg over the *visible* range
|
||||||
|
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
|
||||||
|
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
|
||||||
|
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias sensorNames: chart.sensorNames
|
||||||
|
property alias seriesData: chart.seriesData
|
||||||
|
property alias title: chart.title
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: chart
|
||||||
|
anchors.fill: parent
|
||||||
|
showMinMaxMarkers: true
|
||||||
|
xLabel: "Measurement Interval"
|
||||||
|
yLabel: "Temperature (°C)"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: interaction
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
property real lastX: 0
|
||||||
|
|
||||||
|
onWheel: function(wheel) {
|
||||||
|
var frac = Math.max(0, Math.min(1, wheel.x / width));
|
||||||
|
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
|
||||||
|
chart.zoomAtFraction(frac, factor);
|
||||||
|
wheel.accepted = true;
|
||||||
|
}
|
||||||
|
onPressed: function(mouse) {
|
||||||
|
lastX = mouse.x;
|
||||||
|
}
|
||||||
|
onPositionChanged: function(mouse) {
|
||||||
|
if (pressed && width > 0) {
|
||||||
|
chart.panByFraction(-(mouse.x - lastX) / width);
|
||||||
|
lastX = mouse.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Min:"
|
||||||
|
color: Theme.sensorLow
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Max:"
|
||||||
|
color: Theme.sensorHigh
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Avg:"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: chart.viewAvg.toFixed(2)
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: resetZoomBtn
|
||||||
|
implicitHeight: 32
|
||||||
|
hoverEnabled: true
|
||||||
|
text: "Reset Zoom"
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: resetZoomBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: resetZoomBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: chart.resetZoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Uppercase card heading — shared by the Data tab cards.
|
||||||
|
Label {
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import Qt.labs.qmlmodels
|
import Qt.labs.qmlmodels
|
||||||
import ISC
|
import ISC
|
||||||
@@ -24,9 +25,11 @@ Dialog {
|
|||||||
property int selectedRow: -1
|
property int selectedRow: -1
|
||||||
property int metadataEditRow: -1
|
property int metadataEditRow: -1
|
||||||
readonly property string masterTypeFieldText: {
|
readonly property string masterTypeFieldText: {
|
||||||
if (metadataEditRow < 0) return "";
|
if (metadataEditRow < 0)
|
||||||
|
return "";
|
||||||
const rec = tableModel[metadataEditRow];
|
const rec = tableModel[metadataEditRow];
|
||||||
if (!rec) return "";
|
if (!rec)
|
||||||
|
return "";
|
||||||
return rec.masterType ?? "";
|
return rec.masterType ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ Dialog {
|
|||||||
chamberField.text = rec.chamber ?? "";
|
chamberField.text = rec.chamber ?? "";
|
||||||
if (!chamberField.text && settingsModel.chamberId)
|
if (!chamberField.text && settingsModel.chamberId)
|
||||||
chamberField.text = settingsModel.chamberId;
|
chamberField.text = settingsModel.chamberId;
|
||||||
notesField.text = rec.notes ?? "";
|
notesField.text = rec.notes ?? "";
|
||||||
filePathLabel.text = rec.fileName ?? "";
|
filePathLabel.text = rec.fileName ?? "";
|
||||||
metadataEditDialog.open();
|
metadataEditDialog.open();
|
||||||
}
|
}
|
||||||
@@ -76,7 +79,7 @@ Dialog {
|
|||||||
readonly property var normalizedRows: {
|
readonly property var normalizedRows: {
|
||||||
// Touch settingsModel.masters so QML tracks this dependency.
|
// Touch settingsModel.masters so QML tracks this dependency.
|
||||||
const _ = settingsModel.masters;
|
const _ = settingsModel.masters;
|
||||||
return (tableModel || []).map(function(row) {
|
return (tableModel || []).map(function (row) {
|
||||||
row = row || {};
|
row = row || {};
|
||||||
// settingsModel is the authoritative source; fall back to sidecar masterType.
|
// settingsModel is the authoritative source; fall back to sidecar masterType.
|
||||||
const masterType = root.masterFamilyForPath(row.fileName ?? "") || row.masterType || "";
|
const masterType = root.masterFamilyForPath(row.fileName ?? "") || row.masterType || "";
|
||||||
@@ -113,7 +116,7 @@ Dialog {
|
|||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,12 @@ Dialog {
|
|||||||
color: Theme.fieldBackground
|
color: Theme.fieldBackground
|
||||||
border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
border.width: dialogTextInput.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
border.color: dialogTextInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
border.color: dialogTextInput.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +172,7 @@ Dialog {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 16
|
anchors.leftMargin: 16
|
||||||
text: root.title
|
text: root.title
|
||||||
font.pixelSize: 15
|
font.pixelSize: Theme.fontLg
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
@@ -173,10 +182,17 @@ Dialog {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.rightMargin: 12
|
anchors.rightMargin: 12
|
||||||
text: "✕"
|
|
||||||
implicitWidth: 32
|
implicitWidth: 32
|
||||||
implicitHeight: 32
|
implicitHeight: 32
|
||||||
onClicked: root.close()
|
onClicked: root.close()
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/x.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +237,7 @@ Dialog {
|
|||||||
text: file_browser.currentDirectory
|
text: file_browser.currentDirectory
|
||||||
color: Theme.fieldText
|
color: Theme.fieldText
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,7 +258,7 @@ Dialog {
|
|||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: root.headerTitles[index] ?? ""
|
text: root.headerTitles[index] ?? ""
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,7 +345,7 @@ Dialog {
|
|||||||
const mt = root.normalizedRows[row]?.masterType ?? "";
|
const mt = root.normalizedRows[row]?.masterType ?? "";
|
||||||
return mt || "—";
|
return mt || "—";
|
||||||
}
|
}
|
||||||
font.pixelSize: 14
|
font.pixelSize: Theme.fontMd
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: {
|
color: {
|
||||||
const mt = root.normalizedRows[row]?.masterType ?? "";
|
const mt = root.normalizedRows[row]?.masterType ?? "";
|
||||||
@@ -346,7 +362,7 @@ Dialog {
|
|||||||
sourceComponent: Button {
|
sourceComponent: Button {
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
text: "Edit…"
|
text: "Edit…"
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
onClicked: root.openMetadataEditor(row)
|
onClicked: root.openMetadataEditor(row)
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
@@ -361,7 +377,7 @@ Dialog {
|
|||||||
color: Theme.buttonNeutralText
|
color: Theme.buttonNeutralText
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +404,7 @@ Dialog {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,12 +425,12 @@ Dialog {
|
|||||||
return record.notes ?? "";
|
return record.notes ?? "";
|
||||||
}
|
}
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
enabled: column !== 6
|
enabled: column !== 6
|
||||||
onClicked: {
|
onClicked: {
|
||||||
@@ -456,7 +472,7 @@ Dialog {
|
|||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
text: root.selectedPath
|
text: root.selectedPath
|
||||||
elide: Text.ElideLeft
|
elide: Text.ElideLeft
|
||||||
font.pixelSize: 13
|
font.pixelSize: Theme.fontSm
|
||||||
color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder
|
color: root.selectedPath ? Theme.fieldText : Theme.fieldPlaceholder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,7 +520,7 @@ Dialog {
|
|||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 16
|
anchors.leftMargin: 16
|
||||||
text: metadataEditDialog.title
|
text: metadataEditDialog.title
|
||||||
font.pixelSize: 15
|
font.pixelSize: Theme.fontLg
|
||||||
font.bold: true
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
@@ -530,7 +546,7 @@ Dialog {
|
|||||||
maximumLineCount: 3
|
maximumLineCount: 3
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import ISC
|
import ISC
|
||||||
import ".."
|
import ".."
|
||||||
|
|
||||||
@@ -14,10 +15,11 @@ ColumnLayout {
|
|||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "SOURCE"
|
text: "SOURCE"
|
||||||
color: Theme.bodyColor
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: 10
|
font.family: Theme.uiFontFamily
|
||||||
font.letterSpacing: 1.8
|
font.pixelSize: Theme.fontSm
|
||||||
font.weight: Font.Medium
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
topPadding: 6
|
topPadding: 6
|
||||||
bottomPadding: 8
|
bottomPadding: 8
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -26,51 +28,51 @@ ColumnLayout {
|
|||||||
|
|
||||||
Button {
|
Button {
|
||||||
id: editBtn
|
id: editBtn
|
||||||
text: "✎"
|
implicitWidth: 28
|
||||||
implicitWidth: 26
|
implicitHeight: 28
|
||||||
implicitHeight: 26
|
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
font.pixelSize: 14
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: editBtn.pressed ? Theme.buttonPressed
|
color: editBtn.pressed ? Theme.buttonPressed : editBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
: editBtn.hovered ? Theme.buttonNeutralHover
|
|
||||||
: "transparent"
|
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
}
|
}
|
||||||
contentItem: Text {
|
contentItem: IconImage {
|
||||||
text: parent.text
|
source: "../icons/edit.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font: parent.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
onClicked: {
|
onClicked: {
|
||||||
file_browser.refreshFiles()
|
file_browser.refreshFiles();
|
||||||
csvEditorDialog.open()
|
csvEditorDialog.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: editBtn.hovered
|
||||||
|
text: "Edit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
id: refreshBtn
|
id: refreshBtn
|
||||||
text: "⟳"
|
implicitWidth: 28
|
||||||
implicitWidth: 26
|
implicitHeight: 28
|
||||||
implicitHeight: 26
|
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
font.pixelSize: 14
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: refreshBtn.pressed ? Theme.buttonPressed
|
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
: refreshBtn.hovered ? Theme.buttonNeutralHover
|
|
||||||
: "transparent"
|
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
}
|
}
|
||||||
contentItem: Text {
|
contentItem: IconImage {
|
||||||
text: parent.text
|
source: "../icons/refresh.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font: parent.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
onClicked: file_browser.refreshFiles()
|
onClicked: file_browser.refreshFiles()
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: refreshBtn.hovered
|
||||||
|
text: "Refresh"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,28 +81,28 @@ ColumnLayout {
|
|||||||
id: dirBtn
|
id: dirBtn
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
implicitHeight: 32
|
implicitHeight: 36
|
||||||
leftPadding: 8
|
leftPadding: 8
|
||||||
rightPadding: 8
|
rightPadding: 8
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
color: dirBtn.pressed ? Theme.buttonNeutralPressed
|
color: dirBtn.pressed ? Theme.buttonNeutralPressed : dirBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
: dirBtn.hovered ? Theme.buttonNeutralHover
|
|
||||||
: Theme.buttonNeutralBackground
|
|
||||||
border.width: Theme.borderThin
|
border.width: Theme.borderThin
|
||||||
border.color: Theme.fieldBorder
|
border.color: Theme.fieldBorder
|
||||||
}
|
}
|
||||||
contentItem: RowLayout {
|
contentItem: RowLayout {
|
||||||
spacing: 6
|
spacing: 6
|
||||||
Label {
|
IconImage {
|
||||||
text: "▤"
|
source: "../icons/folder.svg"
|
||||||
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 12
|
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font.pixelSize: 11
|
font.pixelSize: Theme.fontSm
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ ColumnLayout {
|
|||||||
id: filter
|
id: filter
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
placeholderText: "Filter…"
|
placeholderText: "Filter…"
|
||||||
font.pixelSize: 11
|
font.pixelSize: Theme.fontSm
|
||||||
color: Theme.fieldText
|
color: Theme.fieldText
|
||||||
placeholderTextColor: Theme.fieldPlaceholder
|
placeholderTextColor: Theme.fieldPlaceholder
|
||||||
selectedTextColor: Theme.fieldBackground
|
selectedTextColor: Theme.fieldBackground
|
||||||
@@ -123,6 +125,12 @@ ColumnLayout {
|
|||||||
color: Theme.fieldBackground
|
color: Theme.fieldBackground
|
||||||
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
|
border.width: filter.activeFocus ? Theme.borderStrong : Theme.borderThin
|
||||||
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
border.color: filter.activeFocus ? Theme.fieldBorderFocus : Theme.fieldBorder
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,28 +154,62 @@ ColumnLayout {
|
|||||||
focusPolicy: Qt.NoFocus
|
focusPolicy: Qt.NoFocus
|
||||||
|
|
||||||
readonly property bool matchesFilter: {
|
readonly property bool matchesFilter: {
|
||||||
if (filter.text === "") return true
|
if (filter.text === "")
|
||||||
var q = filter.text.toLowerCase()
|
return true;
|
||||||
return (modelData.baseName + modelData.waferType + modelData.date)
|
var q = filter.text.toLowerCase();
|
||||||
.toLowerCase().indexOf(q) >= 0
|
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active only in review mode; no highlight during live streaming
|
readonly property var dataTab: {
|
||||||
readonly property bool isActive:
|
try {
|
||||||
streamController.mode === "review" &&
|
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
||||||
streamController.loadedFile !== "" &&
|
} catch (e) { return null; }
|
||||||
modelData.fileName === streamController.loadedFile
|
}
|
||||||
|
|
||||||
|
// Run A's effective file: the master file when "Compare vs Master
|
||||||
|
// File" is toggled on, otherwise the manually selected file.
|
||||||
|
readonly property string effectiveRunAFile: fileItem.dataTab
|
||||||
|
? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA)
|
||||||
|
: ""
|
||||||
|
readonly property bool isRunA: fileItem.dataTab !== null
|
||||||
|
&& fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile
|
||||||
|
readonly property bool isRunB: fileItem.dataTab !== null
|
||||||
|
&& modelData.fileName === fileItem.dataTab.compareFileB
|
||||||
|
|
||||||
|
// Active highlight logic: on Data tab, highlight the Run A/Run B files (or the
|
||||||
|
// resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab).
|
||||||
|
readonly property bool isActive: fileItem.dataTab !== null
|
||||||
|
? (fileItem.isRunA || fileItem.isRunB)
|
||||||
|
: (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile)
|
||||||
|
|
||||||
|
// A file registered as any family's master in Settings → Master Files.
|
||||||
|
readonly property bool isMasterFile: {
|
||||||
|
var m = settingsModel.masters
|
||||||
|
for (var fam in m) {
|
||||||
|
if (m[fam] && m[fam] === modelData.fileName) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
|
||||||
|
readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile
|
||||||
|
|
||||||
visible: matchesFilter
|
visible: matchesFilter
|
||||||
height: matchesFilter ? implicitHeight : 0
|
height: matchesFilter ? implicitHeight : 0
|
||||||
|
enabled: !liveFileBlocked
|
||||||
|
opacity: liveFileBlocked ? 0.45 : 1.0
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: streamController.loadFile(modelData.fileName)
|
onClicked: streamController.loadFile(modelData.fileName)
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: fileItem.isActive
|
color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
|
||||||
? Qt.rgba(1, 1, 1, 0.06)
|
|
||||||
: fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
|
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
|
||||||
// Left accent bar — shown only when active
|
// Left accent bar — shown only when active
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -175,15 +217,23 @@ ColumnLayout {
|
|||||||
width: 3
|
width: 3
|
||||||
radius: 1.5
|
radius: 1.5
|
||||||
anchors {
|
anchors {
|
||||||
top: parent.top; bottom: parent.bottom; left: parent.left
|
top: parent.top
|
||||||
topMargin: 5; bottomMargin: 5
|
bottom: parent.bottom
|
||||||
|
left: parent.left
|
||||||
|
topMargin: 5
|
||||||
|
bottomMargin: 5
|
||||||
}
|
}
|
||||||
color: {
|
color: {
|
||||||
var t = modelData.waferType
|
if (fileItem.isRunA) return Theme.primaryAccent;
|
||||||
if (t === "A" || t === "E" || t === "P") return "#3B82F6"
|
if (fileItem.isRunB) return Theme.themeSkill;
|
||||||
if (t === "B" || t === "C" || t === "D") return "#10B981"
|
var t = modelData.waferType;
|
||||||
if (t === "Z") return "#8B5CF6"
|
if (t === "A" || t === "E" || t === "P")
|
||||||
return Theme.headingColor
|
return Theme.familyBlueAccent;
|
||||||
|
if (t === "B" || t === "C" || t === "D")
|
||||||
|
return Theme.familyGreenAccent;
|
||||||
|
if (t === "Z")
|
||||||
|
return Theme.familyVioletAccent;
|
||||||
|
return Theme.headingColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,24 +243,55 @@ ColumnLayout {
|
|||||||
|
|
||||||
// Wafer-type avatar (circle)
|
// Wafer-type avatar (circle)
|
||||||
Rectangle {
|
Rectangle {
|
||||||
implicitWidth: 28; implicitHeight: 28
|
implicitWidth: 28
|
||||||
|
implicitHeight: 28
|
||||||
Layout.preferredWidth: 28
|
Layout.preferredWidth: 28
|
||||||
Layout.preferredHeight: 28
|
Layout.preferredHeight: 28
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
radius: 14
|
radius: 14
|
||||||
color: {
|
color: {
|
||||||
var t = modelData.waferType
|
var t = modelData.waferType;
|
||||||
if (t === "A" || t === "E" || t === "P") return "#1D4ED8"
|
if (t === "A" || t === "E" || t === "P")
|
||||||
if (t === "B" || t === "C" || t === "D") return "#065F46"
|
return Theme.familyBlueFill;
|
||||||
if (t === "Z") return "#7C3AED"
|
if (t === "B" || t === "C" || t === "D")
|
||||||
return "#374151"
|
return Theme.familyGreenFill;
|
||||||
|
if (t === "Z")
|
||||||
|
return Theme.familyVioletFill;
|
||||||
|
return Theme.familyNeutralFill;
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: modelData.waferType || "?"
|
text: modelData.waferType || "?"
|
||||||
font.pixelSize: 12
|
font.pixelSize: Theme.fontMd
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
color: "#FFFFFF"
|
color: Theme.textOnColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recording indicator dot — distinguishes a live-recorded CSV
|
||||||
|
// (SessionController.startRecording) from a read-memory dump
|
||||||
|
// (DeviceController.parseAndSaveData).
|
||||||
|
Rectangle {
|
||||||
|
visible: modelData.isRecording === true
|
||||||
|
width: 10
|
||||||
|
height: 10
|
||||||
|
radius: 5
|
||||||
|
color: Theme.recordColor
|
||||||
|
border.color: Theme.subtleSectionBackground
|
||||||
|
border.width: 1.5
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: -2
|
||||||
|
anchors.rightMargin: -2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accent ring — this file is registered as a master (Settings → Master Files).
|
||||||
|
Rectangle {
|
||||||
|
visible: fileItem.isMasterFile
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
color: "transparent"
|
||||||
|
border.color: Theme.primaryAccent
|
||||||
|
border.width: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,14 +300,57 @@ ColumnLayout {
|
|||||||
spacing: 1
|
spacing: 1
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
||||||
// Primary: wafer type + serial number
|
// Primary: wafer type + serial number, plus a REC badge for recordings
|
||||||
Label {
|
RowLayout {
|
||||||
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
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Bold
|
||||||
|
elide: Text.ElideRight
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: modelData.isRecording === true
|
||||||
|
radius: 4
|
||||||
|
color: Qt.alpha(Theme.recordColor, 0.18)
|
||||||
|
implicitWidth: recLabel.implicitWidth + 10
|
||||||
|
implicitHeight: recLabel.implicitHeight + 4
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: recLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "REC"
|
||||||
|
color: Theme.recordColor
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: fileItem.isMasterFile
|
||||||
|
radius: 4
|
||||||
|
color: Qt.alpha(Theme.primaryAccent, 0.18)
|
||||||
|
implicitWidth: masterLabel.implicitWidth + 10
|
||||||
|
implicitHeight: masterLabel.implicitHeight + 4
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: masterLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "MASTER"
|
||||||
|
color: Theme.primaryAccent
|
||||||
|
font.pixelSize: Theme.font2xs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary: date · time
|
// Secondary: date · time
|
||||||
@@ -235,24 +359,24 @@ ColumnLayout {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Label {
|
Label {
|
||||||
text: {
|
text: {
|
||||||
var d = modelData.date || ""
|
var d = modelData.date || "";
|
||||||
return d.length >= 10 ? d.substring(0, 10) : (d || "—")
|
return d.length >= 10 ? d.substring(0, 10) : (d || "—");
|
||||||
}
|
}
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 10
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
visible: (modelData.timeStr || "") !== ""
|
visible: (modelData.timeStr || "") !== ""
|
||||||
text: "·"
|
text: "·"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 10
|
font.pixelSize: Theme.fontSm
|
||||||
opacity: 0.45
|
opacity: 0.45
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
visible: (modelData.timeStr || "") !== ""
|
visible: (modelData.timeStr || "") !== ""
|
||||||
text: modelData.timeStr || ""
|
text: modelData.timeStr || ""
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 10
|
font.pixelSize: Theme.fontSm
|
||||||
opacity: 0.65
|
opacity: 0.65
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,18 +384,20 @@ ColumnLayout {
|
|||||||
// Tertiary: edited metadata (chamber · notes) if any
|
// Tertiary: edited metadata (chamber · notes) if any
|
||||||
Label {
|
Label {
|
||||||
visible: {
|
visible: {
|
||||||
var c = modelData.chamber || ""
|
var c = modelData.chamber || "";
|
||||||
var n = modelData.notes || ""
|
var n = modelData.notes || "";
|
||||||
return c !== "" || n !== ""
|
return c !== "" || n !== "";
|
||||||
}
|
}
|
||||||
text: {
|
text: {
|
||||||
var parts = []
|
var parts = [];
|
||||||
if (modelData.chamber) parts.push(modelData.chamber)
|
if (modelData.chamber)
|
||||||
if (modelData.notes) parts.push(modelData.notes)
|
parts.push(modelData.chamber);
|
||||||
return parts.join(" · ")
|
if (modelData.notes)
|
||||||
|
parts.push(modelData.notes);
|
||||||
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 9
|
font.pixelSize: Theme.fontXs
|
||||||
opacity: 0.7
|
opacity: 0.7
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -285,7 +411,7 @@ ColumnLayout {
|
|||||||
Label {
|
Label {
|
||||||
text: file_browser.files.length + " files · CSV only"
|
text: file_browser.files.length + " files · CSV only"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: 10
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectFileDialog {
|
SelectFileDialog {
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ===== Split Dialog (Threshold Segmentation) =====
|
||||||
|
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
|
||||||
|
// Backend: splitter.segment_profile() returns list of DataSegment objects.
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: root
|
||||||
|
modal: true
|
||||||
|
dim: true
|
||||||
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
width: Math.min(parent.width - 100, 800)
|
||||||
|
height: Math.min(parent.height - 100, 600)
|
||||||
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
property real threshold: 40.0
|
||||||
|
property bool segmenting: false
|
||||||
|
property var segments: []
|
||||||
|
property string currentFile: streamController.loadedFile || ""
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 20
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SPLIT DATA"
|
||||||
|
font.pixelSize: Theme.fontLg
|
||||||
|
font.bold: true
|
||||||
|
color: Theme.headingColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "\u2715"
|
||||||
|
flat: true
|
||||||
|
Layout.preferredWidth: 32
|
||||||
|
Layout.preferredHeight: 32
|
||||||
|
onClicked: root.close()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Threshold Input ───────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "THRESHOLD TEMPERATURE (°C)"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SpinBox {
|
||||||
|
id: thresholdSpin
|
||||||
|
from: 0
|
||||||
|
to: 200
|
||||||
|
value: root.threshold
|
||||||
|
stepSize: 1
|
||||||
|
editable: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "°C"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "SESSION CSV"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: root.currentFile !== ""
|
||||||
|
? root.currentFile.split("/").pop()
|
||||||
|
: "No file loaded"
|
||||||
|
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split Button ──────────────────────────────────────────
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: splitBtn
|
||||||
|
text: "SPLIT"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !root.segmenting && root.currentFile !== ""
|
||||||
|
onClicked: {
|
||||||
|
root.threshold = thresholdSpin.value;
|
||||||
|
root.segmenting = true;
|
||||||
|
streamController.splitData(root.currentFile, root.threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
|
||||||
|
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 8
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
|
||||||
|
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
running: root.segmenting
|
||||||
|
visible: root.segmenting
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Segments Table ────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.panelBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 0
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 36
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label { text: "PHASE"; Layout.preferredWidth: 100; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Item { Layout.preferredWidth: 80 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table body (scrollable)
|
||||||
|
ScrollView {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
width: parent.width - 32
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.segments.length > 0 ? root.segments : []
|
||||||
|
delegate: Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: modelData.label || ""
|
||||||
|
Layout.preferredWidth: 100
|
||||||
|
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
|
||||||
|
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.start_frame || 0).toString()
|
||||||
|
Layout.preferredWidth: 120
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.end_frame || 0).toString()
|
||||||
|
Layout.preferredWidth: 120
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: exportBtn
|
||||||
|
text: "Export"
|
||||||
|
Layout.preferredWidth: 80
|
||||||
|
Layout.preferredHeight: 26
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: streamController.exportSegment(index)
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: exportBtn.pressed ? Theme.buttonNeutralPressed
|
||||||
|
: exportBtn.hovered ? Theme.buttonNeutralHover
|
||||||
|
: Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.fieldBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
contentItem: Label {
|
||||||
|
text: exportBtn.text
|
||||||
|
color: Theme.buttonNeutralText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
Label {
|
||||||
|
visible: root.segments.length === 0
|
||||||
|
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 100
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary line ──────────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
visible: root.segments.length > 0
|
||||||
|
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
|
||||||
|
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export feedback ───────────────────────────────────────
|
||||||
|
Label {
|
||||||
|
id: exportStatus
|
||||||
|
visible: text !== ""
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backend Connection ────────────────────────────────────────
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onSplitResult(result) {
|
||||||
|
root.segmenting = false;
|
||||||
|
exportStatus.text = "";
|
||||||
|
if (result && result.success) {
|
||||||
|
root.segments = result.segments || [];
|
||||||
|
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
||||||
|
} else {
|
||||||
|
root.segments = [];
|
||||||
|
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSegmentExported(result) {
|
||||||
|
if (result && result.success)
|
||||||
|
exportStatus.text = "Exported: " + result.path.split("/").pop();
|
||||||
|
else
|
||||||
|
exportStatus.text = "Export failed: " + (result.error || "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
import ISC.Tabs.components
|
||||||
|
|
||||||
|
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||||
|
ColumnLayout {
|
||||||
|
spacing: Theme.sideRailSpacing
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: hwActionsCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "HARDWARE ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "DETECT WAFER"
|
||||||
|
iconSource: "../icons/detect.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !deviceController.operationInProgress
|
||||||
|
onClicked: root._doDetect()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "READ MEMORY"
|
||||||
|
iconSource: "../icons/read.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.readMemoryAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "ERASE MEMORY"
|
||||||
|
iconSource: "../icons/erase.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
destructive: true
|
||||||
|
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.eraseMemory(
|
||||||
|
deviceController.selectedPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "READ DEBUG"
|
||||||
|
iconSource: "../icons/read.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
// One-shot toggle: "on" while the debug read is in flight,
|
||||||
|
// resets automatically once deviceController flips status.
|
||||||
|
toggled: deviceController.connectionStatus === "Reading debug..."
|
||||||
|
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.readDebug(deviceController.selectedPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: logActionsCol.implicitHeight + 28
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: logActionsCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "LOG ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "REFRESH SESSION"
|
||||||
|
iconSource: "../icons/refresh.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
enabled: !deviceController.operationInProgress
|
||||||
|
onClicked: deviceController.clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "CLEAR LOG"
|
||||||
|
iconSource: "../icons/x.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
onClicked: deviceController.clearActivityLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Dialogs
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: contentCol.implicitHeight + 24
|
||||||
|
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
signal exportRequested(string filePath, string extra)
|
||||||
|
|
||||||
|
property int _liveSecs: 0
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onModeChanged() {
|
||||||
|
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
|
||||||
|
modeBar.currentIndex = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToReview() {
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: contentCol
|
||||||
|
anchors {
|
||||||
|
fill: parent
|
||||||
|
topMargin: 10
|
||||||
|
leftMargin: 12
|
||||||
|
rightMargin: 12
|
||||||
|
bottomMargin: 12
|
||||||
|
}
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
TabBar {
|
||||||
|
id: modeBar
|
||||||
|
currentIndex: 0
|
||||||
|
spacing: 2
|
||||||
|
padding: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 28
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TabButton {
|
||||||
|
text: "Review"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 24
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusXs - 1
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TabButton {
|
||||||
|
id: liveTab
|
||||||
|
text: "Live"
|
||||||
|
enabled: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 24
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: disabledHover.containsMouse && !liveTab.enabled
|
||||||
|
text: "Connect a wafer to enable Live mode"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: disabledHover
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: !liveTab.enabled
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||||
|
radius: Theme.radiusXs - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
Row {
|
||||||
|
spacing: 4
|
||||||
|
anchors.centerIn: parent
|
||||||
|
Rectangle {
|
||||||
|
id: liveTabDot
|
||||||
|
width: 5; height: 5; radius: 2.5
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: streamController.mode === "live" && 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) liveTabDot.opacity = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: liveTab.text
|
||||||
|
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
||||||
|
font: liveTab.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
height: parent.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCurrentIndexChanged: {
|
||||||
|
if (currentIndex === 1) {
|
||||||
|
if (deviceController.connectionStatus !== "Connected") {
|
||||||
|
currentIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamController.setMode("live");
|
||||||
|
var fc = "";
|
||||||
|
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
||||||
|
fc = deviceController.lastWaferInfo[0];
|
||||||
|
}
|
||||||
|
streamController.startStream(deviceController.selectedPort, fc);
|
||||||
|
} else {
|
||||||
|
streamController.setMode("review");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "METRICS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Received Frames"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Errors"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
|
||||||
|
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Resyncs"
|
||||||
|
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricRow {
|
||||||
|
label: "Elapsed"
|
||||||
|
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: recordBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 28
|
||||||
|
enabled: streamController.mode === "live"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
text: streamController.recording ? "Stop REC" : "Record"
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: recordBtn.hovered && !recordBtn.enabled
|
||||||
|
text: "Connect to Live to start recording"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.recording) {
|
||||||
|
streamController.stopRecording();
|
||||||
|
} else {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
var serial = (info && info.length > 1) ? info[1] : "";
|
||||||
|
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
||||||
|
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
||||||
|
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: recordBtn.down ? Theme.transportButtonHover
|
||||||
|
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: recordBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: exportBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 28
|
||||||
|
text: "Export"
|
||||||
|
// Nothing to export until a file is loaded or a stream has played
|
||||||
|
enabled: streamController.sensorValues.length > 0
|
||||||
|
opacity: enabled ? 1 : 0.4
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
// Default into <saveDataDir>/Export (created on demand)
|
||||||
|
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
|
||||||
|
exportDialog.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: exportBtn.down ? Theme.transportButtonHover
|
||||||
|
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: exportBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: exportDialog
|
||||||
|
title: "Export Wafer Map"
|
||||||
|
fileMode: FileDialog.SaveFile
|
||||||
|
defaultSuffix: "png"
|
||||||
|
nameFilters: ["PNG files (*.png)"]
|
||||||
|
onAccepted: {
|
||||||
|
var path = String(selectedFile).replace(/^file:\/\//, "");
|
||||||
|
var extra = "";
|
||||||
|
if (streamController.mode === "live") {
|
||||||
|
extra = "Frames: " + streamController.receivedCount
|
||||||
|
+ " Errors: " + streamController.errorCount
|
||||||
|
+ " Resyncs: " + streamController.resyncCount
|
||||||
|
+ " Elapsed: " + root.fmtTime(root._liveSecs);
|
||||||
|
}
|
||||||
|
root.exportRequested(path, extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: 1
|
||||||
|
color: Theme.cardBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: primaryBtn
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: 32
|
||||||
|
text: streamController.mode === "live" ? "STOP" : "START"
|
||||||
|
enabled: streamController.mode === "live"
|
||||||
|
? true
|
||||||
|
: deviceController.connectionStatus === "Connected"
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: primaryBtn.hovered && !primaryBtn.enabled
|
||||||
|
text: "Connect a wafer to enable Live mode"
|
||||||
|
delay: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.mode === "live") {
|
||||||
|
streamController.stopStream();
|
||||||
|
modeBar.currentIndex = 0;
|
||||||
|
} else {
|
||||||
|
modeBar.currentIndex = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: primaryBtn.down ? Theme.transportButtonHover
|
||||||
|
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: primaryBtn.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.letterSpacing: 1.0
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +1,284 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
RowLayout {
|
// ===== Control Bar =====
|
||||||
spacing: 2
|
// Review-mode transport footer (frame counter, play/pause/step, speed).
|
||||||
|
// Live-mode's Received/Errors/Stop moved to ReadoutPanel's "LIVE STREAM"
|
||||||
component TBtn: Button {
|
// card so this bar can collapse to zero height and free vertical space for
|
||||||
implicitWidth: 44
|
// the trend chart while streaming.
|
||||||
implicitHeight: 32
|
Item {
|
||||||
hoverEnabled: true
|
id: bar
|
||||||
font.pixelSize: 13
|
Layout.fillWidth: true
|
||||||
background: Rectangle {
|
implicitHeight: hasContent ? 80 : 0
|
||||||
color: parent.pressed ? Theme.transportButtonHover
|
Behavior on implicitHeight {
|
||||||
: parent.hovered ? Theme.transportButtonBg
|
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
||||||
: Theme.transportBackground
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: Theme.headingColor
|
|
||||||
font: parent.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
clip: true
|
||||||
|
|
||||||
// Frame counter badge on the left
|
// Only shown in review mode, and only once a file is loaded
|
||||||
Rectangle {
|
readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
|
||||||
id: frameCounter
|
|
||||||
implicitWidth: frameCounterText.implicitWidth + 16
|
|
||||||
implicitHeight: 32
|
|
||||||
color: Theme.fieldBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
Text {
|
// ── REVIEW MODE ──────────────────────────────────────────────
|
||||||
id: frameCounterText
|
Item {
|
||||||
|
id: reviewContent
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
// Single centered pill for frame counter + transport + speed
|
||||||
|
Rectangle {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "Frame " + (streamController.frameIndex + 1) + " / " + streamController.frameTotal
|
implicitWidth: reviewRow.implicitWidth + 48
|
||||||
color: Theme.headingColor
|
implicitHeight: 68
|
||||||
font.pixelSize: 11
|
radius: Theme.radiusMd
|
||||||
font.weight: Font.Medium
|
color: Theme.sidePanelBackground
|
||||||
}
|
border.color: Theme.sideBorder
|
||||||
}
|
border.width: 1
|
||||||
|
|
||||||
// Left fill — centers the button cluster
|
Row {
|
||||||
Item { Layout.fillWidth: true }
|
id: reviewRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 24
|
||||||
|
|
||||||
TBtn { text: "⏮"; onClicked: streamController.step(-1) }
|
// Frame counter
|
||||||
TBtn { text: "⏹"; onClicked: streamController.stop() }
|
Row {
|
||||||
TBtn { text: "⏸"; onClicked: streamController.pause() }
|
spacing: 6
|
||||||
// Play: slightly wider; leftPadding nudge compensates for ▶ optical offset
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
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
|
Label {
|
||||||
Item { Layout.fillWidth: true }
|
text: "Frame"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
// Speed cycle
|
TextField {
|
||||||
Button {
|
id: frameInput
|
||||||
id: speedBtn
|
text: String(streamController.frameIndex + 1)
|
||||||
property var speeds: [1, 2, 5]
|
font.pixelSize: Theme.fontMd
|
||||||
property int idx: 0
|
font.weight: Font.Medium
|
||||||
implicitWidth: 44
|
font.family: Theme.codeFontFamily
|
||||||
implicitHeight: 32
|
color: Theme.headingColor
|
||||||
hoverEnabled: true
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
text: speeds[idx] + "×"
|
horizontalAlignment: TextInput.AlignHCenter
|
||||||
font.pixelSize: 11
|
selectByMouse: true
|
||||||
font.weight: Font.Medium
|
inputMethodHints: Qt.ImhDigitsOnly
|
||||||
background: Rectangle {
|
padding: 0
|
||||||
color: parent.pressed ? Theme.transportButtonHover
|
width: Math.max(44, contentWidth + 16)
|
||||||
: parent.hovered ? Theme.transportButtonBg
|
height: 28
|
||||||
: Theme.transportBackground
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
background: Rectangle {
|
||||||
contentItem: Text {
|
color: Theme.fieldBackground
|
||||||
text: parent.text
|
radius: Theme.radiusXs
|
||||||
color: Theme.bodyColor
|
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
|
||||||
font: parent.font
|
border.width: 1
|
||||||
horizontalAlignment: Text.AlignHCenter
|
}
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
onEditingFinished: {
|
||||||
onClicked: {
|
var val = parseInt(text)
|
||||||
idx = (idx + 1) % speeds.length
|
if (!isNaN(val)) {
|
||||||
streamController.setSpeed(speeds[idx])
|
var targetFrame = Math.max(1, Math.min(val, streamController.frameTotal))
|
||||||
|
streamController.seek(targetFrame - 1)
|
||||||
|
}
|
||||||
|
text = String(streamController.frameIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: streamController
|
||||||
|
function onFrameUpdated() {
|
||||||
|
if (!frameInput.activeFocus) {
|
||||||
|
frameInput.text = String(streamController.frameIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "/ " + streamController.frameTotal
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator 1
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1
|
||||||
|
height: 36
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media Buttons Row
|
||||||
|
Row {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Skip-start
|
||||||
|
Button {
|
||||||
|
id: skipStartBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 56
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: skipStartBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: skipStartBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/prev.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: streamController.step(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
Button {
|
||||||
|
id: reviewStopBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 56
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: reviewStopBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: reviewStopBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/stop.svg"
|
||||||
|
width: 20; height: 20
|
||||||
|
sourceSize.width: 20
|
||||||
|
sourceSize.height: 20
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: streamController.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play / Pause toggle
|
||||||
|
Button {
|
||||||
|
id: playPauseBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 56
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: playPauseBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: playPauseBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
if (streamController.playing) {
|
||||||
|
streamController.pause();
|
||||||
|
} else {
|
||||||
|
streamController.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip-end
|
||||||
|
Button {
|
||||||
|
id: skipEndBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
implicitWidth: 56
|
||||||
|
implicitHeight: 56
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: skipEndBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: skipEndBtn.hovered ? Theme.transportButtonBg
|
||||||
|
: "transparent"
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/next.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
|
color: Theme.headingColor
|
||||||
|
anchors.centerIn: parent
|
||||||
|
}
|
||||||
|
onClicked: streamController.step(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator 2
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 1
|
||||||
|
height: 36
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speed capsule button
|
||||||
|
Button {
|
||||||
|
id: speedBtn
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
property var speeds: [1, 2, 4]
|
||||||
|
property int idx: 0
|
||||||
|
implicitWidth: 72
|
||||||
|
implicitHeight: 48
|
||||||
|
hoverEnabled: true
|
||||||
|
text: speeds[idx] + "x"
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: speedBtn.pressed ? Theme.transportButtonHover
|
||||||
|
: Theme.transportButtonBg
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: parent.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font: parent.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: {
|
||||||
|
idx = (idx + 1) % speeds.length;
|
||||||
|
streamController.setSpeed(speeds[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
id: utilFooter
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: Theme.sideFooterUtility
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
property var settingsPopup: null
|
||||||
|
property var aboutDialog: null
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 4
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: settingsBtn
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
icon.source: "../icons/settings.svg"
|
||||||
|
onClicked: if (settingsPopup) settingsPopup.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: settingsBtn.hovered
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 6
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
source: "../icons/settings.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.headingColor
|
||||||
|
opacity: 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "SETTINGS"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 1
|
||||||
|
height: 24
|
||||||
|
color: Theme.sideBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: aboutBtn
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
icon.source: "../icons/about.svg"
|
||||||
|
onClicked: if (aboutDialog) aboutDialog.open()
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
radius: 8
|
||||||
|
color: aboutBtn.hovered
|
||||||
|
? Theme.sideActiveBackground : "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Row {
|
||||||
|
spacing: 6
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
source: "../icons/about.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.headingColor
|
||||||
|
opacity: 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "ABOUT"
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.2
|
||||||
|
opacity: 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ import QtQuick.Controls
|
|||||||
import ISC
|
import ISC
|
||||||
import ISC.Wafer
|
import ISC.Wafer
|
||||||
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
property real blend: 0.0
|
property real blend: 0.0
|
||||||
property bool showLabels: true
|
property bool showLabels: true
|
||||||
|
property alias showExtremes: map.showExtremes
|
||||||
|
property alias showThickness: map.showThickness
|
||||||
|
property alias hasThickness: map.hasThickness
|
||||||
|
|
||||||
WaferMapItem {
|
WaferMapItem {
|
||||||
id: map
|
id: map
|
||||||
@@ -22,24 +24,51 @@ Item {
|
|||||||
blend: root.blend
|
blend: root.blend
|
||||||
showLabels: root.showLabels
|
showLabels: root.showLabels
|
||||||
// Bind to Theme so colors update on dark/light mode switch
|
// Bind to Theme so colors update on dark/light mode switch
|
||||||
ringColor: Theme.waferRingColor
|
ringColor: Theme.waferRingColor
|
||||||
axisColor: Theme.waferAxisColor
|
axisColor: Theme.waferAxisColor
|
||||||
lowColor: Theme.sensorLow
|
lowColor: Theme.sensorLow
|
||||||
inRangeColor: Theme.sensorInRange
|
inRangeColor: Theme.sensorInRange
|
||||||
highColor: Theme.sensorHigh
|
highColor: Theme.sensorHigh
|
||||||
textColor: Theme.headingColor
|
textColor: Theme.headingColor
|
||||||
|
|
||||||
|
// Grows the hovered marker smoothly; hoverScale setter triggers a repaint.
|
||||||
|
hoverScale: 1.0 + 0.35 * hoverPulse
|
||||||
|
property real hoverPulse: map.hoveredIndex >= 0 ? 1.0 : 0.0
|
||||||
|
Behavior on hoverPulse {
|
||||||
|
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hoverHandler
|
||||||
|
onPointChanged: {
|
||||||
|
map.hoveredIndex = streamController.mode === "live"
|
||||||
|
? -1
|
||||||
|
: map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
||||||
|
}
|
||||||
|
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
||||||
|
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
}
|
||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
onTapped: (ev) => {
|
onTapped: ev => {
|
||||||
var idx = map.which_marker(ev.position.x, ev.position.y)
|
if (streamController.mode === "live")
|
||||||
if (idx >= 0) replaceDialog.openFor(idx)
|
return;
|
||||||
|
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||||
|
if (idx >= 0)
|
||||||
|
replaceDialog.openFor(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ReplaceSensorDialog { id: replaceDialog}
|
ReplaceSensorDialog {
|
||||||
|
id: replaceDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportImage(filePath, extra) {
|
||||||
|
return map.export_image(filePath, extra || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThickness(filePath) {
|
||||||
|
return map.loadThickness(filePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,3 +5,22 @@ ReadoutPanel 1.0 ReadoutPanel.qml
|
|||||||
TransportBar 1.0 TransportBar.qml
|
TransportBar 1.0 TransportBar.qml
|
||||||
WaferMapView 1.0 WaferMapView.qml
|
WaferMapView 1.0 WaferMapView.qml
|
||||||
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
||||||
|
RailActionButton 1.0 RailActionButton.qml
|
||||||
|
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||||
|
SplitDialog 1.0 SplitDialog.qml
|
||||||
|
StreamControlPanel 1.0 StreamControlPanel.qml
|
||||||
|
StatusActionsPanel 1.0 StatusActionsPanel.qml
|
||||||
|
ConnectionFooter 1.0 ConnectionFooter.qml
|
||||||
|
UtilityFooter 1.0 UtilityFooter.qml
|
||||||
|
PanelCheckBox 1.0 PanelCheckBox.qml
|
||||||
|
ReadoutStat 1.0 ReadoutStat.qml
|
||||||
|
MetricRow 1.0 MetricRow.qml
|
||||||
|
AppToolTip 1.0 AppToolTip.qml
|
||||||
|
SectionTitle 1.0 SectionTitle.qml
|
||||||
|
PanelBox 1.0 PanelBox.qml
|
||||||
|
PanelSlider 1.0 PanelSlider.qml
|
||||||
|
EditableScrubStat 1.0 EditableScrubStat.qml
|
||||||
|
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
||||||
|
OverlapMapCard 1.0 OverlapMapCard.qml
|
||||||
|
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
|
||||||
|
RunChart 1.0 RunChart.qml
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 294 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m12 19-7-7 7-7" />
|
||||||
|
<path d="M19 12H5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" x2="18" y1="20" y2="10" />
|
||||||
|
<line x1="12" x2="12" y1="20" y2="4" />
|
||||||
|
<line x1="6" x2="6" y1="20" y2="14" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 3 4 7l4 4" />
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="m16 21 4-4-4-4" />
|
||||||
|
<path d="M20 17H4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||||
|
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||||
|
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m21 21-4.34-4.34" />
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 275 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 11v6" />
|
||||||
|
<path d="M14 11v6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(-1.5, 0)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.29289 1.29289C9.48043 1.10536 9.73478 1 10 1H18C19.6569 1 21 2.34315 21 4V9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V4C19 3.44772 18.5523 3 18 3H11V8C11 8.55228 10.5523 9 10 9H5V20C5 20.5523 5.44772 21 6 21H7C7.55228 21 8 21.4477 8 22C8 22.5523 7.55228 23 7 23H6C4.34315 23 3 21.6569 3 20V8C3 7.73478 3.10536 7.48043 3.29289 7.29289L9.29289 1.29289ZM6.41421 7H9V4.41421L6.41421 7ZM19 12C19.5523 12 20 12.4477 20 13V19H23C23.5523 19 24 19.4477 24 20C24 20.5523 23.5523 21 23 21H19C18.4477 21 18 20.5523 18 20V13C18 12.4477 18.4477 12 19 12ZM11.8137 12.4188C11.4927 11.9693 10.8682 11.8653 10.4188 12.1863C9.96935 12.5073 9.86526 13.1318 10.1863 13.5812L12.2711 16.5L10.1863 19.4188C9.86526 19.8682 9.96935 20.4927 10.4188 20.8137C10.8682 21.1347 11.4927 21.0307 11.8137 20.5812L13.5 18.2205L15.1863 20.5812C15.5073 21.0307 16.1318 21.1347 16.5812 20.8137C17.0307 20.4927 17.1347 19.8682 16.8137 19.4188L14.7289 16.5L16.8137 13.5812C17.1347 13.1318 17.0307 12.5073 16.5812 12.1863C16.1318 11.8653 15.5073 11.9693 15.1863 12.4188L13.5 14.7795L11.8137 12.4188Z" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||||
|
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||||
|
<path d="M10 9H8" />
|
||||||
|
<path d="M16 13H8" />
|
||||||
|
<path d="M16 17H8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M3 9h18" />
|
||||||
|
<path d="M3 15h18" />
|
||||||
|
<path d="M9 3v18" />
|
||||||
|
<path d="M15 3v18" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 355 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="14" y="3" width="5" height="18" rx="1" />
|
||||||
|
<rect x="6" y="3" width="5" height="18" rx="1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polygon points="6 3 20 12 6 21 6 3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 238 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1,16 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||||
|
<path d="M21 3v5h-5" />
|
||||||
|
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||||
|
<path d="M3 21v-5h5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||||
|
<path d="M3 3v5h5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 586 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||||
|
<path d="M12 9v4" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 260 B |
@@ -5,4 +5,3 @@ WaferMapTab 1.0 WaferMapTab.qml
|
|||||||
DataTab 1.0 DataTab.qml
|
DataTab 1.0 DataTab.qml
|
||||||
SettingsTab 1.0 SettingsTab.qml
|
SettingsTab 1.0 SettingsTab.qml
|
||||||
StatusTab 1.0 StatusTab.qml
|
StatusTab 1.0 StatusTab.qml
|
||||||
SelectFileDialog 1.0 SelectFileDialog.qml
|
|
||||||
|
|||||||
@@ -7,110 +7,191 @@ import QtQuick
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Sections:
|
// Sections:
|
||||||
// 1. Mode flag
|
// 1. Mode flag
|
||||||
// 2. Tone palette (raw tones; everything else derives from these)
|
// 2. Typography (families, pixel sizes, weights)
|
||||||
// 3. Surfaces (page / card / panel / workspace backgrounds)
|
// 3. Motion
|
||||||
// 4. Borders
|
// 4. Tone palette (raw tones; everything else derives from these)
|
||||||
// 5. Text (headings, body, panel titles)
|
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
||||||
// 6. Controls (fields, buttons, checkbox)
|
// 6. Borders
|
||||||
// 7. Tracks (pill toggle + scrollbar)
|
// 7. Text (headings, body, panel titles)
|
||||||
// 8. Tabs (footer tab bar)
|
// 8. Controls (fields, buttons, checkbox, toggle)
|
||||||
// 9. Side rail
|
// 9. Interaction overlays (hover / active washes — mode-aware)
|
||||||
// 10. Status (success / warning / error)
|
// 10. Tracks (pill toggle + scrollbar)
|
||||||
// 11. Geometry (radius / border width / spacing / sizing)
|
// 11. Tabs (mode toggle pills)
|
||||||
|
// 12. Side rail
|
||||||
|
// 13. Transport / toolbar
|
||||||
|
// 14. Status & badges (success / warning / error, badge text)
|
||||||
|
// 15. Sensor bands (wafer map dots)
|
||||||
|
// 16. Wafer family badges (source list avatars + accent bars)
|
||||||
|
// 17. Comparison & charts (metric grading, diff accent, canvas grid)
|
||||||
|
// 18. Geometry (radius / border width / spacing / sizing)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
// Based on Codex themes:
|
||||||
|
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6
|
||||||
|
// Light (absolutely): surface #F9F9F7, ink #2D2D2B
|
||||||
|
// Accent (both): #F9B98C
|
||||||
|
|
||||||
QtObject {
|
QtObject {
|
||||||
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
||||||
property bool isDarkMode: true
|
property bool isDarkMode: true
|
||||||
|
|
||||||
// ── 2. Tone palette (base values; consume via semantic tokens below) ─────
|
// ── 2. Typography ────────────────────────────────────────────────────────
|
||||||
readonly property color tone100: isDarkMode ? "#111111" : "#FAFAFA"
|
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
|
||||||
readonly property color tone150: isDarkMode ? "#161616" : "#F6F6F6"
|
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
|
||||||
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 toneMute: isDarkMode ? "#A8A8A8" : "#8A8A8A"
|
|
||||||
readonly property color toneBorder: isDarkMode ? "#2E2E2E" : "#D4D4D4"
|
|
||||||
|
|
||||||
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
// pixelSize scale — map to closest token, tune globally
|
||||||
|
readonly property int font2xs: 9 // tiny metadata
|
||||||
|
readonly property int fontXs: 11 // captions, micro labels
|
||||||
|
readonly property int fontSm: 13 // sidebar labels, secondary
|
||||||
|
readonly property int fontMd: 14 // body, field values
|
||||||
|
readonly property int fontLg: 16 // section headers
|
||||||
|
readonly property int fontXl: 20 // panel / dialog titles
|
||||||
|
readonly property int font2xl: 24 // hero titles
|
||||||
|
readonly property int font3xl: 30 // display
|
||||||
|
|
||||||
|
readonly property int fontWeightRegular: 400
|
||||||
|
readonly property int fontWeightMedium: 500
|
||||||
|
readonly property int fontWeightBold: 700
|
||||||
|
|
||||||
|
// ── 3. Motion ────────────────────────────────────────────────────────────
|
||||||
|
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
|
||||||
|
readonly property int durationBase: 180 // standard transitions (pill switch, row select)
|
||||||
|
readonly property int durationSlow: 260 // larger transitions (panel open/close)
|
||||||
|
readonly property var easeStandard: Easing.OutCubic
|
||||||
|
|
||||||
|
// ── 4. Tone palette (Codex theme v1 inspired) ────────────────────────────
|
||||||
|
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
|
||||||
|
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
|
||||||
|
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
|
||||||
|
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
|
||||||
|
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
|
||||||
|
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
|
||||||
|
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
|
||||||
|
|
||||||
|
readonly property color tone100: themeSurface
|
||||||
|
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
|
||||||
|
readonly property color tone200: isDarkMode ? "#151518" : "#EEEEEC"
|
||||||
|
readonly property color tone250: isDarkMode ? "#1B1B1F" : "#E8E8E6"
|
||||||
|
readonly property color tone300: isDarkMode ? "#222228" : "#E0E0DE"
|
||||||
|
readonly property color tone350: isDarkMode ? "#2B2B32" : "#D7D7D4"
|
||||||
|
readonly property color toneText: themeInk
|
||||||
|
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
|
||||||
|
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
|
||||||
|
|
||||||
|
// ── 5. Surfaces ──────────────────────────────────────────────────────────
|
||||||
readonly property color pageBackground: tone100
|
readonly property color pageBackground: tone100
|
||||||
readonly property color cardBackground: tone200
|
readonly property color cardBackground: tone150
|
||||||
readonly property color panelBackground: tone200
|
readonly property color panelBackground: tone150
|
||||||
readonly property color workspaceBackground: tone200
|
readonly property color workspaceBackground: tone150
|
||||||
readonly property color responseBackground: tone200
|
readonly property color responseBackground: tone150
|
||||||
readonly property color subtleSectionBackground: tone300
|
readonly property color subtleSectionBackground: tone250
|
||||||
|
|
||||||
// ── 4. Borders ───────────────────────────────────────────────────────────
|
// ── 6. Borders ───────────────────────────────────────────────────────────
|
||||||
readonly property color cardBorder: toneBorder
|
readonly property color cardBorder: toneBorder
|
||||||
readonly property color cardSurfaceBorder: isDarkMode ? "#3C3C3C" : "#C8C8C8"
|
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||||
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 ? "#383838" : "#CACACA"
|
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||||
readonly property color softBorder: isDarkMode ? "#252525" : "#E0E0E0"
|
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
|
||||||
|
|
||||||
// ── 5. Text ──────────────────────────────────────────────────────────────
|
// ── 7. Text ──────────────────────────────────────────────────────────────
|
||||||
readonly property color headingColor: toneText
|
readonly property color headingColor: toneText
|
||||||
readonly property color bodyColor: toneMute
|
readonly property color bodyColor: toneMute
|
||||||
readonly property color subheadingColor: toneMute
|
readonly property color subheadingColor: toneMute
|
||||||
readonly property color panelTitleText: toneMute
|
readonly property color panelTitleText: toneMute
|
||||||
readonly property color disabledText: toneMute
|
readonly property color disabledText: toneMute
|
||||||
|
|
||||||
// ── 6. Controls ──────────────────────────────────────────────────────────
|
// ── 8. Controls ──────────────────────────────────────────────────────────
|
||||||
// Fields
|
// Fields
|
||||||
readonly property color fieldBackground: tone100
|
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
|
||||||
readonly property color fieldText: toneText
|
readonly property color fieldText: toneText
|
||||||
readonly property color fieldPlaceholder: toneMute
|
readonly property color fieldPlaceholder: toneMute
|
||||||
readonly property color fieldBorder: toneBorder
|
readonly property color fieldBorder: toneBorder
|
||||||
readonly property color fieldBorderFocus: toneText
|
readonly property color fieldBorderFocus: themeAccent
|
||||||
// Neutral buttons
|
// Neutral buttons
|
||||||
readonly property color buttonNeutralBackground: tone300
|
readonly property color buttonNeutralBackground: tone250
|
||||||
readonly property color buttonNeutralHover: isDarkMode ? "#303030" : "#DDDDDD"
|
readonly property color buttonNeutralHover: tone300
|
||||||
readonly property color buttonNeutralPressed: tone100
|
readonly property color buttonNeutralPressed: isDarkMode ? "#0F0F13" : "#FFFFFF"
|
||||||
readonly property color buttonNeutralText: toneText
|
readonly property color buttonNeutralText: toneText
|
||||||
readonly property color buttonPressed: tone300
|
readonly property color buttonPressed: tone300
|
||||||
readonly property color primaryAccent: toneText
|
readonly property color primaryAccent: themeAccent
|
||||||
// Checkbox
|
// Checkbox
|
||||||
readonly property color checkboxText: toneText
|
readonly property color checkboxText: toneText
|
||||||
|
// Toggle switch thumb (white knob reads on both track colors)
|
||||||
|
readonly property color toggleThumb: "#FFFFFF"
|
||||||
|
|
||||||
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
|
// ── 9. Interaction overlays ──────────────────────────────────────────────
|
||||||
readonly property color trackBackground: isDarkMode ? "#25252B" : "#D1D5DB"
|
// Mode-aware washes. Never use raw Qt.rgba(1,1,1,x) in views: a white
|
||||||
|
// overlay is invisible on light-mode surfaces.
|
||||||
|
readonly property color listHoverBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.04) : Qt.rgba(0, 0, 0, 0.03)
|
||||||
|
readonly property color listActiveBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.05)
|
||||||
|
readonly property color flatButtonHover: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.06)
|
||||||
|
readonly property color cardWash: isDarkMode ? Qt.rgba(1, 1, 1, 0.02) : Qt.rgba(0, 0, 0, 0.02)
|
||||||
|
|
||||||
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
|
// ── 10. Tracks (pill toggle, scrollbar thumb) ────────────────────────────
|
||||||
readonly property color tabBarBackground: tone200
|
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
|
||||||
readonly property color tabBackground: "transparent"
|
|
||||||
readonly property color tabActiveBackground: isDarkMode ? "#333333" : "#FFFFFF"
|
|
||||||
readonly property color tabHoverBackground: tone300
|
|
||||||
readonly property color tabBorder: "transparent"
|
|
||||||
readonly property color tabText: toneMute
|
|
||||||
readonly property color tabActiveText: toneText
|
|
||||||
|
|
||||||
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
// ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
|
||||||
readonly property color sideRailBackground: tone200
|
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
readonly property color sideActiveBackground: tone300
|
|
||||||
|
|
||||||
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
|
// ── 12. Side rail ────────────────────────────────────────────────────────
|
||||||
readonly property color transportBackground: isDarkMode ? "#0D0D0D" : "#E8E8E8"
|
readonly property color sideRailBackground: tone150
|
||||||
readonly property color transportButtonBg: isDarkMode ? "#2A2A2A" : "#D4D4D4"
|
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
|
||||||
readonly property color transportButtonHover: isDarkMode ? "#3A3A3A" : "#C4C4C4"
|
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
readonly property color liveColor: isDarkMode ? "#22C55E" : "#16A34A"
|
readonly property color sideBorder: toneBorder
|
||||||
readonly property color recordColor: isDarkMode ? "#EF4444" : "#DC2626"
|
readonly property color sideMutedText: toneMute
|
||||||
|
|
||||||
// ── 10. Status ───────────────────────────────────────────────────────────
|
// ── 13. Transport / toolbar surfaces ─────────────────────────────────────
|
||||||
readonly property color statusSuccessColor: isDarkMode ? "#63D471" : "#2E9E44"
|
readonly property color transportButtonBg: tone250
|
||||||
readonly property color statusWarningColor: isDarkMode ? "#F5C15C" : "#C88A18"
|
readonly property color transportButtonHover: tone300
|
||||||
readonly property color statusErrorColor: isDarkMode ? "#FF6B6B" : "#D64545"
|
readonly property color liveColor: themeAdded
|
||||||
|
readonly property color recordColor: themeRemoved
|
||||||
|
|
||||||
// -- 10b. Sensor bands (wafer map dots)
|
// ── 14. Status & badges ──────────────────────────────────────────────────
|
||||||
|
readonly property color statusSuccessColor: themeAdded
|
||||||
|
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
|
||||||
|
readonly property color statusErrorColor: themeRemoved
|
||||||
|
// Text placed ON a filled status pill / saturated chip:
|
||||||
|
readonly property color statusBadgeText: "#111111" // dark ink on bright status fills, both modes
|
||||||
|
readonly property color textOnColor: "#FFFFFF" // white text on deep saturated fills (avatars etc.)
|
||||||
|
// Error banner (soft red surface + readable red text)
|
||||||
|
readonly property color errorSurface: Qt.alpha(statusErrorColor, 0.15)
|
||||||
|
readonly property color errorTextSoft: isDarkMode ? "#FCA5A5" : "#B91C1C"
|
||||||
|
|
||||||
|
// ── 15. Sensor bands (wafer map dots) ────────────────────────────────────
|
||||||
readonly property color sensorInRange: statusSuccessColor
|
readonly property color sensorInRange: statusSuccessColor
|
||||||
|
// ponytail: high-temp shares error red — semantically correct (dangerous value),
|
||||||
|
// re-hue if colorblind testing demands a different channel.
|
||||||
readonly property color sensorHigh: statusErrorColor
|
readonly property color sensorHigh: statusErrorColor
|
||||||
readonly property color sensorLow: isDarkMode ? "#5B9DF5" : "#2F6FE0"
|
readonly property color sensorLow: themeSkill
|
||||||
readonly property color waferRingColor: toneBorder
|
readonly property color waferRingColor: toneBorder
|
||||||
readonly property color waferAxisColor: softBorder
|
readonly property color waferAxisColor: softBorder
|
||||||
|
|
||||||
// ── 11. Geometry ─────────────────────────────────────────────────────────
|
// ── 16. Wafer family badges (SourcePanel list) ───────────────────────────
|
||||||
|
// Accent = left bar / outline shade; Fill = avatar circle behind textOnColor.
|
||||||
|
// Family grouping mirrors FileBrowser's waferType first-letter convention.
|
||||||
|
readonly property color familyBlueAccent: "#3B82F6" // A / E / P
|
||||||
|
readonly property color familyBlueFill: "#1D4ED8"
|
||||||
|
readonly property color familyGreenAccent: "#10B981" // B / C / D
|
||||||
|
readonly property color familyGreenFill: "#065F46"
|
||||||
|
readonly property color familyVioletAccent: "#8B5CF6" // Z
|
||||||
|
readonly property color familyVioletFill: "#7C3AED"
|
||||||
|
readonly property color familyNeutralFill: "#374151" // unknown family
|
||||||
|
|
||||||
|
// ── 17. Comparison & charts ──────────────────────────────────────────────
|
||||||
|
// Metric grading for DTW readout cards (good → caution → bad).
|
||||||
|
// Dark values are pastel (readable on tone150); light values are deepened
|
||||||
|
// so they still pass on near-white cards.
|
||||||
|
readonly property color metricGood: isDarkMode ? "#6EE7B7" : "#059669"
|
||||||
|
readonly property color metricWarn: isDarkMode ? "#FDE047" : "#B45309"
|
||||||
|
readonly property color metricBad: isDarkMode ? "#EF4444" : "#DC2626"
|
||||||
|
// DIFF column accent in ReadoutPanel
|
||||||
|
readonly property color diffAccent: themeViolet
|
||||||
|
// Canvas chart primitives (DTW plot grid + axis labels)
|
||||||
|
readonly property color chartGridLine: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.10)
|
||||||
|
readonly property color chartAxisText: isDarkMode ? Qt.rgba(1, 1, 1, 0.35) : Qt.rgba(0, 0, 0, 0.45)
|
||||||
|
|
||||||
|
// ── 18. Geometry ─────────────────────────────────────────────────────────
|
||||||
// Radius
|
// Radius
|
||||||
readonly property int radiusXs: 6 // fields, tight elements
|
readonly property int radiusXs: 6 // fields, tight elements
|
||||||
readonly property int radiusSm: 8 // buttons
|
readonly property int radiusSm: 8 // buttons
|
||||||
@@ -124,17 +205,15 @@ QtObject {
|
|||||||
readonly property int rightPaneGap: 6
|
readonly property int rightPaneGap: 6
|
||||||
readonly property int panelPadding: 12
|
readonly property int panelPadding: 12
|
||||||
// Side rail layout
|
// Side rail layout
|
||||||
readonly property int sideRailWidth: 190
|
readonly property int sideRailWidth: 300
|
||||||
readonly property int sideRailSpacing: 14
|
readonly property int rightRailWidth: 280
|
||||||
readonly property int sideButtonHeight: 146
|
readonly property int sideRailMargin: 16
|
||||||
readonly property int sideButtonMinHeight: 88
|
readonly property int sideRailSpacing: 16
|
||||||
// Tab bar layout
|
readonly property int sideButtonHeight: 44
|
||||||
readonly property int tabBarHeight: 34
|
readonly property int sidePanelRadius: 10
|
||||||
readonly property int tabBarPadding: 6
|
readonly property int sideFooterConnection: 100
|
||||||
readonly property int tabSpacing: 2
|
readonly property int sideFooterUtility: 46
|
||||||
readonly property int tabRadius: 8
|
readonly property int sidePillHeight: 54
|
||||||
readonly property int tabFontSize: 12
|
|
||||||
readonly property int tabButtonMinWidth: 80
|
|
||||||
// Settings page layout
|
// Settings page layout
|
||||||
readonly property int settingsPanelMaxWidth: 760
|
readonly property int settingsPanelMaxWidth: 760
|
||||||
readonly property int settingsOuterMargin: 40
|
readonly property int settingsOuterMargin: 40
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ module ISC
|
|||||||
# ===== Root Components =====
|
# ===== Root Components =====
|
||||||
Main 1.0 Main.qml
|
Main 1.0 Main.qml
|
||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
|
AboutDialog 1.0 AboutDialog.qml
|
||||||
|
|
||||||
# ===== Singleton Theme =====
|
# ===== Singleton Theme =====
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtQml import QQmlApplicationEngine
|
from PySide6.QtQml import QQmlApplicationEngine
|
||||||
@@ -8,11 +9,22 @@ from PySide6.QtWidgets import QApplication
|
|||||||
|
|
||||||
from pygui.backend.controllers.device_controller import DeviceController
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
from pygui.backend.data.constants import default_data_dir
|
||||||
from pygui.backend.data.file_browser import FileBrowser
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
|
from pygui.backend.license.license_model import LicenseModel
|
||||||
|
|
||||||
# Importing wafer_map_item registers the @QmlElement WaferMapItem (QML: import ISC.Wafer).
|
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
|
||||||
|
from pygui.backend.visualization import (
|
||||||
|
graph_quick_item as graph_quick_item, # noqa: F401
|
||||||
|
)
|
||||||
|
from pygui.backend.visualization import (
|
||||||
|
trend_chart_item as trend_chart_item, # noqa: F401
|
||||||
|
)
|
||||||
|
from pygui.backend.visualization import (
|
||||||
|
wafer_map_item as wafer_map_item, # noqa: F401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ===== Application Entry Point =====
|
# ===== Application Entry Point =====
|
||||||
@@ -30,19 +42,45 @@ def main() -> int:
|
|||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
engine = QQmlApplicationEngine()
|
engine = QQmlApplicationEngine()
|
||||||
|
|
||||||
# ===== Shared Models =====
|
# ===== Shared Settings =====
|
||||||
settings_model = LocalSettingsModel()
|
# One LocalSettings instance, one disk read. LocalSettingsModel and
|
||||||
|
# DeviceController both hold this same object, so a save from the
|
||||||
|
# Settings tab is visible to the device layer immediately — no reload,
|
||||||
|
# no second copy to go stale.
|
||||||
|
data_dir = str(default_data_dir())
|
||||||
|
raw_settings = LocalSettings.read_settings(data_dir)
|
||||||
|
|
||||||
|
settings_model = LocalSettingsModel(raw_settings, data_dir)
|
||||||
select_file_dialog_model = FileBrowser()
|
select_file_dialog_model = FileBrowser()
|
||||||
settings_model.loadSettings()
|
settings_model.loadSettings()
|
||||||
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
engine.rootContext().setContextProperty("settingsModel", settings_model)
|
||||||
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
|
||||||
|
|
||||||
|
# App version from package metadata so the About dialog can't drift
|
||||||
|
# from pyproject.toml.
|
||||||
|
try:
|
||||||
|
app_version = version("pygui")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
app_version = "dev"
|
||||||
|
engine.rootContext().setContextProperty("appVersion", app_version)
|
||||||
|
|
||||||
# ===== Device Controller (serial comm) =====
|
# ===== Device Controller (serial comm) =====
|
||||||
data_dir = str(settings_model._data_dir)
|
# TODO P1.1: construct LicenseModel BEFORE DeviceController and pass
|
||||||
raw_settings = LocalSettings.read_settings(data_dir)
|
# license_lookup=license_model.levelForWafer into the ctor.
|
||||||
|
# THINKING: controller must answer "is this serial licensed?" at
|
||||||
|
# read/erase time (P2.1); a bound Callable[[str], str] avoids a
|
||||||
|
# LicenseModel import in the controller and keeps it testable with a
|
||||||
|
# lambda. See docs/pending/license-gating-plan.md §1.1.
|
||||||
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)
|
||||||
|
|
||||||
|
# Source panel browses the same folder recordings/reads are saved to.
|
||||||
|
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
|
||||||
|
|
||||||
|
# ===== License Model (About dialog grid, replay trial) =====
|
||||||
|
license_model = LicenseModel(data_dir)
|
||||||
|
engine.rootContext().setContextProperty("licenseModel", license_model)
|
||||||
|
|
||||||
# ===== Session Controller (live/review wafer dashboard) =====
|
# ===== Session Controller (live/review wafer dashboard) =====
|
||||||
stream_controller = SessionController()
|
stream_controller = SessionController()
|
||||||
engine.rootContext().setContextProperty("streamController", stream_controller)
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# ===== Backend Package =====
|
|
||||||
# Backward-compatible re-exports so existing callers keep working
|
|
||||||
# while imports are migrated to the new sub-package paths.
|
|
||||||
|
|
||||||
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
|
|
||||||
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
|
|
||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
|
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
|
|
||||||
from pygui.backend.data.data_records import read_data_records # noqa: F401
|
|
||||||
from pygui.backend.data.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
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from typing import List, Optional, Tuple
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from pygui.backend.visualization.contour_models import ContourLine, ContourSegment
|
from pygui.backend.attic.contour_models import ContourLine, ContourSegment
|
||||||
|
|
||||||
|
|
||||||
# ===== Contour Generation =====
|
# ===== Contour Generation =====
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Dynamic Time Warping comparision between two temperature runs."""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from dtw import dtw
|
||||||
|
|
||||||
|
|
||||||
|
def compare_runs(series_a: list[float], series_b: list[float]) -> dict:
|
||||||
|
"""Compute DTW alignment between 2 average-temperature series.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys:
|
||||||
|
- distance: float - warping distance
|
||||||
|
- index_a: list[int] - warping path indices into series_a
|
||||||
|
- index_b: list[int] - warping path indices into series_b
|
||||||
|
- warping_path: list[tuple[int,int]] - paired indices
|
||||||
|
"""
|
||||||
|
x = np.array(series_a, dtype=float)
|
||||||
|
y = np.array(series_b, dtype=float)
|
||||||
|
|
||||||
|
# Filter out NaN/Inf values so dtw doesn't choke on them.
|
||||||
|
mask_x = np.isfinite(x)
|
||||||
|
mask_y = np.isfinite(y)
|
||||||
|
if not mask_x.any() or not mask_y.any():
|
||||||
|
return {"distance": float('inf'), "index_a": [], "index_b": [], "warping_path": []}
|
||||||
|
|
||||||
|
x = x[mask_x]
|
||||||
|
y = y[mask_y]
|
||||||
|
|
||||||
|
alignment = dtw(x, y, keep_internals=True)
|
||||||
|
return {
|
||||||
|
"distance": float(alignment.distance),
|
||||||
|
"index_a": alignment.index1.tolist(),
|
||||||
|
"index_b": alignment.index2.tolist(),
|
||||||
|
"warping_path": list(zip(alignment.index1.tolist(), alignment.index2.tolist()))
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# ===== Controllers Sub-package =====
|
|
||||||
from pygui.backend.controllers.device_controller import DeviceController
|
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
|
||||||
|
|
||||||
__all__ = ["DeviceController", "SessionController"]
|
|
||||||
|
|||||||
@@ -3,30 +3,30 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
|
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
|
convert_to_debug_temperatures,
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
|
save_debug_csv,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
)
|
)
|
||||||
from pygui.serialcomm.device_service import DeviceService
|
|
||||||
from pygui.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__)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,11 +38,15 @@ class DeviceController(QObject):
|
|||||||
|
|
||||||
# ---- public signals ----
|
# ---- public signals ----
|
||||||
portsUpdated = Signal(list)
|
portsUpdated = Signal(list)
|
||||||
|
connectionStatusChanged = Signal()
|
||||||
|
operationInProgressChanged = Signal()
|
||||||
|
waferDetectedChanged = Signal()
|
||||||
|
selectedPortChanged = Signal()
|
||||||
detectResult = Signal(object) # WaferInfo dict or None
|
detectResult = Signal(object) # WaferInfo dict or None
|
||||||
readResult = Signal(object) # {"success": bool, "bytes": int} or {"error": str}
|
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
||||||
eraseResult = Signal(object) # {"success": bool}
|
eraseResult = Signal(dict) # {"success": bool}
|
||||||
debugResult = Signal(object) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
|
debugResult = Signal(dict) # {"success": bool, "sensor_bytes": int, "debug_bytes": int}
|
||||||
parsedDataReady = Signal(object) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
|
parsedDataReady = Signal(dict) # {"success": bool, "rows": int, "cols": int, "data": ..., "csv_path": str}
|
||||||
statusRestored = Signal() # Emitted when status is restored from previous session
|
statusRestored = Signal() # Emitted when status is restored from previous session
|
||||||
logMessage = Signal(str)
|
logMessage = Signal(str)
|
||||||
activityLogUpdated = Signal(str)
|
activityLogUpdated = Signal(str)
|
||||||
@@ -50,45 +54,52 @@ class DeviceController(QObject):
|
|||||||
# ---- private signals (marshal worker-thread results back to main thread) ----
|
# ---- private signals (marshal worker-thread results back to main thread) ----
|
||||||
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
|
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
|
||||||
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
|
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
|
||||||
|
_eraseFinished = Signal(bool) # (success)
|
||||||
|
_debugFinished = Signal(dict) # (result_dict)
|
||||||
|
|
||||||
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._data_dir = data_dir
|
self._data_dir = data_dir
|
||||||
|
from pygui.serialcomm.device_service import DeviceService
|
||||||
self._service = DeviceService(settings)
|
self._service = DeviceService(settings)
|
||||||
|
|
||||||
# Initialize status from persisted settings (with defaults for new installations)
|
# Always start fresh — never restore connection status or logs from a prior session
|
||||||
self._connection_status = getattr(settings, 'connection_status', "Disconnected")
|
self._connection_status = "Disconnected"
|
||||||
self._operation_in_progress = False
|
self._operation_in_progress = False
|
||||||
self._activity_log: list[str] = getattr(settings, 'activity_log', [])
|
self._activity_log: list[str] = []
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
from pathlib import Path
|
|
||||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "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', {})
|
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
|
||||||
self._last_csv_path: str = getattr(settings, 'last_csv_path', "")
|
# (default lambda s: "") and add:
|
||||||
self._selected_port: str = getattr(settings, 'selected_port', "")
|
# def _wafer_license_level(self) -> str:
|
||||||
self._data_row_count: int = getattr(settings, 'data_row_count', 0)
|
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
|
||||||
self._data_col_count: int = getattr(settings, 'data_col_count', 0)
|
# plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
|
||||||
|
# THINKING: single shared guard — per-call-site checks are the
|
||||||
|
# sibling-caller bug (fix read, forget erase). Empty serial → ""
|
||||||
|
# → denied = safe default. Called by P2.1.
|
||||||
|
# See docs/pending/license-gating-plan.md §1.2.
|
||||||
|
self._last_wafer_info: dict[str, Any] = {}
|
||||||
|
self._wafer_detected: bool = False
|
||||||
|
self._last_csv_path: str = ""
|
||||||
|
self._selected_port: str = ""
|
||||||
|
self._data_row_count: int = 0
|
||||||
|
self._data_col_count: int = 0
|
||||||
self._data_model = TemperatureTableModel(self)
|
self._data_model = TemperatureTableModel(self)
|
||||||
self._graph_view = GraphView(self)
|
self._graph_view = GraphView(self)
|
||||||
|
|
||||||
# If we have persisted activity log, emit it to QML
|
# Reset connection status on fresh start
|
||||||
if self._activity_log:
|
self._connection_status = "Disconnected"
|
||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
|
||||||
|
|
||||||
# Log status restoration and emit signal for UI updates
|
|
||||||
if self._connection_status != "Disconnected" or self._selected_port or self._last_wafer_info:
|
|
||||||
self._append_log("Restored previous session status")
|
|
||||||
if self._selected_port:
|
|
||||||
self._append_log(f"Last selected port: {self._selected_port}")
|
|
||||||
if self._last_wafer_info:
|
|
||||||
info = self._last_wafer_info
|
|
||||||
self._append_log(f"Last wafer: {info.get('serialNumber', 'Unknown')} ({info.get('familyCode', 'Unknown')})")
|
|
||||||
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.ConnectionType.QueuedConnection)
|
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
|
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self._eraseFinished.connect(self._handle_erase_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self._debugFinished.connect(self._handle_debug_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.addHandler(QmlActivityLogHandler(self))
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
# ---- Properties ----
|
# ---- Properties ----
|
||||||
@Property(list, notify=portsUpdated)
|
@Property(list, notify=portsUpdated)
|
||||||
@@ -96,38 +107,89 @@ class DeviceController(QObject):
|
|||||||
"""Currently available serial port names."""
|
"""Currently available serial port names."""
|
||||||
return self._service.enumerate_ports()
|
return self._service.enumerate_ports()
|
||||||
|
|
||||||
@Property(str, notify=portsUpdated)
|
def _set_connection_status(self, status: str) -> None:
|
||||||
|
"""Update connection status and notify QML."""
|
||||||
|
if self._connection_status != status:
|
||||||
|
self._connection_status = status
|
||||||
|
self.connectionStatusChanged.emit()
|
||||||
|
|
||||||
|
@Property(str, notify=connectionStatusChanged)
|
||||||
def connectionStatus(self) -> str:
|
def connectionStatus(self) -> str:
|
||||||
"""Current connection status string for display."""
|
"""Current connection status string for display."""
|
||||||
return self._connection_status
|
return self._connection_status
|
||||||
|
|
||||||
@Property(bool, notify=portsUpdated)
|
@Property(bool, notify=operationInProgressChanged)
|
||||||
def operationInProgress(self) -> bool:
|
def operationInProgress(self) -> bool:
|
||||||
"""Whether a hardware operation is currently running."""
|
"""Whether a hardware operation is currently running."""
|
||||||
return self._operation_in_progress
|
return self._operation_in_progress
|
||||||
|
|
||||||
|
@Property(str, notify=activityLogUpdated)
|
||||||
|
def activityLog(self) -> str:
|
||||||
|
"""Full activity log text; survives QML tab reloads."""
|
||||||
|
return "\n".join(self._activity_log)
|
||||||
|
|
||||||
@Property(str, notify=activityLogUpdated)
|
@Property(str, notify=activityLogUpdated)
|
||||||
def saveDataDir(self) -> str:
|
def saveDataDir(self) -> str:
|
||||||
"""Directory where parsed CSV data files are saved."""
|
"""Directory where parsed CSV data files are saved."""
|
||||||
return self._save_data_dir
|
return self._save_data_dir
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSaveDataDir(self, path: str) -> None:
|
def setSaveDataDir(self, path: str) -> None:
|
||||||
"""Set the directory for saving parsed CSV data files."""
|
"""Set the directory for saving parsed CSV data files."""
|
||||||
self._save_data_dir = path
|
self._save_data_dir = path
|
||||||
self._append_log(f"Save data dir set to: {path}")
|
self._append_log(f"Save data dir set to: {path}")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Property(str, notify=portsUpdated)
|
@Slot(result=str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def exportDir(self) -> str:
|
||||||
|
"""Ensure and return the Export subfolder under saveDataDir."""
|
||||||
|
path = Path(self._save_data_dir) / "Export"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def openCsvFile(self, file_path: str) -> None:
|
||||||
|
"""Open a CSV file in the system default spreadsheet application."""
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
self._append_log("No file path provided to openCsvFile")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
self._append_log(f"File not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Platform-appropriate file opener
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", str(path)])
|
||||||
|
elif sys.platform == "linux":
|
||||||
|
subprocess.Popen(["xdg-open", str(path)])
|
||||||
|
else: # Windows
|
||||||
|
os.startfile(str(path))
|
||||||
|
|
||||||
|
self._append_log(f"Opened file: {file_path}")
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Failed to open file %s: %s", file_path, exc)
|
||||||
|
self._append_log(f"Error opening file: {exc}")
|
||||||
|
|
||||||
|
@Property(str, notify=selectedPortChanged)
|
||||||
def selectedPort(self) -> str:
|
def selectedPort(self) -> str:
|
||||||
"""Currently selected serial port shared across the UI."""
|
"""Currently selected serial port shared across the UI."""
|
||||||
return self._selected_port
|
return self._selected_port
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSelectedPort(self, port: str) -> None:
|
def setSelectedPort(self, port: str) -> None:
|
||||||
"""Set the active serial port from the port selector."""
|
"""Set the active serial port from the port selector."""
|
||||||
self._selected_port = port
|
if self._selected_port != port:
|
||||||
self._save_status()
|
self._selected_port = port
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
|
self._save_status()
|
||||||
|
|
||||||
@Property(int, notify=parsedDataReady)
|
@Property(int, notify=parsedDataReady)
|
||||||
def dataRowCount(self) -> int:
|
def dataRowCount(self) -> int:
|
||||||
@@ -146,15 +208,33 @@ class DeviceController(QObject):
|
|||||||
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
|
Returns [familyCode, serialNumber, sensorCount, mfgDateHex, runtime, cycleCount].
|
||||||
"""
|
"""
|
||||||
info = self._last_wafer_info
|
info = self._last_wafer_info
|
||||||
|
family_code = info.get("familyCode", "")
|
||||||
|
sensor_count = info.get("sensorCount", 0)
|
||||||
|
from pygui.serialcomm.data_parser import csv_column_count
|
||||||
|
mapped_count = csv_column_count(family_code)
|
||||||
|
if mapped_count > 0:
|
||||||
|
sensor_count = mapped_count
|
||||||
|
|
||||||
return [
|
return [
|
||||||
info.get("familyCode", ""),
|
family_code,
|
||||||
info.get("serialNumber", ""),
|
info.get("serialNumber", ""),
|
||||||
info.get("sensorCount", 0),
|
sensor_count,
|
||||||
info.get("mfgDateHex", ""),
|
info.get("mfgDateHex", ""),
|
||||||
info.get("runtime", 0),
|
info.get("runtime", 0),
|
||||||
info.get("cycleCount", 0),
|
info.get("cycleCount", 0),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@Property(bool, notify=waferDetectedChanged)
|
||||||
|
def waferDetected(self) -> bool:
|
||||||
|
"""Whether a wafer is currently detected and ready for read/erase."""
|
||||||
|
return self._wafer_detected
|
||||||
|
|
||||||
|
def _set_wafer_detected(self, detected: bool) -> None:
|
||||||
|
if self._wafer_detected != detected:
|
||||||
|
self._wafer_detected = detected
|
||||||
|
self.waferDetectedChanged.emit()
|
||||||
|
|
||||||
|
|
||||||
@Property(object, notify=detectResult)
|
@Property(object, notify=detectResult)
|
||||||
def dataModel(self) -> TemperatureTableModel:
|
def dataModel(self) -> TemperatureTableModel:
|
||||||
"""QAbstractTableModel for the parsed temperature data table."""
|
"""QAbstractTableModel for the parsed temperature data table."""
|
||||||
@@ -166,6 +246,7 @@ class DeviceController(QObject):
|
|||||||
return self._graph_view
|
return self._graph_view
|
||||||
|
|
||||||
@Slot(result=object)
|
@Slot(result=object)
|
||||||
|
@slot_error_boundary
|
||||||
def getChartData(self) -> dict[str, Any]:
|
def getChartData(self) -> dict[str, Any]:
|
||||||
"""Extract chart-ready data from the current model.
|
"""Extract chart-ready data from the current model.
|
||||||
|
|
||||||
@@ -204,6 +285,7 @@ class DeviceController(QObject):
|
|||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearActivityLog(self) -> None:
|
def clearActivityLog(self) -> None:
|
||||||
"""Clear the activity log."""
|
"""Clear the activity log."""
|
||||||
self._activity_log.clear()
|
self._activity_log.clear()
|
||||||
@@ -211,42 +293,48 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearSession(self) -> None:
|
def clearSession(self) -> None:
|
||||||
"""Clear the current session state, allowing a clean detect."""
|
"""Clear the current session state, allowing a clean detect."""
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._selected_port = ""
|
self._selected_port = ""
|
||||||
self._last_wafer_info = {}
|
self._last_wafer_info = {}
|
||||||
|
self._set_wafer_detected(False)
|
||||||
self._data_row_count = 0
|
self._data_row_count = 0
|
||||||
self._data_col_count = 0
|
self._data_col_count = 0
|
||||||
self._raw_bytes = None
|
self._raw_bytes = None
|
||||||
self._operation_in_progress = False
|
self._operation_in_progress = False
|
||||||
|
self._data_model.reset()
|
||||||
|
self._graph_view.resetChart()
|
||||||
|
self._activity_log.clear()
|
||||||
self.detectResult.emit(None)
|
self.detectResult.emit(None)
|
||||||
|
self.parsedDataReady.emit({"success": False})
|
||||||
self.portsUpdated.emit(self.availablePorts)
|
self.portsUpdated.emit(self.availablePorts)
|
||||||
self._append_log("Session refreshed/cleared")
|
self.activityLogUpdated.emit("")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
def _set_operation_progress(self, in_progress: bool) -> None:
|
|
||||||
"""Set operation progress state and notify QML.
|
|
||||||
|
|
||||||
portsUpdated is the shared notify signal for availablePorts,
|
def _set_operation_progress(self, in_progress: bool) -> None:
|
||||||
connectionStatus, and operationInProgress — emitting it forces
|
"""Set operation progress state and notify QML."""
|
||||||
QML to re-evaluate all three bindings.
|
if self._operation_in_progress != in_progress:
|
||||||
"""
|
self._operation_in_progress = in_progress
|
||||||
self._operation_in_progress = in_progress
|
self.operationInProgressChanged.emit()
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
|
||||||
|
|
||||||
# ---- Public Slots ----
|
# ---- Public Slots ----
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def refreshPorts(self) -> None:
|
def refreshPorts(self) -> None:
|
||||||
"""Scan for available serial ports and emit updated list."""
|
"""Scan for available serial ports and emit updated list."""
|
||||||
|
self._service.clear_port_scan_cache()
|
||||||
ports = self._service.enumerate_ports()
|
ports = self._service.enumerate_ports()
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self.portsUpdated.emit(ports)
|
self.portsUpdated.emit(ports)
|
||||||
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
self._append_log(f"Scanned {len(ports)} serial port(s)")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def detectWafer(self) -> None:
|
def detectWafer(self) -> None:
|
||||||
"""Scan all serial ports for a wafer (runs in background thread).
|
"""Scan all serial ports for a wafer (runs in background thread).
|
||||||
|
|
||||||
@@ -258,8 +346,21 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Already busy — ignoring detect request")
|
self._append_log("Already busy — ignoring detect request")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Clear previous session data & logs for a fresh session
|
||||||
|
self._selected_port = ""
|
||||||
|
self._last_wafer_info = {}
|
||||||
|
self._data_row_count = 0
|
||||||
|
self._data_col_count = 0
|
||||||
|
self._raw_bytes = None
|
||||||
|
self._data_model.reset()
|
||||||
|
self._graph_view.resetChart()
|
||||||
|
self._activity_log.clear()
|
||||||
|
self.detectResult.emit(None)
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
|
self.parsedDataReady.emit({"success": False})
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Detecting..."
|
self._set_connection_status("Detecting...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log("Scanning all ports for wafer ...")
|
self._append_log("Scanning all ports for wafer ...")
|
||||||
|
|
||||||
@@ -276,30 +377,37 @@ class DeviceController(QObject):
|
|||||||
self._detectFinished.emit(port, info)
|
self._detectFinished.emit(port, info)
|
||||||
|
|
||||||
@Slot(object, object)
|
@Slot(object, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_detect_finished(
|
def _handle_detect_finished(
|
||||||
self, port: Optional[str], info: Optional[WaferInfo]
|
self, port: Optional[str], info: Optional[WaferInfo]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for detectWafer."""
|
"""Main-thread completion handler for detectWafer."""
|
||||||
if info is not None:
|
try:
|
||||||
self._selected_port = port or ""
|
if info is not None:
|
||||||
self._connection_status = "Connected"
|
# Build the dict (can raise) before declaring success, so a
|
||||||
self._append_log(
|
# bad payload can't leave status="Connected" while
|
||||||
f"Detected: {info.serial_number} (family={info.family_code}, "
|
# waferDetected stays false and Read/Erase stay greyed.
|
||||||
f"port={port}, runtime={info.runtime}s, cycles={info.cycle_count})"
|
wafer_dict = self._wafer_info_to_dict(info)
|
||||||
)
|
self._selected_port = port or ""
|
||||||
wafer_dict = self._wafer_info_to_dict(info)
|
self._last_wafer_info = wafer_dict
|
||||||
self._last_wafer_info = wafer_dict
|
self._set_wafer_detected(True)
|
||||||
self.detectResult.emit(wafer_dict)
|
self._set_connection_status("Connected")
|
||||||
self._save_status()
|
self.detectResult.emit(wafer_dict)
|
||||||
else:
|
self.selectedPortChanged.emit()
|
||||||
self._selected_port = ""
|
self._save_status()
|
||||||
self._connection_status = "Disconnected"
|
else:
|
||||||
self._append_log("No wafer detected on any port")
|
self._selected_port = ""
|
||||||
self._last_wafer_info = {}
|
self._set_connection_status("Disconnected")
|
||||||
self.detectResult.emit(None)
|
self._append_log("No wafer detected on any port")
|
||||||
self._set_operation_progress(False)
|
self._last_wafer_info = {}
|
||||||
|
self._set_wafer_detected(False)
|
||||||
|
self.detectResult.emit(None)
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
|
finally:
|
||||||
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def readMemoryAsync(self) -> None:
|
def readMemoryAsync(self) -> None:
|
||||||
"""Read wafer memory in a background thread.
|
"""Read wafer memory in a background thread.
|
||||||
|
|
||||||
@@ -311,6 +419,16 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Already busy — ignoring read request")
|
self._append_log("Already busy — ignoring read request")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# TODO P2.1: license guard —
|
||||||
|
# if self._wafer_license_level() == "":
|
||||||
|
# self._append_log("Wafer not licensed — load its license .bin (About dialog)")
|
||||||
|
# self.readResult.emit({"error": "Wafer not licensed"})
|
||||||
|
# return
|
||||||
|
# THINKING: this slot is the trust boundary; the QML enabled
|
||||||
|
# binding (P2.2) is UX only. Reuses existing readResult error
|
||||||
|
# shape → zero new signals. Depends on P1.2. Same guard goes in
|
||||||
|
# eraseMemory (emit eraseResult there). See plan §2.1.
|
||||||
|
|
||||||
if not self._selected_port:
|
if not self._selected_port:
|
||||||
self._append_log("No wafer detected — run Detect Wafer first")
|
self._append_log("No wafer detected — run Detect Wafer first")
|
||||||
self.readResult.emit({"error": "No port — detect wafer first"})
|
self.readResult.emit({"error": "No port — detect wafer first"})
|
||||||
@@ -320,7 +438,7 @@ class DeviceController(QObject):
|
|||||||
family_code = self._last_wafer_info.get("familyCode", "")
|
family_code = self._last_wafer_info.get("familyCode", "")
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Reading..."
|
self._set_connection_status("Reading...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(
|
self._append_log(
|
||||||
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
f"Reading memory on {port} (family={family_code or 'auto'}) ..."
|
||||||
@@ -342,12 +460,13 @@ class DeviceController(QObject):
|
|||||||
self._readFinished.emit(port, family_code, data)
|
self._readFinished.emit(port, family_code, data)
|
||||||
|
|
||||||
@Slot(str, str, object)
|
@Slot(str, str, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_read_finished(
|
def _handle_read_finished(
|
||||||
self, port: str, family_code: str, data: Optional[bytes]
|
self, port: str, family_code: str, data: Optional[bytes]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for readMemoryAsync."""
|
"""Main-thread completion handler for readMemoryAsync."""
|
||||||
if data is not None:
|
if data is not None:
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log(f"Read {len(data)} bytes")
|
self._append_log(f"Read {len(data)} bytes")
|
||||||
self._raw_bytes = data
|
self._raw_bytes = data
|
||||||
self.readResult.emit({"success": True, "bytes": len(data)})
|
self.readResult.emit({"success": True, "bytes": len(data)})
|
||||||
@@ -355,7 +474,7 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Memory read complete - choose save directory to save CSV")
|
self._append_log("Memory read complete - choose save directory to save CSV")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
return
|
return
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("Read returned no data")
|
self._append_log("Read returned no data")
|
||||||
self._raw_bytes = None
|
self._raw_bytes = None
|
||||||
self.readResult.emit({"error": "Read returned no data"})
|
self.readResult.emit({"error": "Read returned no data"})
|
||||||
@@ -363,67 +482,132 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def eraseMemory(self, port: str) -> None:
|
def eraseMemory(self, port: str) -> None:
|
||||||
"""Send p1 erase command."""
|
"""Send p1 erase command."""
|
||||||
|
if self._operation_in_progress:
|
||||||
|
self._append_log("Already busy — ignoring erase request")
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO P2.1: same license guard as readMemoryAsync, but emit
|
||||||
|
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Erasing..."
|
self._set_connection_status("Erasing...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
||||||
|
|
||||||
ok = self._service.erase_wafer(port)
|
threading.Thread(
|
||||||
|
target=self._erase_worker,
|
||||||
|
args=(port,),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _erase_worker(self, port: str) -> None:
|
||||||
|
"""Background thread worker for eraseMemory."""
|
||||||
|
try:
|
||||||
|
ok = self._service.erase_wafer(port)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Erase worker crashed: %s", exc)
|
||||||
|
ok = False
|
||||||
|
self._eraseFinished.emit(ok)
|
||||||
|
|
||||||
|
@Slot(bool)
|
||||||
|
@slot_error_boundary
|
||||||
|
def _handle_erase_finished(self, ok: bool) -> None:
|
||||||
|
"""Main thread completion handler for eraseMemory."""
|
||||||
if ok:
|
if ok:
|
||||||
self._connection_status = "Connected"
|
self._set_connection_status("Connected")
|
||||||
self._append_log("Erase command accepted — wait ~15 seconds")
|
self._append_log("Erase command accepted — wait ~15 seconds")
|
||||||
else:
|
else:
|
||||||
self._connection_status = "Disconnected"
|
self._set_connection_status("Disconnected")
|
||||||
self._append_log("Erase command failed")
|
self._append_log("Erase command failed")
|
||||||
self.eraseResult.emit({"success": ok})
|
self.eraseResult.emit({"success": ok})
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def readDebug(self, port: str) -> None:
|
def readDebug(self, port: str) -> None:
|
||||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
"""Read both D1 (sensor) and F1 (debug/cold-junction) data."""
|
||||||
|
if self._operation_in_progress:
|
||||||
Emits debugResult with counts on success.
|
self._append_log("Already busy — ignoring debug read request")
|
||||||
"""
|
return
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._connection_status = "Reading debug..."
|
self._set_connection_status("Reading debug...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
self._append_log(f"Reading debug data on {port} ...")
|
self._append_log(f"Reading debug data on {port} ...")
|
||||||
|
|
||||||
# Step 1: D1 sensor data
|
threading.Thread(
|
||||||
sensor_data = self._service.read_wafer_data(port, family_code="")
|
target=self._debug_worker,
|
||||||
if sensor_data is None:
|
args=(port,),
|
||||||
self._connection_status = "Disconnected"
|
daemon=True,
|
||||||
self._append_log("D1 read failed — aborting debug read")
|
).start()
|
||||||
self.debugResult.emit({"error": "D1 read failed"})
|
|
||||||
self._set_operation_progress(False)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._append_log(f"D1 read: {len(sensor_data)} bytes")
|
def _debug_worker(self, port: str) -> None:
|
||||||
|
"""Background thread worker for readDebug."""
|
||||||
|
try:
|
||||||
|
# Step 1: D1 sensor data
|
||||||
|
sensor_data = self._service.read_wafer_data(port, family_code="")
|
||||||
|
if sensor_data is None:
|
||||||
|
self._debugFinished.emit({"error": "D1 read failed"})
|
||||||
|
return
|
||||||
|
|
||||||
# Step 2: F1 debug data
|
# Step 2: F1 debug data
|
||||||
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
||||||
if debug_data is None:
|
if debug_data is None:
|
||||||
self._connection_status = "Disconnected"
|
self._debugFinished.emit({"error": "F1 read failed"})
|
||||||
self._append_log("F1 read failed")
|
return
|
||||||
self.debugResult.emit({"error": "F1 read failed"})
|
|
||||||
self._set_operation_progress(False)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._connection_status = "Connected"
|
fc = (
|
||||||
self._append_log(
|
self._last_wafer_info.get("familyCode", "")
|
||||||
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
if self._last_wafer_info
|
||||||
f"{len(debug_data)} debug bytes"
|
else ""
|
||||||
)
|
)
|
||||||
self.debugResult.emit({
|
sensor_hex = parse_binary_data(sensor_data, fc)
|
||||||
"success": True,
|
debug_hex = parse_binary_data(debug_data, fc)
|
||||||
"sensor_bytes": len(sensor_data),
|
if not sensor_hex or not debug_hex:
|
||||||
"debug_bytes": len(debug_data),
|
self._debugFinished.emit({"error": "Debug binary parse failed"})
|
||||||
})
|
return
|
||||||
|
|
||||||
|
temp_data = convert_to_temperatures(sensor_hex, fc)
|
||||||
|
debug_temp_data = convert_to_debug_temperatures(debug_hex)
|
||||||
|
csv_path = save_debug_csv(temp_data, debug_temp_data, self._save_data_dir)
|
||||||
|
if csv_path is None:
|
||||||
|
self._debugFinished.emit({"error": "Debug CSV save failed"})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._debugFinished.emit({
|
||||||
|
"success": True,
|
||||||
|
"sensor_bytes": len(sensor_data),
|
||||||
|
"debug_bytes": len(debug_data),
|
||||||
|
"csv_path": csv_path,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Debug worker crashed: %s", exc)
|
||||||
|
self._debugFinished.emit({"error": str(exc)})
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
@slot_error_boundary
|
||||||
|
def _handle_debug_finished(self, result: dict) -> None:
|
||||||
|
"""Main thread completion handler for readDebug."""
|
||||||
|
if result.get("success"):
|
||||||
|
self._set_connection_status("Connected")
|
||||||
|
sensor_bytes = result.get("sensor_bytes", 0)
|
||||||
|
debug_bytes = result.get("debug_bytes", 0)
|
||||||
|
csv_path = result.get("csv_path", "")
|
||||||
|
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
|
||||||
|
if csv_path:
|
||||||
|
msg += f", CSV written to {csv_path}"
|
||||||
|
self._append_log(msg)
|
||||||
|
else:
|
||||||
|
self._set_connection_status("Disconnected")
|
||||||
|
err_msg = result.get("error", "Debug read failed")
|
||||||
|
self._append_log(err_msg)
|
||||||
|
|
||||||
|
self.debugResult.emit(result)
|
||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
||||||
"""Parse raw bytes into temperatures and save to CSV.
|
"""Parse raw bytes into temperatures and save to CSV.
|
||||||
|
|
||||||
@@ -439,7 +623,6 @@ class DeviceController(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self._save_data_dir:
|
if not self._save_data_dir:
|
||||||
from pathlib import Path
|
|
||||||
self._save_data_dir = str(Path(self._data_dir) / "csv")
|
self._save_data_dir = str(Path(self._data_dir) / "csv")
|
||||||
self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
|
self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
|
||||||
self._save_status()
|
self._save_status()
|
||||||
@@ -530,11 +713,29 @@ class DeviceController(QObject):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
|
def _wafer_info_to_dict(info: WaferInfo) -> dict[str, Any]:
|
||||||
"""Convert WaferInfo dataclass to JSON-serialisable dict for QML."""
|
"""Convert WaferInfo dataclass to JSON-serialisable dict for QML."""
|
||||||
|
from pygui.serialcomm.data_parser import csv_column_count
|
||||||
|
sensor_count = info.sensor_count
|
||||||
|
mapped_count = csv_column_count(info.family_code)
|
||||||
|
if mapped_count > 0:
|
||||||
|
sensor_count = mapped_count
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"familyCode": info.family_code,
|
"familyCode": info.family_code,
|
||||||
"serialNumber": info.serial_number,
|
"serialNumber": info.serial_number,
|
||||||
"sensorCount": info.sensor_count,
|
"sensorCount": sensor_count,
|
||||||
"mfgDateHex": info.mfg_date_hex,
|
"mfgDateHex": info.mfg_date_hex,
|
||||||
"runtime": info.runtime,
|
"runtime": info.runtime,
|
||||||
"cycleCount": info.cycle_count,
|
"cycleCount": info.cycle_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class QmlActivityLogHandler(logging.Handler):
|
||||||
|
def __init__(self, controller: DeviceController) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._controller = controller
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
message = record.getMessage()
|
||||||
|
if "found wafer on" in message:
|
||||||
|
return
|
||||||
|
level = record.levelname
|
||||||
|
msg = f"{level}: {message}"
|
||||||
|
self._controller._append_log(msg)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""QML-facing controller for the live/review wafer dashboard."""
|
"""QML-facing controller for the live/review wafer dashboard."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -10,10 +11,17 @@ 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.cluster_average import average_clusters, group_sensors_by_radius
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
from pygui.backend.models.frame_player import (
|
||||||
|
FramePlayer,
|
||||||
|
all_sensor_series,
|
||||||
|
frames_from_wafer_data,
|
||||||
|
)
|
||||||
|
from pygui.backend.models.frame_stats import compute_edge_center
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
from pygui.backend.models.session_model import SessionModel
|
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
from pygui.serialcomm.stream_reader import StreamReader
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
@@ -32,9 +40,20 @@ class SessionController(QObject):
|
|||||||
recordingChanged = Signal()
|
recordingChanged = Signal()
|
||||||
sensorsChanged = Signal()
|
sensorsChanged = Signal()
|
||||||
loadedFileChanged = Signal()
|
loadedFileChanged = Signal()
|
||||||
|
loadFileError = Signal(str)
|
||||||
clusterAveragingEnabledChanged = Signal()
|
clusterAveragingEnabledChanged = Signal()
|
||||||
|
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||||
|
# trend: per-frame avg for live graph
|
||||||
|
trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
|
||||||
|
trendReset = Signal() # live trend buffer cleared (new stream started)
|
||||||
|
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||||
|
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
||||||
|
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||||
|
splitResult = Signal(dict) # dict with success, segments
|
||||||
|
segmentExported = Signal(dict) # dict with success, path | error
|
||||||
# private: marshal a worker-thread frame onto the main thread
|
# private: marshal a worker-thread frame onto the main thread
|
||||||
_liveFrame = Signal(object) # Frame
|
_liveFrame = Signal(object) # Frame
|
||||||
|
_liveError = Signal() # worker-thread error ping
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -44,8 +63,9 @@ class SessionController(QObject):
|
|||||||
self._reader: Optional[StreamReader] = None
|
self._reader: Optional[StreamReader] = None
|
||||||
self._recorder = CsvRecorder()
|
self._recorder = CsvRecorder()
|
||||||
self._sensors: list[Sensor] = []
|
self._sensors: list[Sensor] = []
|
||||||
self._last = None # last SessionUpdate
|
self._last: Optional[SessionUpdate] = None
|
||||||
self._elapsed = 0.0
|
self._elapsed = 0.0
|
||||||
|
self._stream_start_time: float = 0.0
|
||||||
|
|
||||||
self._play_timer = QTimer(self)
|
self._play_timer = QTimer(self)
|
||||||
self._play_timer.timeout.connect(self._advance)
|
self._play_timer.timeout.connect(self._advance)
|
||||||
@@ -58,12 +78,21 @@ class SessionController(QObject):
|
|||||||
self._dirty = False
|
self._dirty = False
|
||||||
|
|
||||||
self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection)
|
self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection)
|
||||||
|
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
self._sensor_editor = SensorEditor()
|
self._sensor_editor = SensorEditor()
|
||||||
|
self._ec_pairs: tuple[tuple[int, int], ...] = ()
|
||||||
self._last_raw_frame: Frame | None = None
|
self._last_raw_frame: Frame | None = None
|
||||||
self._loaded_file: str = ""
|
self._loaded_file: str = ""
|
||||||
self._cluster_averaging_enabled = False
|
self._cluster_averaging_enabled = False
|
||||||
self._active_clusters: list[list[int]] = []
|
self._active_clusters: list[list[int]] = []
|
||||||
|
self._received_count: int = 0
|
||||||
|
self._error_count: int = 0
|
||||||
|
|
||||||
|
# Comparison cache for dynamic overlap mapping
|
||||||
|
self._compare_recs_a: list = []
|
||||||
|
self._compare_recs_b: list = []
|
||||||
|
self._compare_alignment_map: dict[int, int] = {}
|
||||||
|
|
||||||
# ---- properties QML binds to ----
|
# ---- properties QML binds to ----
|
||||||
@Property(str, notify=modeChanged)
|
@Property(str, notify=modeChanged)
|
||||||
@@ -75,6 +104,9 @@ class SessionController(QObject):
|
|||||||
@Property(int, notify=frameUpdated)
|
@Property(int, notify=frameUpdated)
|
||||||
def frameTotal(self) -> int: return self._player.total
|
def frameTotal(self) -> int: return self._player.total
|
||||||
|
|
||||||
|
@Property(bool, notify=stateChanged)
|
||||||
|
def playing(self) -> bool: return self._play_timer.isActive()
|
||||||
|
|
||||||
@Property(str, notify=stateChanged)
|
@Property(str, notify=stateChanged)
|
||||||
def state(self) -> str:
|
def state(self) -> str:
|
||||||
if self._last:
|
if self._last:
|
||||||
@@ -101,7 +133,7 @@ class SessionController(QObject):
|
|||||||
"value": round(v, 2), "band": band, "index": i})
|
"value": round(v, 2), "band": band, "index": i})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@Property("QVariantList", notify=sensorsChanged)
|
@Property("QVariantList", notify=sensorsChanged) # type: ignore[arg-type]
|
||||||
def sensorLayout(self) -> list:
|
def sensorLayout(self) -> list:
|
||||||
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
|
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
|
||||||
return [
|
return [
|
||||||
@@ -126,14 +158,14 @@ class SessionController(QObject):
|
|||||||
"""Wafer size in mm."""
|
"""Wafer size in mm."""
|
||||||
return getattr(self._sensors, "size", 300.0)
|
return getattr(self._sensors, "size", 300.0)
|
||||||
|
|
||||||
@Property("QVariantList", notify=frameUpdated)
|
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||||
def sensorValues(self) -> list:
|
def sensorValues(self) -> list:
|
||||||
"""[float] in sensor order."""
|
"""[float] in sensor order."""
|
||||||
if not self._last:
|
if not self._last:
|
||||||
return []
|
return []
|
||||||
return [round(v, 3) for v in self._last.values]
|
return [round(v, 3) for v in self._last.values]
|
||||||
|
|
||||||
@Property("QVariantList", notify=frameUpdated)
|
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||||
def sensorBands(self) -> list:
|
def sensorBands(self) -> list:
|
||||||
"""['in_range'|'high'|'low'] in sensor order."""
|
"""['in_range'|'high'|'low'] in sensor order."""
|
||||||
if not self._last:
|
if not self._last:
|
||||||
@@ -155,15 +187,43 @@ class SessionController(QObject):
|
|||||||
if not self._last:
|
if not self._last:
|
||||||
return {}
|
return {}
|
||||||
s = self._last.stats
|
s = self._last.stats
|
||||||
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
||||||
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||||
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||||
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
||||||
|
ec = compute_edge_center(self._last.values, self._ec_pairs,
|
||||||
|
self._sensor_editor.replaced_indices())
|
||||||
|
if ec is not None:
|
||||||
|
out.update({
|
||||||
|
"ecMinEdgeIndex": ec.min_edge_index + 1,
|
||||||
|
"ecMinCenterIndex": ec.min_center_index + 1,
|
||||||
|
"ecMinEdgeValue": round(ec.min_edge, 2),
|
||||||
|
"ecMinCenterValue": round(ec.min_center, 2),
|
||||||
|
"ecMinDelta": round(ec.min_delta, 2),
|
||||||
|
"ecMaxEdgeIndex": ec.max_edge_index + 1,
|
||||||
|
"ecMaxCenterIndex": ec.max_center_index + 1,
|
||||||
|
"ecMaxEdgeValue": round(ec.max_edge, 2),
|
||||||
|
"ecMaxCenterValue": round(ec.max_center, 2),
|
||||||
|
"ecMaxDelta": round(ec.max_delta, 2),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
@Property(str, notify=loadedFileChanged)
|
@Property(str, notify=loadedFileChanged)
|
||||||
def loadedFile(self) -> str: return self._loaded_file
|
def loadedFile(self) -> str: return self._loaded_file
|
||||||
|
|
||||||
@Property("QVariantList", notify=frameUpdated)
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def graphSensorNames(self) -> str:
|
||||||
|
"""Comma-joined sensor names for the whole-run Graph tab."""
|
||||||
|
names, _ = all_sensor_series(list(self._player.frames))
|
||||||
|
return ",".join(names)
|
||||||
|
|
||||||
|
@Property(str, notify=loadedFileChanged)
|
||||||
|
def graphSeriesJson(self) -> str:
|
||||||
|
"""JSON per-sensor value series (one list per sensor) for the Graph tab."""
|
||||||
|
_, series = all_sensor_series(list(self._player.frames))
|
||||||
|
return json.dumps(series)
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||||
def overriddenSensors(self) -> list[int]:
|
def overriddenSensors(self) -> list[int]:
|
||||||
"""Indices of sensors that currently have a replacement or offset."""
|
"""Indices of sensors that currently have a replacement or offset."""
|
||||||
return self._sensor_editor.active_indices()
|
return self._sensor_editor.active_indices()
|
||||||
@@ -172,7 +232,7 @@ class SessionController(QObject):
|
|||||||
def clusterAveragingEnabled(self) -> bool:
|
def clusterAveragingEnabled(self) -> bool:
|
||||||
return self._cluster_averaging_enabled
|
return self._cluster_averaging_enabled
|
||||||
|
|
||||||
@clusterAveragingEnabled.setter
|
@clusterAveragingEnabled.setter # type: ignore[no-redef]
|
||||||
def clusterAveragingEnabled(self, value: bool) -> None:
|
def clusterAveragingEnabled(self, value: bool) -> None:
|
||||||
if self._cluster_averaging_enabled == value:
|
if self._cluster_averaging_enabled == value:
|
||||||
return
|
return
|
||||||
@@ -180,17 +240,38 @@ class SessionController(QObject):
|
|||||||
self.clusterAveragingEnabledChanged.emit()
|
self.clusterAveragingEnabledChanged.emit()
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def receivedCount(self) -> int:
|
||||||
|
return self._received_count
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def errorCount(self) -> int:
|
||||||
|
return self._error_count
|
||||||
|
|
||||||
|
@Property(int, notify=liveStatsChanged)
|
||||||
|
def resyncCount(self) -> int:
|
||||||
|
return self._reader.resync_count if self._reader else 0
|
||||||
|
|
||||||
# ---- mode + thresholds ----
|
# ---- mode + thresholds ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setMode(self, mode: str) -> None:
|
def setMode(self, mode: str) -> None:
|
||||||
if mode == self._mode:
|
if mode == self._mode:
|
||||||
return
|
return
|
||||||
self.stopStream()
|
if mode != "live":
|
||||||
|
self.stopStream()
|
||||||
|
if self._mode == "live":
|
||||||
|
# Leaving a live session: blank the wafer map instead of
|
||||||
|
# freezing the last streamed frame on screen.
|
||||||
|
self._last = None
|
||||||
|
self._last_raw_frame = None
|
||||||
|
self.frameUpdated.emit()
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self.modeChanged.emit()
|
self.modeChanged.emit()
|
||||||
|
|
||||||
@Slot(float, float, bool)
|
@Slot(float, float, bool)
|
||||||
|
@slot_error_boundary
|
||||||
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
||||||
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
||||||
if self._last: # re-band current frame in place
|
if self._last: # re-band current frame in place
|
||||||
@@ -198,6 +279,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- review: file load + playback ----
|
# ---- review: file load + playback ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def loadFile(self, file_path: str) -> None:
|
def loadFile(self, file_path: str) -> None:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -210,24 +292,25 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
frames = []
|
frames = []
|
||||||
|
|
||||||
if is_official_csv(file_path):
|
try:
|
||||||
sensors, records = read_official_csv(file_path)
|
if is_official_csv(file_path):
|
||||||
frames = frames_from_wafer_data(None, records)
|
sensors, records = read_official_csv(file_path)
|
||||||
else:
|
frames = frames_from_wafer_data(None, records)
|
||||||
try:
|
else:
|
||||||
data, _ = ZWaferParser().parse(file_path)
|
data, _ = ZWaferParser().parse(file_path)
|
||||||
except (ValueError, KeyError) as exc:
|
if data is None or not data.sensors:
|
||||||
log.warning("Could not parse %s: %s", file_path, exc)
|
self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
|
||||||
return
|
return
|
||||||
if data is None or not data.sensors:
|
records = read_data_records(file_path)
|
||||||
log.warning("Could not parse %s", file_path)
|
sensors = data.sensors
|
||||||
return
|
frames = frames_from_wafer_data(data, records)
|
||||||
records = read_data_records(file_path)
|
|
||||||
sensors = data.sensors
|
|
||||||
frames = frames_from_wafer_data(data, records)
|
|
||||||
|
|
||||||
if not sensors or not frames:
|
if not sensors or not frames:
|
||||||
log.warning("No sensors or data in %s", file_path)
|
self.loadFileError.emit("No sensors or frames found in data file.")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Could not parse file %s: %s", file_path, exc)
|
||||||
|
self.loadFileError.emit(f"Load error: {exc}")
|
||||||
return
|
return
|
||||||
|
|
||||||
wafer_id = ""
|
wafer_id = ""
|
||||||
@@ -238,6 +321,7 @@ class SessionController(QObject):
|
|||||||
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
|
||||||
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
shape, size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
|
self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
|
||||||
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
self._sensors = WaferLayout(sensors, shape=shape, size=size)
|
||||||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
@@ -250,30 +334,294 @@ class SessionController(QObject):
|
|||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def unloadFile(self) -> None:
|
||||||
|
"""Clear the loaded file, resetting the player and frame states."""
|
||||||
|
self._player.load([])
|
||||||
|
self._model.reset()
|
||||||
|
self._ec_pairs = ()
|
||||||
|
self._loaded_file = ""
|
||||||
|
self.loadedFileChanged.emit()
|
||||||
|
self.sensorsChanged.emit()
|
||||||
|
|
||||||
|
# ---- comparison: DTW between two CSV files ----
|
||||||
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
|
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||||
|
"""Run DTW comparison between two CSV files and emit result."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.comparison import compare_runs
|
||||||
|
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
|
|
||||||
|
if Path(file_a).resolve() == Path(file_b).resolve():
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "Cannot compare a file to itself. Choose two different runs."
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_official_csv(file_a):
|
||||||
|
_, recs_a = read_official_csv(file_a)
|
||||||
|
else:
|
||||||
|
recs_a = read_data_records(file_a)
|
||||||
|
|
||||||
|
if is_official_csv(file_b):
|
||||||
|
_, recs_b = read_official_csv(file_b)
|
||||||
|
else:
|
||||||
|
recs_b = read_data_records(file_b)
|
||||||
|
if not recs_a or not recs_b:
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No data in one or both files"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._compare_recs_a = recs_a
|
||||||
|
self._compare_recs_b = recs_b
|
||||||
|
|
||||||
|
len_a, len_b = len(recs_a), len(recs_b)
|
||||||
|
# DTW can only align frames both runs actually have; the longer
|
||||||
|
# run's extra tail is still returned for display, just unaligned.
|
||||||
|
overlap_frames = min(len_a, len_b)
|
||||||
|
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
|
||||||
|
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)]
|
||||||
|
time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
|
||||||
|
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
|
||||||
|
|
||||||
|
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
|
||||||
|
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
|
||||||
|
|
||||||
|
# Mean temporal shift along the DTW path: positive means run B
|
||||||
|
# reaches the same profile features later than run A.
|
||||||
|
path = result["warping_path"]
|
||||||
|
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
||||||
|
frame_offset_seconds = round(
|
||||||
|
sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2
|
||||||
|
) if path else 0.0
|
||||||
|
|
||||||
|
# Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
|
||||||
|
# the overlap both runs share) and take the largest per-sensor abs
|
||||||
|
# difference across all sensors, not just the sensor[0] series used
|
||||||
|
# for alignment.
|
||||||
|
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
|
||||||
|
max_sensor_deviation = 0.0
|
||||||
|
for i, j in zip(result["index_a"], result["index_b"]):
|
||||||
|
values_a = recs_a[i].values
|
||||||
|
values_b = recs_b[j].values
|
||||||
|
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
||||||
|
diff = abs(values_a[s] - values_b[s])
|
||||||
|
if diff > max_sensor_deviation:
|
||||||
|
max_sensor_deviation = diff
|
||||||
|
|
||||||
|
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||||
|
# same sensor-parsing branch as loadFile() (file_a is assumed representative
|
||||||
|
# of both files' wafer type).
|
||||||
|
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
|
sensor_layout: list[dict] = []
|
||||||
|
sensor_diff: list[float] = []
|
||||||
|
wafer_shape = "round"
|
||||||
|
wafer_size = 300.0
|
||||||
|
try:
|
||||||
|
if is_official_csv(file_a):
|
||||||
|
sensors, _ = read_official_csv(file_a)
|
||||||
|
stem = Path(file_a).stem
|
||||||
|
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||||
|
else:
|
||||||
|
parsed, _ = ZWaferParser().parse(file_a)
|
||||||
|
sensors = parsed.sensors if parsed else []
|
||||||
|
wafer_id = parsed.serial if (parsed and parsed.serial) else ""
|
||||||
|
|
||||||
|
if sensors:
|
||||||
|
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
|
last_a = recs_a[overlap_frames - 1].values
|
||||||
|
last_b = recs_b[overlap_frames - 1].values
|
||||||
|
n = min(len(sensors), len(last_a), len(last_b))
|
||||||
|
sensor_layout = [
|
||||||
|
{
|
||||||
|
"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 sensors[:n]
|
||||||
|
]
|
||||||
|
sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
|
||||||
|
except Exception as layout_exc:
|
||||||
|
log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)
|
||||||
|
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": True,
|
||||||
|
"distance": result["distance"],
|
||||||
|
# Lists, not tuples — tuples don't survive QVariant conversion
|
||||||
|
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
||||||
|
"frame_offset": frame_offset,
|
||||||
|
"frame_offset_seconds": frame_offset_seconds,
|
||||||
|
"max_sensor_deviation": max_sensor_deviation,
|
||||||
|
"series_a": series_a,
|
||||||
|
"series_b": series_b,
|
||||||
|
"time_a": time_a,
|
||||||
|
"time_b": time_b,
|
||||||
|
"frame_count_a": len_a,
|
||||||
|
"frame_count_b": len_b,
|
||||||
|
"sensor_layout": sensor_layout,
|
||||||
|
"sensor_diff": sensor_diff,
|
||||||
|
"wafer_shape": wafer_shape,
|
||||||
|
"wafer_size": wafer_size,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Comparison failed: %s", exc)
|
||||||
|
self.comparisonResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc)
|
||||||
|
})
|
||||||
|
|
||||||
|
@Slot(int, result="QVariantList")
|
||||||
|
def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
|
||||||
|
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
|
||||||
|
if not self._compare_recs_a or not self._compare_recs_b:
|
||||||
|
return []
|
||||||
|
|
||||||
|
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
|
||||||
|
if idx_a not in self._compare_alignment_map:
|
||||||
|
# DTW path only covers indices up to the shorter run's length —
|
||||||
|
# past that, Run B has no data at this frame, not a clampable one.
|
||||||
|
return []
|
||||||
|
idx_b = self._compare_alignment_map[idx_a]
|
||||||
|
|
||||||
|
values_a = self._compare_recs_a[idx_a].values
|
||||||
|
values_b = self._compare_recs_b[idx_b].values
|
||||||
|
|
||||||
|
n = min(len(values_a), len(values_b))
|
||||||
|
return [round(values_b[i] - values_a[i], 3) for i in range(n)]
|
||||||
|
|
||||||
|
# ---- splitting: threshold-based segmentation ----
|
||||||
|
@Slot(str, float)
|
||||||
|
@slot_error_boundary
|
||||||
|
def splitData(self, file_path: str, threshold: float) -> None:
|
||||||
|
"""Segment temperature profile into Ramp/Soak/Cool phases."""
|
||||||
|
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||||
|
from pygui.backend.splitter import segment_profile
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_official_csv(file_path):
|
||||||
|
_, records = read_official_csv(file_path)
|
||||||
|
else:
|
||||||
|
records = read_data_records(file_path)
|
||||||
|
if not records:
|
||||||
|
self.splitResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No data in file"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract average temperatures (use first sensor as proxy)
|
||||||
|
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
||||||
|
segments = segment_profile(avg_temps, threshold)
|
||||||
|
|
||||||
|
# cache for exportSegment (per-segment Export in SplitDialog)
|
||||||
|
self._last_split_file = file_path
|
||||||
|
self._last_split_segments = segments
|
||||||
|
|
||||||
|
self.splitResult.emit({
|
||||||
|
"success": True,
|
||||||
|
"segments": [{
|
||||||
|
"label": seg.label,
|
||||||
|
"start_frame": seg.start_frame,
|
||||||
|
"end_frame": seg.end_frame,
|
||||||
|
"avg_temp": seg.avg_temp
|
||||||
|
} for seg in segments]
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Split failed: %s", exc)
|
||||||
|
self.splitResult.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": str(exc)
|
||||||
|
})
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
|
def exportSegment(self, index: int) -> None:
|
||||||
|
"""Write one segment from the last split as a standalone CSV.
|
||||||
|
|
||||||
|
Output lands next to the source file as
|
||||||
|
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
|
||||||
|
so read_official_csv still resolves the layout on re-import.
|
||||||
|
Emits segmentExported with success + path (or error).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||||
|
|
||||||
|
source = getattr(self, "_last_split_file", "")
|
||||||
|
segments = getattr(self, "_last_split_segments", [])
|
||||||
|
if not source or not (0 <= index < len(segments)):
|
||||||
|
self.segmentExported.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No split result to export"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
seg = segments[index]
|
||||||
|
src = Path(source)
|
||||||
|
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
|
||||||
|
try:
|
||||||
|
ok = CsvRecorder.write_segment(
|
||||||
|
str(dest), str(src), seg.start_frame, seg.end_frame)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Segment export failed: %s", exc)
|
||||||
|
self.segmentExported.emit({"success": False, "error": str(exc)})
|
||||||
|
return
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
self.segmentExported.emit({"success": True, "path": str(dest)})
|
||||||
|
else:
|
||||||
|
self.segmentExported.emit({
|
||||||
|
"success": False,
|
||||||
|
"error": "No frames in range"
|
||||||
|
})
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def play(self) -> None:
|
def play(self) -> None:
|
||||||
self._play_timer.start(self._next_interval_ms())
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def pause(self) -> None: self._play_timer.stop()
|
@slot_error_boundary
|
||||||
|
def pause(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._player.seek(0)
|
self._player.seek(0)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def step(self, delta: int) -> None:
|
def step(self, delta: int) -> None:
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._player.step(delta)
|
self._player.step(delta)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
self.stateChanged.emit()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def seek(self, i: int) -> None:
|
def seek(self, i: int) -> None:
|
||||||
self._player.seek(i)
|
self._player.seek(i)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot(float)
|
@Slot(float)
|
||||||
|
@slot_error_boundary
|
||||||
def setSpeed(self, x: float) -> None:
|
def setSpeed(self, x: float) -> None:
|
||||||
self._speed = max(0.1, x)
|
self._speed = max(0.1, x)
|
||||||
if self._play_timer.isActive():
|
if self._play_timer.isActive():
|
||||||
@@ -289,6 +637,7 @@ class SessionController(QObject):
|
|||||||
def _advance(self) -> None:
|
def _advance(self) -> None:
|
||||||
if self._player.at_end:
|
if self._player.at_end:
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
|
self.stateChanged.emit()
|
||||||
return
|
return
|
||||||
self._player.step(1)
|
self._player.step(1)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
@@ -325,7 +674,11 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- live: stream start/stop ----
|
# ---- live: stream start/stop ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def startStream(self, port: str, family_code: str = "") -> None:
|
def startStream(self, port: str, family_code: str = "") -> None:
|
||||||
|
if self._reader is not None:
|
||||||
|
log.warning("startStream: StreamReader is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
from pygui.serialcomm.serial_port import SerialPort # transport open
|
from pygui.serialcomm.serial_port import SerialPort # transport open
|
||||||
@@ -336,6 +689,7 @@ class SessionController(QObject):
|
|||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Could not load layout for %s: %s", family_code, e)
|
log.warning("Could not load layout for %s: %s", family_code, e)
|
||||||
|
self._ec_pairs = ec_pairs_for_wafer_id(family_code)
|
||||||
|
|
||||||
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
self._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||||
if not self._active_clusters:
|
if not self._active_clusters:
|
||||||
@@ -366,20 +720,36 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# Clear out any old data from the prev sessions
|
# Clear out any old data from the prev sessions
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
|
self._reset_live_trend()
|
||||||
self._last = None
|
self._last = None
|
||||||
self._last_raw_frame = None
|
self._last_raw_frame = None
|
||||||
|
self._received_count = 0
|
||||||
|
self._error_count = 0
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
|
|
||||||
transport = SerialPort.open_port(port, timeout=1)
|
transport = SerialPort.open_port(port, timeout=1)
|
||||||
# Send 'D2' command padded to 512 bytes to start the stream
|
# Send 'D2' command padded to 512 bytes to start the stream
|
||||||
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
||||||
transport.write(cmd)
|
transport.write(cmd)
|
||||||
|
|
||||||
|
import weakref
|
||||||
|
weak_self = weakref.ref(self)
|
||||||
|
|
||||||
def on_error(exc: Exception):
|
def on_error(exc: Exception):
|
||||||
log.error("Live stream error: %s", exc)
|
log.error("Live stream error: %s", exc)
|
||||||
|
ref = weak_self()
|
||||||
|
if ref is not None:
|
||||||
|
ref._liveError.emit()
|
||||||
|
ref.stopStream()
|
||||||
|
|
||||||
|
def on_frame(frame: Frame):
|
||||||
|
ref = weak_self()
|
||||||
|
if ref is not None:
|
||||||
|
ref._liveFrame.emit(frame)
|
||||||
|
|
||||||
self._reader = StreamReader(
|
self._reader = StreamReader(
|
||||||
transport, parse_binary_frame,
|
transport, parse_binary_frame,
|
||||||
on_frame=lambda f: self._liveFrame.emit(f),
|
on_frame=on_frame,
|
||||||
on_error=on_error,
|
on_error=on_error,
|
||||||
family_code=family_code or "A")
|
family_code=family_code or "A")
|
||||||
self._reader.start()
|
self._reader.start()
|
||||||
@@ -389,14 +759,17 @@ class SessionController(QObject):
|
|||||||
self.stateChanged.emit()
|
self.stateChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stopStream(self) -> None:
|
def stopStream(self) -> None:
|
||||||
self._repaint_timer.stop()
|
self._repaint_timer.stop()
|
||||||
|
self._received_count = 0
|
||||||
|
self._error_count = 0
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
if self._reader:
|
if self._reader:
|
||||||
transport = self._reader._transport
|
transport = self._reader._transport
|
||||||
self._reader.stop()
|
|
||||||
self._reader = None
|
|
||||||
|
|
||||||
# Send 'D2S' command padded to 512 bytes to stop the stream
|
# Send 'D2S' command padded to 512 bytes to stop the stream
|
||||||
|
# Send BEFORE stopping the reader which will close the port on thread exit
|
||||||
if transport and transport.is_open:
|
if transport and transport.is_open:
|
||||||
try:
|
try:
|
||||||
cmd = b"D2S" + (b"F" * 509)
|
cmd = b"D2S" + (b"F" * 509)
|
||||||
@@ -405,11 +778,17 @@ class SessionController(QObject):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("Error sending stop command: %s", exc)
|
log.error("Error sending stop command: %s", exc)
|
||||||
|
|
||||||
|
self._reader.stop()
|
||||||
|
self._reader = None
|
||||||
|
|
||||||
self.stateChanged.emit()
|
self.stateChanged.emit()
|
||||||
self.stopRecording()
|
self.stopRecording()
|
||||||
|
|
||||||
@Slot(object)
|
@Slot(object)
|
||||||
|
@slot_error_boundary
|
||||||
def _on_live_frame(self, frame: Frame) -> None:
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
|
self._received_count += 1
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||||
self._last_raw_frame = frame
|
self._last_raw_frame = frame
|
||||||
edited = Frame(seq=frame.seq, time=frame.time,
|
edited = Frame(seq=frame.seq, time=frame.time,
|
||||||
@@ -419,6 +798,11 @@ class SessionController(QObject):
|
|||||||
self._recorder.write(frame) # always record raw values, never edited
|
self._recorder.write(frame) # always record raw values, never edited
|
||||||
self._dirty = True
|
self._dirty = True
|
||||||
|
|
||||||
|
if self._last and self._last.stats:
|
||||||
|
avg = self._last.stats.avg
|
||||||
|
elapsed = time.monotonic() - self._stream_start_time
|
||||||
|
self.trendDelta.emit(json.dumps([[elapsed, avg]]))
|
||||||
|
|
||||||
def _flush_repaint(self) -> None:
|
def _flush_repaint(self) -> None:
|
||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
return
|
return
|
||||||
@@ -426,40 +810,55 @@ class SessionController(QObject):
|
|||||||
self.frameUpdated.emit()
|
self.frameUpdated.emit()
|
||||||
self.stateChanged.emit()
|
self.stateChanged.emit()
|
||||||
|
|
||||||
|
def _on_live_error(self) -> None:
|
||||||
|
"""Main-thread callback for worker-thread stream errors."""
|
||||||
|
self._error_count += 1
|
||||||
|
self.liveStatsChanged.emit()
|
||||||
|
|
||||||
# ---- recording ----
|
# ---- recording ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def startRecording(self, path: str, serial: str = "") -> None:
|
def startRecording(self, path: str, serial: str = "") -> None:
|
||||||
self._recorder.start(path, self._sensors, serial)
|
self._recorder.start(path, self._sensors, serial)
|
||||||
self.recordingChanged.emit()
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stopRecording(self) -> None:
|
def stopRecording(self) -> None:
|
||||||
if self._recorder.is_recording:
|
if self._recorder.is_recording:
|
||||||
self._recorder.stop()
|
self._recorder.stop()
|
||||||
self.recordingChanged.emit()
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
@Slot(int, float)
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
def replaceSensor(self, index: int, value: float) -> None:
|
def replaceSensor(self, index: int, value: float) -> None:
|
||||||
"""Override sensor `index` to display `value` every frame."""
|
"""Override sensor `index` to display `value` every frame."""
|
||||||
self._sensor_editor.set_replacement(index, value)
|
self._sensor_editor.set_replacement(index, value)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot(int, float)
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
def offsetSensor(self, index: int, delta: float) -> None:
|
def offsetSensor(self, index: int, delta: float) -> None:
|
||||||
"""Shift sensor `index` by `delta` every frame."""
|
"""Shift sensor `index` by `delta` every frame."""
|
||||||
self._sensor_editor.set_offset(index, delta)
|
self._sensor_editor.set_offset(index, delta)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def clearSensorEdit(self, index: int) -> None:
|
def clearSensorEdit(self, index: int) -> None:
|
||||||
"""Remove all overrides for sensor `index`."""
|
"""Remove all overrides for sensor `index`."""
|
||||||
self._sensor_editor.clear(index)
|
self._sensor_editor.clear(index)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearSensorEdits(self) -> None:
|
def clearSensorEdits(self) -> None:
|
||||||
"""Remove all sensor overrides."""
|
"""Remove all sensor overrides."""
|
||||||
self._sensor_editor.clear()
|
self._sensor_editor.clear()
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
|
def _reset_live_trend(self) -> None:
|
||||||
|
"""Mark a fresh stream start for elapsed-time trend deltas."""
|
||||||
|
self._stream_start_time = time.monotonic()
|
||||||
|
self.trendReset.emit()
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# ===== Crypto Sub-package =====
|
|
||||||
from pygui.backend.crypto.crypto_helper import * # noqa: F403
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# ===== Data Sub-package =====
|
|
||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
|
||||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
|
||||||
from pygui.backend.data.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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
# ===== Backend Data Constants =====
|
# ===== Backend Data Constants =====
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QStandardPaths
|
||||||
|
|
||||||
DEFAULT_DATA_DIR_NAME = "isc_data"
|
DEFAULT_DATA_DIR_NAME = "isc_data"
|
||||||
|
|
||||||
|
|
||||||
|
def default_data_dir() -> Path:
|
||||||
|
"""Documents-folder isc_data directory used as the default app data location."""
|
||||||
|
documents_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DocumentsLocation)
|
||||||
|
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
||||||
|
return base_dir / DEFAULT_DATA_DIR_NAME
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ class CSVFileMetadata:
|
|||||||
"""Return the first character of the wafer string, or empty if none"""
|
"""Return the first character of the wafer string, or empty if none"""
|
||||||
return self.wafer[0] if self.wafer else ""
|
return self.wafer[0] if self.wafer else ""
|
||||||
|
|
||||||
def get_date(self) -> datetime:
|
def get_date(self) -> datetime | None:
|
||||||
"""Parsifes the date string. Returns current time if parsing fails"""
|
"""Parsifes the date string. Returns None if parsing fails"""
|
||||||
date_format = "%Y-%m-%d %H:%M:%S"
|
date_format = "%Y-%m-%d %H:%M:%S"
|
||||||
try:
|
try:
|
||||||
return datetime.strptime(self.date, date_format)
|
return datetime.strptime(self.date, date_format)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Append live frames"""
|
"""Append live frames"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -12,17 +13,15 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
|||||||
class CsvRecorder:
|
class CsvRecorder:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._file_handle: Optional[TextIO] = None
|
self._file_handle: Optional[TextIO] = None
|
||||||
self._oath: Optional[str] = None
|
self._path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_recording(self) -> bool:
|
def is_recording(self) -> bool:
|
||||||
return self._file_handle is not None
|
return self._file_handle is not None
|
||||||
|
|
||||||
|
def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None:
|
||||||
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
|
|
||||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
file_handle = open(path, "w", encoding="utf-8", newline ="")
|
file_handle = open(path, "w", encoding="utf-8", newline="")
|
||||||
file_handle.write(f"Wafer ID={serial}\n")
|
file_handle.write(f"Wafer ID={serial}\n")
|
||||||
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
|
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
|
||||||
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
|
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
|
||||||
@@ -32,7 +31,7 @@ class CsvRecorder:
|
|||||||
file_handle.flush()
|
file_handle.flush()
|
||||||
self._file_handle, self._path = file_handle, path
|
self._file_handle, self._path = file_handle, path
|
||||||
|
|
||||||
def write(self, frame: Frame) -> None:
|
def write(self, frame: Frame) -> None:
|
||||||
if self._file_handle is None:
|
if self._file_handle is None:
|
||||||
return
|
return
|
||||||
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
|
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
|
||||||
@@ -45,3 +44,60 @@ class CsvRecorder:
|
|||||||
self._file_handle.close()
|
self._file_handle.close()
|
||||||
path, self._file_handle, self._path = self._path, None, None
|
path, self._file_handle, self._path = self._path, None, None
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool:
|
||||||
|
"""Copy a range of frames from source CSV into a new file.
|
||||||
|
|
||||||
|
Preserves the source's header structure so the segment re-imports
|
||||||
|
through FileBrowser unchanged: official CSVs keep their sensor-name
|
||||||
|
row, Z-wafer CSVs keep every metadata line through the "data"
|
||||||
|
sentinel. Frame indices count only valid data rows, matching how
|
||||||
|
the readers in data_records.py number frames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Output CSV path.
|
||||||
|
source_path: Source CSV path with full wafer data
|
||||||
|
start_frame: First frame index (inclusive).
|
||||||
|
end_frame: Last frame index (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True on success (at least one frame written).
|
||||||
|
"""
|
||||||
|
from pygui.backend.data.data_records import is_data_row, is_official_csv
|
||||||
|
|
||||||
|
if start_frame < 0 or end_frame < start_frame:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(source_path, "r", encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
written = 0
|
||||||
|
frame_idx = -1
|
||||||
|
in_data = is_official_csv(source_path)
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.rstrip().rstrip(",").strip()
|
||||||
|
if not in_data:
|
||||||
|
out.append(line)
|
||||||
|
if stripped and stripped.split(",")[0].lower() == "data":
|
||||||
|
in_data = True
|
||||||
|
continue
|
||||||
|
if i == 0:
|
||||||
|
# official format: row 0 is the sensor-name header
|
||||||
|
out.append(line)
|
||||||
|
continue
|
||||||
|
if not stripped or not is_data_row(stripped):
|
||||||
|
continue
|
||||||
|
frame_idx += 1
|
||||||
|
if start_frame <= frame_idx <= end_frame:
|
||||||
|
out.append(line if line.endswith("\n") else line + "\n")
|
||||||
|
written += 1
|
||||||
|
|
||||||
|
if written == 0:
|
||||||
|
return False
|
||||||
|
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(file_path, "w", encoding="utf-8", newline="") as f:
|
||||||
|
f.writelines(out)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
|
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -6,6 +7,20 @@ from pathlib import Path
|
|||||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
|
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
|
||||||
|
|
||||||
|
|
||||||
|
def is_data_row(line: str) -> bool:
|
||||||
|
"""True if *line* is a single CSV row of numeric values (no headers/metadata)."""
|
||||||
|
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
||||||
|
return bool(cols) and all(_is_float(c) for c in cols)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_float(s: str) -> bool:
|
||||||
|
try:
|
||||||
|
float(s)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def read_data_records(file_path: str) -> list[DataRecord]:
|
def read_data_records(file_path: str) -> list[DataRecord]:
|
||||||
records: list[DataRecord] = []
|
records: list[DataRecord] = []
|
||||||
in_data = False
|
in_data = False
|
||||||
@@ -18,13 +33,10 @@ def read_data_records(file_path: str) -> list[DataRecord]:
|
|||||||
if line.split(",")[0].lower() == "data":
|
if line.split(",")[0].lower() == "data":
|
||||||
in_data = True
|
in_data = True
|
||||||
continue
|
continue
|
||||||
|
if not is_data_row(line):
|
||||||
|
continue
|
||||||
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
||||||
try:
|
nums = [float(c) for c in cols]
|
||||||
nums = [float(c) for c in cols]
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if not nums:
|
|
||||||
continue
|
|
||||||
records.append(DataRecord(time=nums[0], values=nums[1:]))
|
records.append(DataRecord(time=nums[0], values=nums[1:]))
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||||
|
|
||||||
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
|
from pygui.backend.data.constants import default_data_dir
|
||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ class FileBrowser(QObject):
|
|||||||
self._refresh_files(show_empty_message=False)
|
self._refresh_files(show_empty_message=False)
|
||||||
|
|
||||||
# ===== Exposed Properties =====
|
# ===== Exposed Properties =====
|
||||||
@Property("QVariantList", notify=filesChanged)
|
@Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
|
||||||
def files(self) -> list[dict[str, object]]:
|
def files(self) -> list[dict[str, object]]:
|
||||||
return [dict(row) for row in self._files]
|
return [dict(row) for row in self._files]
|
||||||
|
|
||||||
@@ -37,12 +37,22 @@ class FileBrowser(QObject):
|
|||||||
return str(self._current_directory)
|
return str(self._current_directory)
|
||||||
|
|
||||||
# ===== Directory Selection =====
|
# ===== Directory Selection =====
|
||||||
|
@Slot(str)
|
||||||
|
def setCurrentDirectory(self, path: str) -> None:
|
||||||
|
"""Point the browser at `path` without opening a native dialog.
|
||||||
|
|
||||||
|
Used to keep this directory in sync with DeviceController.saveDataDir
|
||||||
|
when the user picks a save folder elsewhere in the app.
|
||||||
|
"""
|
||||||
|
self._set_current_directory(Path(path))
|
||||||
|
self._refresh_files(show_empty_message=False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def chooseDirectory(self) -> None:
|
def chooseDirectory(self) -> None:
|
||||||
selected_dir = QFileDialog.getExistingDirectory(
|
selected_dir = QFileDialog.getExistingDirectory(
|
||||||
None,
|
None,
|
||||||
"Select CSV Folder",
|
"Select CSV Folder",
|
||||||
self.currentDirectory,
|
str(self._current_directory),
|
||||||
)
|
)
|
||||||
if not selected_dir:
|
if not selected_dir:
|
||||||
return
|
return
|
||||||
@@ -71,17 +81,16 @@ class FileBrowser(QObject):
|
|||||||
self._show_error(f"CSV file was not found:\n{file_path}")
|
self._show_error(f"CSV file was not found:\n{file_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ponytail: row is absent when saving metadata for a CSV just read from
|
||||||
|
# the wafer (not in the browser listing) — still write the sidecar.
|
||||||
row = self._find_row(file_path)
|
row = self._find_row(file_path)
|
||||||
if row is None:
|
if row is not None:
|
||||||
self._show_error("The selected CSV row is no longer available.")
|
row["wafer"] = wafer
|
||||||
return
|
row["date"] = date
|
||||||
|
row["chamber"] = chamber
|
||||||
row["wafer"] = wafer
|
row["notes"] = notes
|
||||||
row["date"] = date
|
row["selected"] = selected
|
||||||
row["chamber"] = chamber
|
row["masterType"] = master_type
|
||||||
row["notes"] = notes
|
|
||||||
row["selected"] = selected
|
|
||||||
row["masterType"] = master_type
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"wafer": wafer,
|
"wafer": wafer,
|
||||||
@@ -119,6 +128,11 @@ class FileBrowser(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
||||||
|
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
||||||
|
# SessionController.startRecording (see WaferMapTab's Record button),
|
||||||
|
# distinct from the "<serial>-<timestamp>.csv" convention used by
|
||||||
|
# DeviceController.parseAndSaveData for read-memory dumps.
|
||||||
|
is_recording = csv_path.stem.lower().startswith("live_")
|
||||||
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 "")
|
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
|
||||||
@@ -146,6 +160,7 @@ class FileBrowser(QObject):
|
|||||||
"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": wafer_type in {"A", "B", "C"},
|
"highlight": wafer_type in {"A", "B", "C"},
|
||||||
|
"isRecording": is_recording,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -169,6 +184,7 @@ class FileBrowser(QObject):
|
|||||||
"masterType": master_state.get(str(csv_path), ""),
|
"masterType": master_state.get(str(csv_path), ""),
|
||||||
"fileName": str(csv_path),
|
"fileName": str(csv_path),
|
||||||
"highlight": False,
|
"highlight": False,
|
||||||
|
"isRecording": is_recording,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -229,11 +245,7 @@ class FileBrowser(QObject):
|
|||||||
|
|
||||||
# ===== Internal Helpers =====
|
# ===== Internal Helpers =====
|
||||||
def _resolve_default_directory(self) -> Path:
|
def _resolve_default_directory(self) -> Path:
|
||||||
documents_dir = QStandardPaths.writableLocation(
|
return default_data_dir()
|
||||||
QStandardPaths.DocumentsLocation
|
|
||||||
)
|
|
||||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
|
||||||
return base_dir / DEFAULT_DATA_DIR_NAME
|
|
||||||
|
|
||||||
def _set_current_directory(self, directory: Path) -> None:
|
def _set_current_directory(self, directory: Path) -> None:
|
||||||
normalized = Path(directory)
|
normalized = Path(directory)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ===== Settings Persistence Model =====
|
# ===== Settings Persistence Model =====
|
||||||
class LocalSettings:
|
class LocalSettings:
|
||||||
@@ -10,8 +13,8 @@ class LocalSettings:
|
|||||||
# Configuration settings
|
# Configuration settings
|
||||||
self.chamber_id = ""
|
self.chamber_id = ""
|
||||||
self.reverse_z_wafer = False
|
self.reverse_z_wafer = False
|
||||||
self.master = {} # Dict[str, str]
|
self.master: dict[str, str] = {}
|
||||||
self.wafer_data_size = {} # Dict[str, int]
|
self.wafer_data_size: dict[str, int] = {}
|
||||||
self.debug = False
|
self.debug = False
|
||||||
self.wafer_read_retries = 8
|
self.wafer_read_retries = 8
|
||||||
# Timeout in msec for reading from the wafer (default 2 min)
|
# Timeout in msec for reading from the wafer (default 2 min)
|
||||||
@@ -51,7 +54,8 @@ class LocalSettings:
|
|||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error reading setting file: {e}")
|
log = logging.getLogger(__name__)
|
||||||
|
log.warning("Error reading setting file: %s",e)
|
||||||
|
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
@@ -66,7 +70,7 @@ class LocalSettings:
|
|||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=4)
|
json.dump(data, f, indent=4)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving settings: {e}")
|
log.exception("Error saving settings: %s",e)
|
||||||
|
|
||||||
# ===== Master File Helpers =====
|
# ===== Master File Helpers =====
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -2,17 +2,26 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot
|
from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot
|
||||||
|
|
||||||
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
|
||||||
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
||||||
|
|
||||||
|
|
||||||
class LocalSettingsModel(QObject):
|
class LocalSettingsModel(QObject):
|
||||||
|
# Draft attrs are seeded via setattr() in __init__ from _new_defaults();
|
||||||
|
# declared here so mypy knows their types.
|
||||||
|
_chamber_id: str
|
||||||
|
_reverse_z_wafer: bool
|
||||||
|
_debug_mode: bool
|
||||||
|
_wafer_read_timeout: int
|
||||||
|
_wafer_detect_timeout: int
|
||||||
|
_wafer_retries: int
|
||||||
|
_masters: dict[str, str]
|
||||||
|
|
||||||
chamberIdChanged = Signal()
|
chamberIdChanged = Signal()
|
||||||
reverseZWaferChanged = Signal()
|
reverseZWaferChanged = Signal()
|
||||||
debugModeChanged = Signal()
|
debugModeChanged = Signal()
|
||||||
@@ -26,18 +35,35 @@ class LocalSettingsModel(QObject):
|
|||||||
saveStatusChanged = Signal()
|
saveStatusChanged = Signal()
|
||||||
lastSavedAtChanged = Signal()
|
lastSavedAtChanged = Signal()
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
# Draft attr (this class) <-> LocalSettings attr (the shared, persisted
|
||||||
|
# instance also held by DeviceController) <-> coercer applied on both
|
||||||
|
# load and revert/reset. One table drives load/save/revert/reset instead
|
||||||
|
# of four hand-written field-by-field copies.
|
||||||
|
_FIELD_MAP: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
|
||||||
|
("_chamber_id", "chamber_id", str),
|
||||||
|
("_reverse_z_wafer", "reverse_z_wafer", bool),
|
||||||
|
("_debug_mode", "debug", bool),
|
||||||
|
("_wafer_read_timeout", "wafer_read_timeout", int),
|
||||||
|
("_wafer_detect_timeout", "wafer_detect_timeout", int),
|
||||||
|
("_wafer_retries", "wafer_read_retries", int),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: LocalSettings,
|
||||||
|
data_dir: Path | str,
|
||||||
|
parent: QObject | None = None,
|
||||||
|
) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._data_dir = self._resolve_data_dir()
|
# Shared with DeviceController — saving here must mutate this same
|
||||||
|
# instance, not a throwaway copy, or the device layer keeps using
|
||||||
|
# stale config until restart.
|
||||||
|
self._settings = settings
|
||||||
|
self._data_dir = Path(data_dir)
|
||||||
self._defaults = self._new_defaults()
|
self._defaults = self._new_defaults()
|
||||||
|
|
||||||
self._chamber_id = self._defaults["chamberId"]
|
for draft_attr, default_value in self._defaults.items():
|
||||||
self._reverse_z_wafer = self._defaults["reverseZWafer"]
|
setattr(self, draft_attr, deepcopy(default_value))
|
||||||
self._debug_mode = self._defaults["debugMode"]
|
|
||||||
self._wafer_read_timeout = self._defaults["waferReadTimeout"]
|
|
||||||
self._wafer_detect_timeout = self._defaults["waferDetectTimeout"]
|
|
||||||
self._wafer_retries = self._defaults["waferRetries"]
|
|
||||||
self._masters = deepcopy(self._defaults["masters"])
|
|
||||||
|
|
||||||
self._is_dirty = False
|
self._is_dirty = False
|
||||||
self._is_valid = True
|
self._is_valid = True
|
||||||
@@ -47,34 +73,21 @@ class LocalSettingsModel(QObject):
|
|||||||
self._saved_snapshot = self._snapshot()
|
self._saved_snapshot = self._snapshot()
|
||||||
self._recompute_derived()
|
self._recompute_derived()
|
||||||
|
|
||||||
def _resolve_data_dir(self) -> Path:
|
|
||||||
documents_dir = QStandardPaths.writableLocation(
|
|
||||||
QStandardPaths.DocumentsLocation
|
|
||||||
)
|
|
||||||
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
|
|
||||||
return base_dir / DEFAULT_DATA_DIR_NAME
|
|
||||||
|
|
||||||
def _new_defaults(self) -> dict[str, Any]:
|
def _new_defaults(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"chamberId": "2",
|
"_chamber_id": "2",
|
||||||
"reverseZWafer": False,
|
"_reverse_z_wafer": False,
|
||||||
"debugMode": False,
|
"_debug_mode": False,
|
||||||
"waferReadTimeout": 120000,
|
"_wafer_read_timeout": 120000,
|
||||||
"waferDetectTimeout": 5000,
|
"_wafer_detect_timeout": 5000,
|
||||||
"waferRetries": 8,
|
"_wafer_retries": 8,
|
||||||
"masters": {family: "" for family in MASTER_FAMILIES},
|
"_masters": {family: "" for family in MASTER_FAMILIES},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _snapshot(self) -> dict[str, Any]:
|
def _snapshot(self) -> dict[str, Any]:
|
||||||
return {
|
snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
|
||||||
"chamberId": self._chamber_id,
|
snap["_masters"] = deepcopy(self._masters)
|
||||||
"reverseZWafer": self._reverse_z_wafer,
|
return snap
|
||||||
"debugMode": self._debug_mode,
|
|
||||||
"waferReadTimeout": self._wafer_read_timeout,
|
|
||||||
"waferDetectTimeout": self._wafer_detect_timeout,
|
|
||||||
"waferRetries": self._wafer_retries,
|
|
||||||
"masters": deepcopy(self._masters),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _set_is_dirty(self, value: bool) -> None:
|
def _set_is_dirty(self, value: bool) -> None:
|
||||||
if self._is_dirty != value:
|
if self._is_dirty != value:
|
||||||
@@ -137,22 +150,19 @@ class LocalSettingsModel(QObject):
|
|||||||
self.waferRetriesChanged.emit()
|
self.waferRetriesChanged.emit()
|
||||||
self.mastersChanged.emit()
|
self.mastersChanged.emit()
|
||||||
|
|
||||||
def _to_local_settings(self) -> LocalSettings:
|
def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
|
||||||
settings = LocalSettings()
|
masters = {family: "" for family in MASTER_FAMILIES}
|
||||||
settings.chamber_id = self._chamber_id
|
for family, value in source.items():
|
||||||
settings.reverse_z_wafer = self._reverse_z_wafer
|
normalized = str(family).strip().upper()
|
||||||
settings.debug = self._debug_mode
|
if normalized in masters:
|
||||||
settings.wafer_read_timeout = self._wafer_read_timeout
|
masters[normalized] = str(value).strip()
|
||||||
settings.wafer_detect_timeout = self._wafer_detect_timeout
|
return masters
|
||||||
settings.wafer_read_retries = self._wafer_retries
|
|
||||||
settings.master = deepcopy(self._masters)
|
|
||||||
return settings
|
|
||||||
|
|
||||||
@Property(str, notify=chamberIdChanged)
|
@Property(str, notify=chamberIdChanged)
|
||||||
def chamberId(self) -> str:
|
def chamberId(self) -> str:
|
||||||
return self._chamber_id
|
return self._chamber_id
|
||||||
|
|
||||||
@chamberId.setter
|
@chamberId.setter # type: ignore[no-redef]
|
||||||
def chamberId(self, value: str) -> None:
|
def chamberId(self, value: str) -> None:
|
||||||
next_value = str(value).strip()
|
next_value = str(value).strip()
|
||||||
if self._chamber_id == next_value:
|
if self._chamber_id == next_value:
|
||||||
@@ -165,7 +175,7 @@ class LocalSettingsModel(QObject):
|
|||||||
def reverseZWafer(self) -> bool:
|
def reverseZWafer(self) -> bool:
|
||||||
return self._reverse_z_wafer
|
return self._reverse_z_wafer
|
||||||
|
|
||||||
@reverseZWafer.setter
|
@reverseZWafer.setter # type: ignore[no-redef]
|
||||||
def reverseZWafer(self, value: bool) -> None:
|
def reverseZWafer(self, value: bool) -> None:
|
||||||
if self._reverse_z_wafer == value:
|
if self._reverse_z_wafer == value:
|
||||||
return
|
return
|
||||||
@@ -177,7 +187,7 @@ class LocalSettingsModel(QObject):
|
|||||||
def debugMode(self) -> bool:
|
def debugMode(self) -> bool:
|
||||||
return self._debug_mode
|
return self._debug_mode
|
||||||
|
|
||||||
@debugMode.setter
|
@debugMode.setter # type: ignore[no-redef]
|
||||||
def debugMode(self, value: bool) -> None:
|
def debugMode(self, value: bool) -> None:
|
||||||
if self._debug_mode == value:
|
if self._debug_mode == value:
|
||||||
return
|
return
|
||||||
@@ -189,7 +199,7 @@ class LocalSettingsModel(QObject):
|
|||||||
def waferReadTimeout(self) -> int:
|
def waferReadTimeout(self) -> int:
|
||||||
return self._wafer_read_timeout
|
return self._wafer_read_timeout
|
||||||
|
|
||||||
@waferReadTimeout.setter
|
@waferReadTimeout.setter # type: ignore[no-redef]
|
||||||
def waferReadTimeout(self, value: int) -> None:
|
def waferReadTimeout(self, value: int) -> None:
|
||||||
if self._wafer_read_timeout == value:
|
if self._wafer_read_timeout == value:
|
||||||
return
|
return
|
||||||
@@ -201,7 +211,7 @@ class LocalSettingsModel(QObject):
|
|||||||
def waferDetectTimeout(self) -> int:
|
def waferDetectTimeout(self) -> int:
|
||||||
return self._wafer_detect_timeout
|
return self._wafer_detect_timeout
|
||||||
|
|
||||||
@waferDetectTimeout.setter
|
@waferDetectTimeout.setter # type: ignore[no-redef]
|
||||||
def waferDetectTimeout(self, value: int) -> None:
|
def waferDetectTimeout(self, value: int) -> None:
|
||||||
if self._wafer_detect_timeout == value:
|
if self._wafer_detect_timeout == value:
|
||||||
return
|
return
|
||||||
@@ -213,7 +223,7 @@ class LocalSettingsModel(QObject):
|
|||||||
def waferRetries(self) -> int:
|
def waferRetries(self) -> int:
|
||||||
return self._wafer_retries
|
return self._wafer_retries
|
||||||
|
|
||||||
@waferRetries.setter
|
@waferRetries.setter # type: ignore[no-redef]
|
||||||
def waferRetries(self, value: int) -> None:
|
def waferRetries(self, value: int) -> None:
|
||||||
if self._wafer_retries == value:
|
if self._wafer_retries == value:
|
||||||
return
|
return
|
||||||
@@ -251,22 +261,11 @@ class LocalSettingsModel(QObject):
|
|||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def loadSettings(self) -> None:
|
def loadSettings(self) -> None:
|
||||||
loaded = LocalSettings.read_settings(str(self._data_dir))
|
"""Seed draft fields from the shared LocalSettings instance."""
|
||||||
self._chamber_id = str(loaded.chamber_id).strip() or str(
|
for draft_attr, settings_attr, coerce in self._FIELD_MAP:
|
||||||
self._defaults["chamberId"]
|
setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
|
||||||
)
|
self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
|
||||||
self._reverse_z_wafer = bool(loaded.reverse_z_wafer)
|
self._masters = self._normalized_masters(self._settings.master)
|
||||||
self._debug_mode = bool(loaded.debug)
|
|
||||||
self._wafer_read_timeout = int(loaded.wafer_read_timeout)
|
|
||||||
self._wafer_detect_timeout = int(loaded.wafer_detect_timeout)
|
|
||||||
self._wafer_retries = int(loaded.wafer_read_retries)
|
|
||||||
|
|
||||||
masters = {family: "" for family in MASTER_FAMILIES}
|
|
||||||
for family, value in loaded.master.items():
|
|
||||||
normalized = str(family).strip().upper()
|
|
||||||
if normalized in masters:
|
|
||||||
masters[normalized] = str(value).strip()
|
|
||||||
self._masters = masters
|
|
||||||
|
|
||||||
self._saved_snapshot = self._snapshot()
|
self._saved_snapshot = self._snapshot()
|
||||||
self._emit_all_changed()
|
self._emit_all_changed()
|
||||||
@@ -275,13 +274,18 @@ class LocalSettingsModel(QObject):
|
|||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def saveSettings(self) -> None:
|
def saveSettings(self) -> None:
|
||||||
|
"""Write draft fields onto the shared LocalSettings instance and persist it."""
|
||||||
self._recompute_derived()
|
self._recompute_derived()
|
||||||
if not self._is_valid:
|
if not self._is_valid:
|
||||||
self._set_save_status("error: invalid settings")
|
self._set_save_status("error: invalid settings")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
LocalSettings.save_settings(str(self._data_dir), self._to_local_settings())
|
for draft_attr, settings_attr, _coerce in self._FIELD_MAP:
|
||||||
|
setattr(self._settings, settings_attr, getattr(self, draft_attr))
|
||||||
|
self._settings.master = deepcopy(self._masters)
|
||||||
|
LocalSettings.save_settings(str(self._data_dir), self._settings)
|
||||||
|
|
||||||
self._saved_snapshot = self._snapshot()
|
self._saved_snapshot = self._snapshot()
|
||||||
self._recompute_derived()
|
self._recompute_derived()
|
||||||
self._set_save_status("Saved")
|
self._set_save_status("Saved")
|
||||||
@@ -293,13 +297,9 @@ class LocalSettingsModel(QObject):
|
|||||||
@Slot()
|
@Slot()
|
||||||
def revertChanges(self) -> None:
|
def revertChanges(self) -> None:
|
||||||
snap = self._saved_snapshot
|
snap = self._saved_snapshot
|
||||||
self._chamber_id = str(snap["chamberId"])
|
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||||
self._reverse_z_wafer = bool(snap["reverseZWafer"])
|
setattr(self, draft_attr, coerce(snap[draft_attr]))
|
||||||
self._debug_mode = bool(snap["debugMode"])
|
self._masters = deepcopy(snap["_masters"])
|
||||||
self._wafer_read_timeout = int(snap["waferReadTimeout"])
|
|
||||||
self._wafer_detect_timeout = int(snap["waferDetectTimeout"])
|
|
||||||
self._wafer_retries = int(snap["waferRetries"])
|
|
||||||
self._masters = deepcopy(snap["masters"])
|
|
||||||
|
|
||||||
self._emit_all_changed()
|
self._emit_all_changed()
|
||||||
self._recompute_derived()
|
self._recompute_derived()
|
||||||
@@ -307,13 +307,9 @@ class LocalSettingsModel(QObject):
|
|||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def resetDefaults(self) -> None:
|
def resetDefaults(self) -> None:
|
||||||
self._chamber_id = str(self._defaults["chamberId"])
|
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||||
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"])
|
setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
|
||||||
self._debug_mode = bool(self._defaults["debugMode"])
|
self._masters = deepcopy(self._defaults["_masters"])
|
||||||
self._wafer_read_timeout = int(self._defaults["waferReadTimeout"])
|
|
||||||
self._wafer_detect_timeout = int(self._defaults["waferDetectTimeout"])
|
|
||||||
self._wafer_retries = int(self._defaults["waferRetries"])
|
|
||||||
self._masters = deepcopy(self._defaults["masters"])
|
|
||||||
|
|
||||||
self._emit_all_changed()
|
self._emit_all_changed()
|
||||||
self._recompute_derived()
|
self._recompute_derived()
|
||||||
@@ -334,27 +330,3 @@ class LocalSettingsModel(QObject):
|
|||||||
@Slot(str)
|
@Slot(str)
|
||||||
def clearMaster(self, family: str) -> None:
|
def clearMaster(self, family: str) -> None:
|
||||||
self.setMaster(family, "")
|
self.setMaster(family, "")
|
||||||
|
|
||||||
@Slot(str)
|
|
||||||
def setChamberId(self, value: str) -> None:
|
|
||||||
self.chamberId = value
|
|
||||||
|
|
||||||
@Slot(bool)
|
|
||||||
def setReverseZWafer(self, value: bool) -> None:
|
|
||||||
self.reverseZWafer = value
|
|
||||||
|
|
||||||
@Slot(bool)
|
|
||||||
def setDebugMode(self, value: bool) -> None:
|
|
||||||
self.debugMode = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferReadTimeout(self, value: int) -> None:
|
|
||||||
self.waferReadTimeout = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferDetectTimeout(self, value: int) -> None:
|
|
||||||
self.waferDetectTimeout = value
|
|
||||||
|
|
||||||
@Slot(int)
|
|
||||||
def setWaferRetries(self, value: int) -> None:
|
|
||||||
self.waferRetries = value
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""C#-compatible wafer license file parsing.
|
||||||
|
|
||||||
|
License files are AES-128-CBC encrypted `.bin` blobs named
|
||||||
|
``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the
|
||||||
|
5-digit serial number, and ``00`` the 2-digit license level. The blob is
|
||||||
|
``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is
|
||||||
|
a fixed-offset record::
|
||||||
|
|
||||||
|
[0:6] serial e.g. "A00001"
|
||||||
|
[7:15] mfg date 8 hex chars
|
||||||
|
[16:18] level 2 decimal digits ("00" basic, "01"+ adds replay)
|
||||||
|
[19:27] license date 8 hex chars
|
||||||
|
|
||||||
|
The key is hardcoded to match the C# application exactly
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.crypto.crypto_helper import CryptoHelper
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LICENSE_KEY = b"ThisIsA16ByteKey"
|
||||||
|
LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$")
|
||||||
|
|
||||||
|
_MIN_TEXT_LEN = 27
|
||||||
|
|
||||||
|
|
||||||
|
# ===== License Record =====
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class License:
|
||||||
|
"""One parsed license file (all fields as stored in the file)."""
|
||||||
|
|
||||||
|
serial: str # "A00001"
|
||||||
|
level: str # "00".."03"
|
||||||
|
mfg_date_hex: str # 8 hex chars
|
||||||
|
lic_date_hex: str # 8 hex chars
|
||||||
|
|
||||||
|
@property
|
||||||
|
def level_int(self) -> int:
|
||||||
|
"""Level as int; -1 if unparseable."""
|
||||||
|
try:
|
||||||
|
return int(self.level, 10)
|
||||||
|
except ValueError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mfg_date_decimal(self) -> str:
|
||||||
|
"""Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16))."""
|
||||||
|
return _hex_to_decimal(self.mfg_date_hex)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lic_date_decimal(self) -> str:
|
||||||
|
"""License date shown as decimal, same conversion as the C# grid."""
|
||||||
|
return _hex_to_decimal(self.lic_date_hex)
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_decimal(hex_str: str) -> str:
|
||||||
|
try:
|
||||||
|
return str(int(hex_str, 16))
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Blob Parsing =====
|
||||||
|
def split_data_iv(blob: bytes) -> tuple[bytes, bytes]:
|
||||||
|
"""Split a license blob into (ciphertext, iv) — IV is the last 16 bytes."""
|
||||||
|
if len(blob) < 32:
|
||||||
|
raise ValueError("License blob too short")
|
||||||
|
return blob[:-16], blob[-16:]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_license_blob(blob: bytes) -> License | None:
|
||||||
|
"""Decrypt and parse one license blob. None if malformed."""
|
||||||
|
try:
|
||||||
|
data, iv = split_data_iv(blob)
|
||||||
|
message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv)
|
||||||
|
text = message.decode("utf-8")
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
log.warning("Undecryptable license blob: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(text) < _MIN_TEXT_LEN:
|
||||||
|
log.warning("License text too short (%d chars)", len(text))
|
||||||
|
return None
|
||||||
|
|
||||||
|
return License(
|
||||||
|
serial=text[0:6],
|
||||||
|
mfg_date_hex=text[7:15],
|
||||||
|
level=text[16:18],
|
||||||
|
lic_date_hex=text[19:27],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Directory Scan =====
|
||||||
|
def read_license_files(licenses_dir: str | Path) -> list[License]:
|
||||||
|
"""Parse every valid license .bin in the directory.
|
||||||
|
|
||||||
|
A file counts only if its name matches ``X00000-00.bin`` AND equals
|
||||||
|
``{serial}-{level}.bin`` from its own decrypted payload — same
|
||||||
|
tamper check as the C# app.
|
||||||
|
"""
|
||||||
|
directory = Path(licenses_dir)
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
licenses: list[License] = []
|
||||||
|
for path in sorted(directory.glob("*.bin")):
|
||||||
|
if not LICENSE_FILENAME_RE.match(path.name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blob = path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot read license file %s: %s", path, exc)
|
||||||
|
continue
|
||||||
|
lic = parse_license_blob(blob)
|
||||||
|
if lic is None:
|
||||||
|
continue
|
||||||
|
if path.name != f"{lic.serial}-{lic.level}.bin":
|
||||||
|
log.warning("License filename mismatch: %s", path.name)
|
||||||
|
continue
|
||||||
|
licenses.append(lic)
|
||||||
|
return licenses
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""QML-facing license model: grid rows, Load License, replay trial marker."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
||||||
|
|
||||||
|
from pygui.backend.license.license_manager import (
|
||||||
|
LICENSE_FILENAME_RE,
|
||||||
|
License,
|
||||||
|
parse_license_blob,
|
||||||
|
read_license_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TRIAL_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _to_local_path(path_or_url: str) -> Path:
|
||||||
|
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs)."""
|
||||||
|
if path_or_url.startswith("file:"):
|
||||||
|
return Path(unquote(urlparse(path_or_url).path))
|
||||||
|
return Path(path_or_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== License Model =====
|
||||||
|
class LicenseModel(QObject):
|
||||||
|
"""Context property backing the About dialog license grid.
|
||||||
|
|
||||||
|
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
|
||||||
|
``DataDir\\licenses``). The 30-day replay trial marker is
|
||||||
|
``<data_dir>/replay.lic``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
licensesChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._data_dir = Path(data_dir)
|
||||||
|
self._licenses_dir = self._data_dir / "licenses"
|
||||||
|
try:
|
||||||
|
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
|
||||||
|
self._licenses: list[License] = []
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
# ── Grid rows ─────────────────────────────────────────────────────
|
||||||
|
@Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
|
||||||
|
def licenses(self) -> list:
|
||||||
|
"""Rows for the About grid: serial, mfg date, level, license date."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"serial": lic.serial,
|
||||||
|
"mfgDate": lic.mfg_date_decimal,
|
||||||
|
"level": lic.level,
|
||||||
|
"licDate": lic.lic_date_decimal,
|
||||||
|
}
|
||||||
|
for lic in self._licenses
|
||||||
|
]
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def refresh(self) -> None:
|
||||||
|
"""Re-scan the licenses directory."""
|
||||||
|
self._licenses = read_license_files(self._licenses_dir)
|
||||||
|
self.licensesChanged.emit()
|
||||||
|
|
||||||
|
# ── Load License ──────────────────────────────────────────────────
|
||||||
|
@Slot(str, result=bool)
|
||||||
|
def loadLicenseFile(self, path_or_url: str) -> bool:
|
||||||
|
"""Validate a picked .bin and copy it into the licenses directory."""
|
||||||
|
src = _to_local_path(path_or_url)
|
||||||
|
if not src.is_file():
|
||||||
|
log.warning("License file does not exist: %s", src)
|
||||||
|
return False
|
||||||
|
if not LICENSE_FILENAME_RE.match(src.name):
|
||||||
|
log.warning("License filename invalid: %s", src.name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
blob = src.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot read license file %s: %s", src, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
lic = parse_license_blob(blob)
|
||||||
|
if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
|
||||||
|
log.warning("License file failed validation: %s", src.name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._licenses_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(src, self._licenses_dir / src.name)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.refresh()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Wafer detect (informational) ──────────────────────────────────
|
||||||
|
@Slot(str, result=str)
|
||||||
|
def levelForWafer(self, serial: str) -> str:
|
||||||
|
"""License level for a wafer serial like "A00001"; "" if unlicensed."""
|
||||||
|
for lic in self._licenses:
|
||||||
|
if lic.serial == serial:
|
||||||
|
return lic.level
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ── Replay trial (30-day marker, C# parity) ───────────────────────
|
||||||
|
@property
|
||||||
|
def _trial_marker(self) -> Path:
|
||||||
|
return self._data_dir / "replay.lic"
|
||||||
|
|
||||||
|
@Slot(result=str)
|
||||||
|
def replayLicenseState(self) -> str:
|
||||||
|
""""permanent" if any license level >= 1, "temporary" if trial
|
||||||
|
marker exists, else "none" (trial not started)."""
|
||||||
|
if any(lic.level_int >= 1 for lic in self._licenses):
|
||||||
|
return "permanent"
|
||||||
|
if self._trial_marker.exists():
|
||||||
|
return "temporary"
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
@Slot(result=bool)
|
||||||
|
def startReplayTrial(self) -> bool:
|
||||||
|
"""Create the trial marker (idempotent). False on I/O failure."""
|
||||||
|
try:
|
||||||
|
self._trial_marker.touch(exist_ok=True)
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Cannot create trial marker: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@Slot(result=bool)
|
||||||
|
def replayTrialExpired(self) -> bool:
|
||||||
|
"""True once the marker is older than 30 days (or missing)."""
|
||||||
|
marker = self._trial_marker
|
||||||
|
if not marker.exists():
|
||||||
|
return True
|
||||||
|
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
|
||||||
|
return age_days > TRIAL_DAYS
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# ===== Models Sub-package =====
|
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
|
||||||
from pygui.backend.models.frame import Frame
|
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
|
||||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor
|
|
||||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
|
||||||
from pygui.backend.models.stability_detector import StabilityDetector
|
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Frame", "FramePlayer", "frames_from_wafer_data",
|
|
||||||
"Stats", "compute_stats",
|
|
||||||
"SessionModel", "SessionUpdate",
|
|
||||||
"TemperatureTableModel",
|
|
||||||
"SensorEditor",
|
|
||||||
"ThresholdConfig", "classify", "resolve_bounds",
|
|
||||||
"StabilityDetector",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
|||||||
# Column 0 = row index, columns 1..N = sensor values
|
# Column 0 = row index, columns 1..N = sensor values
|
||||||
return self._col_count + 1
|
return self._col_count + 1
|
||||||
|
|
||||||
def data(self, index: Any, role: int = ...) -> Any:
|
def data(self, index: Any, role: int = ...) -> Any: # type: ignore[assignment]
|
||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def headerData(
|
def headerData(
|
||||||
self, section: int, orientation: int, role: int = ...
|
self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
|
||||||
) -> Any:
|
) -> Any:
|
||||||
if role != Qt.ItemDataRole.DisplayRole:
|
if role != Qt.ItemDataRole.DisplayRole:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -4,10 +4,23 @@ from pygui.backend.models.frame import Frame
|
|||||||
from pygui.backend.wafer.zwafer_models import ZWaferData
|
from pygui.backend.wafer.zwafer_models import ZWaferData
|
||||||
|
|
||||||
|
|
||||||
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
|
def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
|
||||||
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
||||||
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
||||||
|
|
||||||
|
|
||||||
|
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
|
||||||
|
"""Per-sensor value series across every frame, for the whole-run Graph tab.
|
||||||
|
|
||||||
|
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
|
||||||
|
"""
|
||||||
|
if not frames:
|
||||||
|
return [], []
|
||||||
|
n_sensors = len(frames[0].values)
|
||||||
|
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
|
||||||
|
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
|
||||||
|
return names, series
|
||||||
|
|
||||||
class FramePlayer:
|
class FramePlayer:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._frames: list[Frame] =[]
|
self._frames: list[Frame] =[]
|
||||||
@@ -22,6 +35,10 @@ class FramePlayer:
|
|||||||
def total(self) -> int:
|
def total(self) -> int:
|
||||||
return len(self._frames)
|
return len(self._frames)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frames(self) -> tuple[Frame, ...]:
|
||||||
|
return tuple(self._frames)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def index(self) -> int:
|
def index(self) -> int:
|
||||||
return self._i
|
return self._i
|
||||||
|
|||||||
@@ -14,6 +14,44 @@ class Stats:
|
|||||||
sigma: float; three_sigma: float
|
sigma: float; three_sigma: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EdgeCenterStats:
|
||||||
|
min_edge_index: int; min_center_index: int
|
||||||
|
min_edge: float; min_center: float; min_delta: float
|
||||||
|
max_edge_index: int; max_center_index: int
|
||||||
|
max_edge: float; max_center: float; max_delta: float
|
||||||
|
|
||||||
|
|
||||||
|
def compute_edge_center(
|
||||||
|
values: list[float],
|
||||||
|
pairs: tuple[tuple[int, int], ...],
|
||||||
|
excluded: set[int],
|
||||||
|
) -> EdgeCenterStats | None:
|
||||||
|
"""Min/max |edge - center| over the family's pair table.
|
||||||
|
|
||||||
|
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
|
||||||
|
NaN value are skipped. Returns None when no pair is computable.
|
||||||
|
"""
|
||||||
|
n = len(values)
|
||||||
|
best: list[tuple[float, int, int]] = []
|
||||||
|
for e, c in pairs:
|
||||||
|
if e in excluded or c in excluded or e >= n or c >= n:
|
||||||
|
continue
|
||||||
|
if math.isnan(values[e]) or math.isnan(values[c]):
|
||||||
|
continue
|
||||||
|
best.append((abs(values[e] - values[c]), e, c))
|
||||||
|
if not best:
|
||||||
|
return None
|
||||||
|
min_d, min_e, min_c = min(best)
|
||||||
|
max_d, max_e, max_c = max(best)
|
||||||
|
return EdgeCenterStats(
|
||||||
|
min_edge_index=min_e, min_center_index=min_c,
|
||||||
|
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
|
||||||
|
max_edge_index=max_e, max_center_index=max_c,
|
||||||
|
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def compute_stats(values: list[float]) -> Stats:
|
def compute_stats(values: list[float]) -> Stats:
|
||||||
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
||||||
if not clean:
|
if not clean:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayStatsTracker:
|
||||||
|
"""Accumulates per-frame statistics across a replay session."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.history: list[dict] = []
|
||||||
|
self.running_avg: float = 0.0
|
||||||
|
|
||||||
|
def track(self, frame_index: int, temperatures: list[float]) -> dict:
|
||||||
|
arr = np.array(temperatures, dtype=np.float32)
|
||||||
|
clean_arr = arr[np.isfinite(arr)]
|
||||||
|
|
||||||
|
if clean_arr.size == 0:
|
||||||
|
stats = {"frame": frame_index, "min":0.0, "max":0.0, "avg":0.0, "std":0.0}
|
||||||
|
else:
|
||||||
|
stats = {
|
||||||
|
"frame": frame_index,
|
||||||
|
"min": float(np.min(clean_arr)),
|
||||||
|
"max": float(np.max(clean_arr)),
|
||||||
|
"avg": float(np.mean(clean_arr)),
|
||||||
|
"std": float(np.std(clean_arr)),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.history.append(stats)
|
||||||
|
self.running_avg = float(np.mean([s["avg"] for s in self.history]))
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def get_trend(self) -> list[float]:
|
||||||
|
"""Return list of per-frame averages for the trend chart."""
|
||||||
|
return [s["avg"] for s in self.history]
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.history.clear()
|
||||||
|
self.running_avg = 0.0
|
||||||
@@ -28,6 +28,11 @@ class SensorEditor:
|
|||||||
def has_overrides(self) -> bool:
|
def has_overrides(self) -> bool:
|
||||||
return bool(self._replacements or self._offsets)
|
return bool(self._replacements or self._offsets)
|
||||||
|
|
||||||
|
def replaced_indices(self) -> set[int]:
|
||||||
|
"""Sensors whose value is a replacement (offsets don't count — an
|
||||||
|
offset sensor still reads from its physical location)."""
|
||||||
|
return set(self._replacements)
|
||||||
|
|
||||||
def active_indices(self) -> list[int]:
|
def active_indices(self) -> list[int]:
|
||||||
"""Return sorted sensor indices that have any override."""
|
"""Return sorted sensor indices that have any override."""
|
||||||
return sorted(set(self._replacements) | set(self._offsets))
|
return sorted(set(self._replacements) | set(self._offsets))
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Threshold-based temperature profile segmentation into Ramp/Soak/Cool phases."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataSegment:
|
||||||
|
"""A contiguous segment of the temperature profile."""
|
||||||
|
label: str
|
||||||
|
start_frame: int
|
||||||
|
end_frame: int
|
||||||
|
avg_temp: float
|
||||||
|
|
||||||
|
|
||||||
|
def segment_profile(avg_temps: list[float], threshold: float = 40.0) -> list[DataSegment]:
|
||||||
|
"""Scan average temperatures and identify Ramp/Soak/Cool phases.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
avg_temps: Per-frame average temperatures.
|
||||||
|
threshold: Temperature threshold (°C) defining Soak entry/exit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DataSegment objects covering the entire profile.
|
||||||
|
"""
|
||||||
|
if not avg_temps:
|
||||||
|
return []
|
||||||
|
|
||||||
|
segments: list[DataSegment] = []
|
||||||
|
n = len(avg_temps)
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < n and avg_temps[i] < threshold:
|
||||||
|
i += 1
|
||||||
|
if i > 0:
|
||||||
|
avg = sum(avg_temps[:i]) / i
|
||||||
|
segments.append(DataSegment("Ramp", 0, i - 1, round(avg, 2)))
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
start = i
|
||||||
|
above = avg_temps[i] >= threshold
|
||||||
|
while i < n and (avg_temps[i] >= threshold) == above:
|
||||||
|
i += 1
|
||||||
|
label = "Soak" if above else "Cool"
|
||||||
|
count = i - start
|
||||||
|
avg = sum(avg_temps[start:i]) / count
|
||||||
|
segments.append(DataSegment(label, start, i - 1, round(avg, 2)))
|
||||||
|
|
||||||
|
return segments
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
def slot_error_boundary(func):
|
||||||
|
"""Wrap a @Slot method so exceptions are logged + signalled to QML.
|
||||||
|
|
||||||
|
Usage (stack under @Slot):
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def myMethod(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
On exception:
|
||||||
|
- Logs full traceback via logger.exception()
|
||||||
|
- Emits logMessage signal with human-readable error (if available)
|
||||||
|
- Returns None so QML never sees a crash
|
||||||
|
"""
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logging.getLogger(self.__class__.__module__)
|
||||||
|
logger.exception("Slot '%s' raised: %s", func.__name__, e)
|
||||||
|
if hasattr(self, "logMessage"):
|
||||||
|
self.logMessage.emit(f"Error in {func.__name__}: {str(e)}")
|
||||||
|
return None
|
||||||
|
return wrapper
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# ===== 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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Pure coordinate-mapping math shared by the QQuickPaintedItem chart items
|
||||||
|
(GraphQuickItem, TrendChartItem). No QPainter or Qt-widget dependency, so
|
||||||
|
it's testable without a GUI — unlike the paint() methods that call it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def value_to_pixel(
|
||||||
|
value: float,
|
||||||
|
value_min: float,
|
||||||
|
value_max: float,
|
||||||
|
pixel_start: float,
|
||||||
|
pixel_span: float,
|
||||||
|
) -> float:
|
||||||
|
"""Map a value onto a pixel range, inverted so larger values sit at
|
||||||
|
smaller pixel coordinates (screen y grows downward).
|
||||||
|
|
||||||
|
Also used for evenly-spaced tick indices: pass the tick index as
|
||||||
|
`value` and `(tick_count - 1)` as `value_max` with `value_min=0`.
|
||||||
|
"""
|
||||||
|
value_range = value_max - value_min
|
||||||
|
if value_range < 1e-9:
|
||||||
|
return pixel_start + pixel_span / 2
|
||||||
|
fraction = 1.0 - (value - value_min) / value_range
|
||||||
|
return pixel_start + fraction * pixel_span
|
||||||
|
|
||||||
|
|
||||||
|
def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: float) -> float:
|
||||||
|
"""Map a 0-based sample index onto a pixel range, left to right."""
|
||||||
|
if count <= 1:
|
||||||
|
return pixel_start + pixel_span / 2
|
||||||
|
fraction = index / (count - 1)
|
||||||
|
return pixel_start + fraction * pixel_span
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_to_pixel(
|
||||||
|
elapsed: float,
|
||||||
|
window_start: float,
|
||||||
|
window_end: float,
|
||||||
|
pixel_start: float,
|
||||||
|
pixel_span: float,
|
||||||
|
) -> float:
|
||||||
|
"""Map an elapsed-seconds value onto a pixel range, left to right.
|
||||||
|
|
||||||
|
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
|
||||||
|
at larger pixel x-coordinates, matching how time flows left-to-right.
|
||||||
|
"""
|
||||||
|
window_span = window_end - window_start
|
||||||
|
if window_span < 1e-9:
|
||||||
|
return pixel_start + pixel_span
|
||||||
|
fraction = (elapsed - window_start) / window_span
|
||||||
|
return pixel_start + fraction * pixel_span
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
"""QQuickPaintedItem line chart for embedding in QML.
|
||||||
|
|
||||||
|
Draws multiple sensor-data line series with axis labels, grid lines,
|
||||||
|
auto-scaled Y range, legend, and a dark-theme default palette that
|
||||||
|
complements Theme.qml.
|
||||||
|
|
||||||
|
Usage from QML::
|
||||||
|
|
||||||
|
import ISC.Wafer
|
||||||
|
|
||||||
|
GraphQuickItem {
|
||||||
|
id: graph
|
||||||
|
anchors.fill: parent
|
||||||
|
seriesData: [...]
|
||||||
|
sensorNames: ["Sensor1", "Sensor2"]
|
||||||
|
title: "Sensor Temperature Over Time"
|
||||||
|
yLabel: "Temperature (°C)"
|
||||||
|
xLabel: "Measurement"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
|
||||||
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||||
|
from PySide6.QtQml import QmlElement
|
||||||
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
||||||
|
|
||||||
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def viewport_stats(
|
||||||
|
series: list[list[float]],
|
||||||
|
names: list[str],
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
) -> tuple[float, float, float, str, str]:
|
||||||
|
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
|
||||||
|
|
||||||
|
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
|
||||||
|
every visible point from every series, plus which sensor achieved each
|
||||||
|
extreme. Non-numeric values are skipped. No points in range -> zeros and
|
||||||
|
empty sensor names.
|
||||||
|
"""
|
||||||
|
vmin = math.inf
|
||||||
|
vmax = -math.inf
|
||||||
|
total = 0.0
|
||||||
|
count = 0
|
||||||
|
min_sensor = ""
|
||||||
|
max_sensor = ""
|
||||||
|
for i, s in enumerate(series):
|
||||||
|
name = names[i] if i < len(names) else ""
|
||||||
|
lo = max(0, start)
|
||||||
|
hi = min(len(s) - 1, end)
|
||||||
|
for j in range(lo, hi + 1):
|
||||||
|
try:
|
||||||
|
v = float(s[j])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if v < vmin:
|
||||||
|
vmin = v
|
||||||
|
min_sensor = name
|
||||||
|
if v > vmax:
|
||||||
|
vmax = v
|
||||||
|
max_sensor = name
|
||||||
|
total += v
|
||||||
|
count += 1
|
||||||
|
if count == 0:
|
||||||
|
return (0.0, 0.0, 0.0, "", "")
|
||||||
|
return (vmin, vmax, total / count, min_sensor, max_sensor)
|
||||||
|
|
||||||
|
|
||||||
|
@QmlElement
|
||||||
|
class GraphQuickItem(QQuickPaintedItem):
|
||||||
|
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||||
|
|
||||||
|
seriesDataChanged = Signal()
|
||||||
|
sensorNamesChanged = Signal()
|
||||||
|
titleChanged = Signal()
|
||||||
|
xLabelChanged = Signal()
|
||||||
|
yLabelChanged = Signal()
|
||||||
|
colorsChanged = Signal()
|
||||||
|
viewportChanged = Signal()
|
||||||
|
viewStatsChanged = Signal()
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._series_data: list[list[float]] = [] # [[sensor1_vals...], [sensor2_vals...]]
|
||||||
|
self._sensor_names: list[str] = [] # parallel to series_data
|
||||||
|
self._title: str = ""
|
||||||
|
self._x_label: str = "Measurement"
|
||||||
|
self._y_label: str = "Temperature (°C)"
|
||||||
|
self._show_legend: bool = True
|
||||||
|
|
||||||
|
# Dark-theme colour defaults (match Theme.qml where possible)
|
||||||
|
self._bg_color = QColor("#1A1A1A") # tone200
|
||||||
|
self._grid_color = QColor("#2A2A2A") # toneBorder
|
||||||
|
self._axis_color = QColor("#A8A8A8") # toneMute
|
||||||
|
self._text_color = QColor("#F2F2F2") # toneText
|
||||||
|
self._series_colors: list[QColor] = [
|
||||||
|
QColor("#FF5757"), # Red
|
||||||
|
QColor("#42A5F5"), # Blue
|
||||||
|
QColor("#66BB6A"), # Green
|
||||||
|
QColor("#FFA726"), # Orange
|
||||||
|
QColor("#AB47BC"), # Purple
|
||||||
|
QColor("#00BCD4"), # Cyan
|
||||||
|
QColor("#FF7043"), # Deep Orange
|
||||||
|
QColor("#795548"), # Brown
|
||||||
|
QColor("#5C6BC0"), # Indigo
|
||||||
|
QColor("#307D75"), # Teal
|
||||||
|
]
|
||||||
|
|
||||||
|
self._min_y: float = 0.0
|
||||||
|
self._max_y: float = 150.0
|
||||||
|
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
||||||
|
|
||||||
|
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
|
||||||
|
# so the Graph tab (which never sets these) is unaffected. See
|
||||||
|
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||||
|
self._view_start: int = 0
|
||||||
|
self._view_end: int = -1
|
||||||
|
self._show_min_max_markers: bool = False
|
||||||
|
self._view_min: float = 0.0
|
||||||
|
self._view_max: float = 0.0
|
||||||
|
self._view_avg: float = 0.0
|
||||||
|
self._view_min_sensor: str = ""
|
||||||
|
self._view_max_sensor: str = ""
|
||||||
|
|
||||||
|
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=seriesDataChanged)
|
||||||
|
def seriesData(self) -> list:
|
||||||
|
return self._series_data
|
||||||
|
|
||||||
|
@seriesData.setter # type: ignore[no-redef]
|
||||||
|
def seriesData(self, val: list) -> None:
|
||||||
|
self._series_data = [list(s) for s in (val or [])]
|
||||||
|
# New data resets the viewport to the full range -- matches
|
||||||
|
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
|
||||||
|
# before RecalculateAxesScale(). Without this, a stale viewport left
|
||||||
|
# over from a previous (longer) series can point entirely past the
|
||||||
|
# new data -- every per-series slice becomes empty and the chart
|
||||||
|
# silently renders nothing instead of the new data.
|
||||||
|
self._view_start = 0
|
||||||
|
self._view_end = -1
|
||||||
|
self._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
|
self.seriesDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QStringList", notify=sensorNamesChanged)
|
||||||
|
def sensorNames(self) -> list:
|
||||||
|
return self._sensor_names
|
||||||
|
|
||||||
|
@sensorNames.setter # type: ignore[no-redef]
|
||||||
|
def sensorNames(self, val: list) -> None:
|
||||||
|
self._sensor_names = list(val or [])
|
||||||
|
self._recompute_view_stats()
|
||||||
|
self.sensorNamesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
|
||||||
|
|
||||||
|
@Property(int, notify=viewportChanged)
|
||||||
|
def viewStartIndex(self) -> int:
|
||||||
|
return self._view_start
|
||||||
|
|
||||||
|
@viewStartIndex.setter # type: ignore[no-redef]
|
||||||
|
def viewStartIndex(self, val: int) -> None:
|
||||||
|
v = max(0, int(val))
|
||||||
|
if v == self._view_start:
|
||||||
|
return
|
||||||
|
self._view_start = v
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
|
@Property(int, notify=viewportChanged)
|
||||||
|
def viewEndIndex(self) -> int:
|
||||||
|
return self._view_end
|
||||||
|
|
||||||
|
@viewEndIndex.setter # type: ignore[no-redef]
|
||||||
|
def viewEndIndex(self, val: int) -> None:
|
||||||
|
v = int(val)
|
||||||
|
if v == self._view_end:
|
||||||
|
return
|
||||||
|
self._view_end = v
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
|
@Property(bool, notify=viewportChanged)
|
||||||
|
def showMinMaxMarkers(self) -> bool:
|
||||||
|
return self._show_min_max_markers
|
||||||
|
|
||||||
|
@showMinMaxMarkers.setter # type: ignore[no-redef]
|
||||||
|
def showMinMaxMarkers(self, val: bool) -> None:
|
||||||
|
v = bool(val)
|
||||||
|
if v == self._show_min_max_markers:
|
||||||
|
return
|
||||||
|
self._show_min_max_markers = v
|
||||||
|
self.viewportChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewMin(self) -> float:
|
||||||
|
return self._view_min
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewMax(self) -> float:
|
||||||
|
return self._view_max
|
||||||
|
|
||||||
|
@Property(float, notify=viewStatsChanged)
|
||||||
|
def viewAvg(self) -> float:
|
||||||
|
return self._view_avg
|
||||||
|
|
||||||
|
@Property(str, notify=viewStatsChanged)
|
||||||
|
def viewMinSensor(self) -> str:
|
||||||
|
return self._view_min_sensor
|
||||||
|
|
||||||
|
@Property(str, notify=viewStatsChanged)
|
||||||
|
def viewMaxSensor(self) -> str:
|
||||||
|
return self._view_max_sensor
|
||||||
|
|
||||||
|
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
|
||||||
|
# with fractions derived from wheel/drag pixel deltas) ───────────────
|
||||||
|
|
||||||
|
@Slot(float, float)
|
||||||
|
def zoomAtFraction(self, frac: float, factor: float) -> None:
|
||||||
|
"""Zoom the viewport around a fractional X position within it.
|
||||||
|
|
||||||
|
`frac` in [0, 1] is where within the current viewport to zoom around;
|
||||||
|
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
|
||||||
|
(zoom out). Clamped to data bounds and a minimum 1-index window.
|
||||||
|
"""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if max_len < 2:
|
||||||
|
return
|
||||||
|
start = float(self._view_start)
|
||||||
|
end = float(self._resolved_view_end())
|
||||||
|
width = max(1.0, end - start)
|
||||||
|
f = max(0.0, min(1.0, frac))
|
||||||
|
center = start + f * width
|
||||||
|
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
|
||||||
|
new_start = center - f * new_width
|
||||||
|
new_end = new_start + new_width
|
||||||
|
self._set_viewport_clamped(new_start, new_end, max_len)
|
||||||
|
|
||||||
|
@Slot(float)
|
||||||
|
def panByFraction(self, delta_frac: float) -> None:
|
||||||
|
"""Shift the viewport by `delta_frac * window_width` indices."""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if max_len < 2:
|
||||||
|
return
|
||||||
|
start = float(self._view_start)
|
||||||
|
end = float(self._resolved_view_end())
|
||||||
|
width = max(1.0, end - start)
|
||||||
|
shift = delta_frac * width
|
||||||
|
self._set_viewport_clamped(start + shift, end + shift, max_len)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def resetZoom(self) -> None:
|
||||||
|
"""Restore the full-range viewport sentinel (0, -1)."""
|
||||||
|
self._set_viewport(0, -1)
|
||||||
|
|
||||||
|
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
|
||||||
|
if new_start < 0.0:
|
||||||
|
new_end -= new_start
|
||||||
|
new_start = 0.0
|
||||||
|
if new_end > max_len - 1:
|
||||||
|
new_start -= new_end - (max_len - 1)
|
||||||
|
new_end = float(max_len - 1)
|
||||||
|
new_start = max(0.0, new_start)
|
||||||
|
self._set_viewport(int(round(new_start)), int(round(new_end)))
|
||||||
|
|
||||||
|
def _set_viewport(self, start: int, end: int) -> None:
|
||||||
|
"""Set the viewport bounds directly (not through the QML-facing
|
||||||
|
property setters, so this stays a plain attribute write for mypy)."""
|
||||||
|
new_start = max(0, start)
|
||||||
|
if new_start == self._view_start and end == self._view_end:
|
||||||
|
return
|
||||||
|
self._view_start = new_start
|
||||||
|
self._view_end = end
|
||||||
|
self._on_viewport_changed()
|
||||||
|
|
||||||
|
@Property(str, notify=titleChanged)
|
||||||
|
def title(self) -> str:
|
||||||
|
return self._title
|
||||||
|
|
||||||
|
@title.setter # type: ignore[no-redef]
|
||||||
|
def title(self, val: str) -> None:
|
||||||
|
self._title = str(val or "")
|
||||||
|
self.titleChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=xLabelChanged)
|
||||||
|
def xLabel(self) -> str:
|
||||||
|
return self._x_label
|
||||||
|
|
||||||
|
@xLabel.setter # type: ignore[no-redef]
|
||||||
|
def xLabel(self, val: str) -> None:
|
||||||
|
self._x_label = str(val or "")
|
||||||
|
self.xLabelChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(str, notify=yLabelChanged)
|
||||||
|
def yLabel(self) -> str:
|
||||||
|
return self._y_label
|
||||||
|
|
||||||
|
@yLabel.setter # type: ignore[no-redef]
|
||||||
|
def yLabel(self, val: str) -> None:
|
||||||
|
self._y_label = str(val or "")
|
||||||
|
self.yLabelChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=colorsChanged)
|
||||||
|
def showLegend(self) -> bool:
|
||||||
|
return self._show_legend
|
||||||
|
|
||||||
|
@showLegend.setter # type: ignore[no-redef]
|
||||||
|
def showLegend(self, val: bool) -> None:
|
||||||
|
self._show_legend = bool(val)
|
||||||
|
self.colorsChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── Colour properties (QML can bind Theme tokens here) ────────────────────
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def backgroundColor(self) -> QColor:
|
||||||
|
return self._bg_color
|
||||||
|
|
||||||
|
@backgroundColor.setter # type: ignore[no-redef]
|
||||||
|
def backgroundColor(self, c: QColor) -> None:
|
||||||
|
self._bg_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def gridColor(self) -> QColor:
|
||||||
|
return self._grid_color
|
||||||
|
|
||||||
|
@gridColor.setter # type: ignore[no-redef]
|
||||||
|
def gridColor(self, c: QColor) -> None:
|
||||||
|
self._grid_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def axisColor(self) -> QColor:
|
||||||
|
return self._axis_color
|
||||||
|
|
||||||
|
@axisColor.setter # type: ignore[no-redef]
|
||||||
|
def axisColor(self, c: QColor) -> None:
|
||||||
|
self._axis_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(QColor, notify=colorsChanged)
|
||||||
|
def textColor(self) -> QColor:
|
||||||
|
return self._text_color
|
||||||
|
|
||||||
|
@textColor.setter # type: ignore[no-redef]
|
||||||
|
def textColor(self, c: QColor) -> None:
|
||||||
|
self._text_color = c
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property("QVariantList", notify=colorsChanged)
|
||||||
|
def seriesColors(self) -> list:
|
||||||
|
"""Return hex strings of the current series color palette."""
|
||||||
|
return [c.name() for c in self._series_colors]
|
||||||
|
|
||||||
|
@seriesColors.setter # type: ignore[no-redef]
|
||||||
|
def seriesColors(self, hex_list: list) -> None:
|
||||||
|
colors = []
|
||||||
|
for h in (hex_list or []):
|
||||||
|
try:
|
||||||
|
colors.append(QColor(str(h)))
|
||||||
|
except Exception:
|
||||||
|
colors.append(QColor("#FFFFFF"))
|
||||||
|
if colors:
|
||||||
|
self._series_colors = colors
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ── internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolved_view_end(self) -> int:
|
||||||
|
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
|
||||||
|
max_len = max((len(s) for s in self._series_data), default=0)
|
||||||
|
if self._view_end < 0 or self._view_end > max_len - 1:
|
||||||
|
return max_len - 1
|
||||||
|
return self._view_end
|
||||||
|
|
||||||
|
def _on_viewport_changed(self) -> None:
|
||||||
|
self._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
|
self.viewportChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _recompute_view_stats(self) -> None:
|
||||||
|
end = self._resolved_view_end()
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
|
||||||
|
self._series_data, self._sensor_names, self._view_start, end
|
||||||
|
)
|
||||||
|
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
|
||||||
|
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
|
||||||
|
self.viewStatsChanged.emit()
|
||||||
|
|
||||||
|
def _auto_range(self) -> None:
|
||||||
|
"""Set Y range to cover the current viewport's data with 10% padding."""
|
||||||
|
start = max(0, self._view_start)
|
||||||
|
end = self._resolved_view_end()
|
||||||
|
all_vals: list[float] = []
|
||||||
|
for series in self._series_data:
|
||||||
|
hi = min(len(series) - 1, end)
|
||||||
|
for i in range(start, hi + 1):
|
||||||
|
try:
|
||||||
|
all_vals.append(float(series[i]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if not all_vals:
|
||||||
|
return
|
||||||
|
mn = min(all_vals)
|
||||||
|
mx = max(all_vals)
|
||||||
|
if mx <= mn:
|
||||||
|
mn -= 5.0
|
||||||
|
mx += 5.0
|
||||||
|
pad = (mx - mn) * 0.1
|
||||||
|
self._min_y = mn - pad
|
||||||
|
self._max_y = mx + pad
|
||||||
|
|
||||||
|
def _plot_rect(self) -> QRectF:
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
pl = self._padding.get("left", 60)
|
||||||
|
pr = self._padding.get("right", 20)
|
||||||
|
pt = self._padding.get("top", 30)
|
||||||
|
pb = self._padding.get("bottom", 50)
|
||||||
|
return QRectF(pl, pt, max(10, w - pl - pr), max(10, h - pt - pb))
|
||||||
|
|
||||||
|
# ── paint ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def paint(self, painter: QPainter) -> None:
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
|
w = self.width()
|
||||||
|
h = self.height()
|
||||||
|
if w < 50 or h < 50:
|
||||||
|
return
|
||||||
|
|
||||||
|
pr = self._plot_rect()
|
||||||
|
plot_left = pr.left()
|
||||||
|
plot_top = pr.top()
|
||||||
|
plot_w = pr.width()
|
||||||
|
plot_h = pr.height()
|
||||||
|
|
||||||
|
# --- Background fill ---
|
||||||
|
painter.fillRect(0, 0, int(w), int(h), self._bg_color)
|
||||||
|
|
||||||
|
# --- Title ---
|
||||||
|
if self._title:
|
||||||
|
title_font = QFont()
|
||||||
|
title_font.setPixelSize(14)
|
||||||
|
title_font.setBold(True)
|
||||||
|
painter.setFont(title_font)
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(0, 4, w, 24),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter,
|
||||||
|
self._title,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._series_data:
|
||||||
|
self._paint_empty(painter, pr)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Determine Y tick count and values ---
|
||||||
|
n_y_ticks = max(2, min(8, int(plot_h // 40)))
|
||||||
|
y_range = self._max_y - self._min_y
|
||||||
|
if y_range <= 0:
|
||||||
|
y_range = 10.0
|
||||||
|
y_step = y_range / (n_y_ticks - 1) if n_y_ticks > 1 else y_range
|
||||||
|
|
||||||
|
# --- Grid & Y-axis labels ---
|
||||||
|
grid_pen = QPen(self._grid_color, 1)
|
||||||
|
axis_pen = QPen(self._axis_color, 1)
|
||||||
|
label_font = QFont()
|
||||||
|
label_font.setPixelSize(10)
|
||||||
|
|
||||||
|
painter.setFont(label_font)
|
||||||
|
fm = QFontMetrics(label_font)
|
||||||
|
|
||||||
|
for i in range(n_y_ticks):
|
||||||
|
y_val = self._min_y + i * y_step
|
||||||
|
y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
|
||||||
|
|
||||||
|
# Grid line
|
||||||
|
painter.setPen(grid_pen)
|
||||||
|
painter.drawLine(
|
||||||
|
int(plot_left), int(y_px),
|
||||||
|
int(plot_left + plot_w), int(y_px),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Label
|
||||||
|
label_str = f"{y_val:.1f}"
|
||||||
|
tw = fm.horizontalAdvance(label_str)
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawText(
|
||||||
|
int(plot_left - tw - 6), int(y_px + fm.height() // 2 - 3),
|
||||||
|
label_str,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Y axis label (rotated) ---
|
||||||
|
if self._y_label:
|
||||||
|
painter.save()
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
label_font_b = QFont()
|
||||||
|
label_font_b.setPixelSize(11)
|
||||||
|
painter.setFont(label_font_b)
|
||||||
|
painter.translate(14, plot_top + plot_h / 2)
|
||||||
|
painter.rotate(-90)
|
||||||
|
painter.drawText(0, 0, self._y_label)
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
# --- X axis label ---
|
||||||
|
if self._x_label:
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
label_font_b = QFont()
|
||||||
|
label_font_b.setPixelSize(11)
|
||||||
|
painter.setFont(label_font_b)
|
||||||
|
painter.drawText(
|
||||||
|
QRectF(plot_left, h - 18, plot_w, 16),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter,
|
||||||
|
self._x_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Viewport slice (replay chart zoom/pan; full range by default,
|
||||||
|
# so unset viewport reproduces the pre-viewport rendering) ---
|
||||||
|
view_start = max(0, self._view_start)
|
||||||
|
view_end = self._resolved_view_end()
|
||||||
|
view_len = max(1, view_end - view_start + 1)
|
||||||
|
|
||||||
|
# --- X-axis ticks (labelled in original, non-sliced index space) ---
|
||||||
|
if view_len > 1:
|
||||||
|
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
|
||||||
|
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
|
||||||
|
for i in range(n_x_ticks):
|
||||||
|
rel_idx = int(round(i * x_tick_step))
|
||||||
|
x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
|
||||||
|
painter.setPen(axis_pen)
|
||||||
|
painter.drawText(
|
||||||
|
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||||
|
str(view_start + rel_idx + 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Axes boundary ---
|
||||||
|
border_pen = QPen(self._axis_color, 1)
|
||||||
|
painter.setPen(border_pen)
|
||||||
|
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
||||||
|
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
||||||
|
|
||||||
|
# --- Draw each series (sliced to the current viewport) ---
|
||||||
|
min_pt: tuple[float, float] | None = None
|
||||||
|
max_pt: tuple[float, float] | None = None
|
||||||
|
min_seen = math.inf
|
||||||
|
max_seen = -math.inf
|
||||||
|
for si, series in enumerate(self._series_data):
|
||||||
|
hi = min(len(series) - 1, view_end)
|
||||||
|
sliced = series[view_start:hi + 1] if view_start <= hi else []
|
||||||
|
if not sliced:
|
||||||
|
continue
|
||||||
|
color = self._series_colors[si % len(self._series_colors)]
|
||||||
|
pen = QPen(color, 2)
|
||||||
|
painter.setPen(pen)
|
||||||
|
|
||||||
|
pts: list[tuple[float, float]] = []
|
||||||
|
for i, val in enumerate(sliced):
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
x_px = index_to_pixel(i, view_len, plot_left, plot_w)
|
||||||
|
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
||||||
|
pts.append((x_px, y_px))
|
||||||
|
if self._show_min_max_markers:
|
||||||
|
if v < min_seen:
|
||||||
|
min_seen = v
|
||||||
|
min_pt = (x_px, y_px)
|
||||||
|
if v > max_seen:
|
||||||
|
max_seen = v
|
||||||
|
max_pt = (x_px, y_px)
|
||||||
|
|
||||||
|
if len(pts) < 2:
|
||||||
|
if len(pts) == 1:
|
||||||
|
painter.drawPoint(int(pts[0][0]), int(pts[0][1]))
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i in range(len(pts) - 1):
|
||||||
|
painter.drawLine(
|
||||||
|
int(pts[i][0]), int(pts[i][1]),
|
||||||
|
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
|
||||||
|
if self._show_min_max_markers:
|
||||||
|
marker_font = QFont()
|
||||||
|
marker_font.setPixelSize(14)
|
||||||
|
marker_font.setBold(True)
|
||||||
|
painter.setFont(marker_font)
|
||||||
|
if max_pt is not None:
|
||||||
|
painter.setPen(QPen(QColor("#FF5757")))
|
||||||
|
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "▼")
|
||||||
|
if min_pt is not None:
|
||||||
|
painter.setPen(QPen(QColor("#42A5F5")))
|
||||||
|
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "▲")
|
||||||
|
|
||||||
|
# --- Legend ---
|
||||||
|
if self._show_legend and self._sensor_names:
|
||||||
|
legend_font = QFont()
|
||||||
|
legend_font.setPixelSize(10)
|
||||||
|
painter.setFont(legend_font)
|
||||||
|
lfm = QFontMetrics(legend_font)
|
||||||
|
|
||||||
|
# Compute legend dimensions
|
||||||
|
lh = 16
|
||||||
|
max_name_w = max(lfm.horizontalAdvance(n) for n in self._sensor_names)
|
||||||
|
lw = max_name_w + 24
|
||||||
|
legend_count = min(len(self._sensor_names), len(self._series_data))
|
||||||
|
lh_total = legend_count * lh + 4
|
||||||
|
|
||||||
|
legend_x = int(plot_left + plot_w - lw - 8)
|
||||||
|
legend_y = int(plot_top + 8)
|
||||||
|
|
||||||
|
# Background
|
||||||
|
painter.fillRect(legend_x, legend_y, lw, lh_total, QColor(0, 0, 0, 140))
|
||||||
|
|
||||||
|
for i in range(legend_count):
|
||||||
|
y_pos = legend_y + 4 + i * lh
|
||||||
|
color = self._series_colors[i % len(self._series_colors)]
|
||||||
|
# Color swatch
|
||||||
|
painter.fillRect(legend_x + 4, y_pos + 2, 12, 10, color)
|
||||||
|
# Label
|
||||||
|
painter.setPen(QPen(self._text_color))
|
||||||
|
painter.drawText(legend_x + 20, y_pos + 11, self._sensor_names[i])
|
||||||
|
|
||||||
|
def _paint_empty(self, painter: QPainter, pr: QRectF) -> None:
|
||||||
|
"""Draw placeholder when no data is available."""
|
||||||
|
painter.setPen(QPen(self._axis_color))
|
||||||
|
empty_font = QFont()
|
||||||
|
empty_font.setPixelSize(14)
|
||||||
|
painter.setFont(empty_font)
|
||||||
|
painter.drawText(
|
||||||
|
pr,
|
||||||
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
|
"No data — read a wafer or open a CSV file",
|
||||||
|
)
|
||||||
@@ -9,16 +9,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QObject, Signal, Slot
|
import pyqtgraph as pg
|
||||||
|
from pyqtgraph import PlotWidget
|
||||||
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
from PySide6.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Import pyqtgraph after Qt is initialized
|
|
||||||
import pyqtgraph as pg
|
|
||||||
from pyqtgraph import PlotWidget
|
|
||||||
|
|
||||||
|
|
||||||
class GraphView(QObject):
|
class GraphView(QObject):
|
||||||
"""QML-exposed controller for a pyqtgraph line chart.
|
"""QML-exposed controller for a pyqtgraph line chart.
|
||||||
|
|
||||||
@@ -26,6 +23,9 @@ class GraphView(QObject):
|
|||||||
each sensor as a separate line series.
|
each sensor as a separate line series.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
|
||||||
|
_trend_line: Any
|
||||||
|
|
||||||
# ---- signals ----
|
# ---- signals ----
|
||||||
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
||||||
|
|
||||||
@@ -167,3 +167,71 @@ class GraphView(QObject):
|
|||||||
self._plot_window = None
|
self._plot_window = None
|
||||||
self._plot_widget = None
|
self._plot_widget = None
|
||||||
self._series = []
|
self._series = []
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def updateTrend(self, avgs_json: str) -> None:
|
||||||
|
"""Plot the running avg temp series."""
|
||||||
|
import json
|
||||||
|
if not self._plot_widget:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
avgs = json.loads(avgs_json)
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("Failed to parse trend data: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
if hasattr(self,'_trend_line') and self._trend_line is not None:
|
||||||
|
self._plot_widget.removeItem(self._trend_line)
|
||||||
|
|
||||||
|
if not avgs:
|
||||||
|
return
|
||||||
|
|
||||||
|
x = list(range(len(avgs)))
|
||||||
|
pen = pg.mkPen("#5B9DF5", width=2)
|
||||||
|
self._trend_line = self._plot_widget.plot(x, avgs, name="Average", pen=pen)
|
||||||
|
|
||||||
|
# Y-axis range with buffer
|
||||||
|
y_min, y_max = min(avgs), max(avgs)
|
||||||
|
buf = max((y_max - y_min) * 0.1, 1.0) if y_max > y_min else 5.0
|
||||||
|
self._plot_widget.setYRange(y_min - buf, y_max + buf)
|
||||||
|
|
||||||
|
@Slot(str,str,str)
|
||||||
|
def updateComparison(self, series_a_json: str, series_b_json: str,path_json: str) -> None:
|
||||||
|
"""Overlay two runs with DTW warping path connections.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
series_a_json: JSON list of floats -- Run Aaverage temps.
|
||||||
|
series_b_json: JSON list of floats -- Run B average temps.
|
||||||
|
path_json: JSON list of [index_a, index_b] paris.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
if not self._plot_widget:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._plot_widget.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
a = json.loads(series_a_json)
|
||||||
|
b = json.loads(series_b_json)
|
||||||
|
path = json.loads(path_json)
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
log.error("Failed to parse comparison data: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
pen_a = pg.mkPen("#5B9DF5", width=2) #blue
|
||||||
|
pen_b = pg.mkPen("#22C55E", width=2) #green
|
||||||
|
|
||||||
|
self._plot_widget.plot(list(range(len(a))), a, name="Run A", pen=pen_a)
|
||||||
|
self._plot_widget.plot(list(range(len(b))), b, name="Run B", pen=pen_b)
|
||||||
|
|
||||||
|
# Draw every 20th warping link as vertical dotted lines
|
||||||
|
step = max(1, len(path)//20)
|
||||||
|
for i in range (0,len(path),step):
|
||||||
|
index_a, index_b = path[i]
|
||||||
|
if index_a < len(a) and index_b < len(b):
|
||||||
|
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||||
|
style=Qt.PenStyle.DashLine))
|
||||||
|
self._plot_widget.addItem(line)
|
||||||
|
|
||||||
|
self._plot_widget.setTitle("DTW Comparison")
|
||||||
|
self._plot_widget.setLabel("left", "Temperature", units="°C")
|
||||||
|
|||||||