Compare commits
29 Commits
915720085b
..
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 |
@@ -40,3 +40,21 @@ dist/
|
|||||||
*.spec
|
*.spec
|
||||||
*.icns
|
*.icns
|
||||||
*.ico
|
*.ico
|
||||||
|
|
||||||
|
# Runtime scratch data
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Superpowers brainstorming visual companion
|
||||||
|
.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/
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ The `ISenseCloud` application provides a real-time monitor and analysis dashboar
|
|||||||
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
|
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
|
||||||
|
|
||||||
### App Showcase
|
### App Showcase
|
||||||
|
|
||||||

|

|
||||||
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
|
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Take Screenshots for Visual Placeholders:**
|
> **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.
|
> - **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.
|
> - **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.
|
||||||
|
|
||||||
@@ -42,12 +44,15 @@ During the refactoring from the flat-layout prototype (`SettingTab` branch) to t
|
|||||||
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
|
||||||
@@ -59,9 +64,11 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
### Launch PySide6 QML App
|
### Launch PySide6 QML App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -71,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 |
|
||||||
@@ -83,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)
|
||||||
@@ -174,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
|
||||||
@@ -187,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`).
|
||||||
@@ -206,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]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
@@ -8,10 +9,12 @@ Popup {
|
|||||||
modal: true
|
modal: true
|
||||||
dim: true
|
dim: true
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
width: 420
|
width: 600
|
||||||
height: 340
|
height: 500
|
||||||
anchors.centerIn: Overlay.overlay
|
anchors.centerIn: Overlay.overlay
|
||||||
|
|
||||||
|
onOpened: licenseModel.refresh()
|
||||||
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: Theme.radiusMd
|
radius: Theme.radiusMd
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
@@ -19,10 +22,17 @@ Popup {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: licenseFileDialog
|
||||||
|
title: "Load License"
|
||||||
|
nameFilters: ["License files (*.bin)"]
|
||||||
|
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 24
|
anchors.margins: 28
|
||||||
spacing: 16
|
spacing: 20
|
||||||
|
|
||||||
// ── Title ──
|
// ── Title ──
|
||||||
Label {
|
Label {
|
||||||
@@ -34,27 +44,20 @@ Popup {
|
|||||||
|
|
||||||
// ── App info ──
|
// ── App info ──
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
spacing: 4
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "ISenseCloud (ISC)"
|
text: "ISenseCloud"
|
||||||
font.pixelSize: Theme.fontLg
|
font.pixelSize: Theme.fontXl
|
||||||
|
font.bold: true
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "Version 0.1.0"
|
text: "Version " + appVersion
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Temperature-sensing wafer monitoring"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
color: Theme.bodyColor
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Separator ──
|
// ── Separator ──
|
||||||
@@ -67,7 +70,8 @@ Popup {
|
|||||||
// ── License grid ──
|
// ── License grid ──
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 100
|
Layout.fillHeight: true
|
||||||
|
Layout.minimumHeight: 150
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
color: Theme.panelBackground
|
color: Theme.panelBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
@@ -75,23 +79,38 @@ Popup {
|
|||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 10
|
anchors.margins: 14
|
||||||
spacing: 2
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
RowLayout {
|
||||||
text: "LICENSE"
|
Layout.fillWidth: true
|
||||||
color: Theme.panelTitleText
|
|
||||||
font.pixelSize: Theme.fontXs
|
Label {
|
||||||
font.letterSpacing: 0.5
|
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
|
// Grid header
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 4
|
spacing: 8
|
||||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
|
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
|
||||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||||
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 }
|
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 {
|
Rectangle {
|
||||||
@@ -100,10 +119,27 @@ Popup {
|
|||||||
color: Theme.softBorder
|
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 {
|
Label {
|
||||||
|
visible: licenseModel.licenses.length === 0
|
||||||
text: "No license loaded"
|
text: "No license loaded"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
verticalAlignment: Text.AlignVCenter
|
verticalAlignment: Text.AlignVCenter
|
||||||
@@ -112,25 +148,55 @@ Popup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Close ──
|
// ── Buttons ──
|
||||||
Button {
|
RowLayout {
|
||||||
text: "Close"
|
Layout.fillWidth: true
|
||||||
Layout.alignment: Qt.AlignRight
|
spacing: 12
|
||||||
Layout.preferredWidth: 100
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
onClicked: root.close()
|
|
||||||
|
|
||||||
background: Rectangle {
|
Button {
|
||||||
radius: Theme.radiusSm
|
text: "Load License"
|
||||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
Layout.preferredWidth: 140
|
||||||
border.color: Theme.fieldBorder
|
Layout.preferredHeight: 36
|
||||||
border.width: 1
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
contentItem: Label {
|
|
||||||
text: parent.text
|
Item { Layout.fillWidth: true }
|
||||||
color: Theme.buttonNeutralText
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
Button {
|
||||||
verticalAlignment: Text.AlignVCenter
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,20 +23,38 @@ Rectangle {
|
|||||||
if (selectedTabIndex === 2) {
|
if (selectedTabIndex === 2) {
|
||||||
file_browser.refreshFiles();
|
file_browser.refreshFiles();
|
||||||
streamController.unloadFile()
|
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){
|
||||||
root.waferDetected = (result != null && result != undefined)
|
// detectResult payload is Signal(object): a dict may cross into QML
|
||||||
|
// 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) {
|
||||||
streamController.loadFile(result.csv_path)
|
streamController.loadFile(result.csv_path)
|
||||||
|
// TODO P3.1: only auto-jump to Map when unlocked:
|
||||||
|
// `&& licenseModel.licenses.length > 0` — otherwise a
|
||||||
|
// successful read walks straight into the locked tab.
|
||||||
|
// See plan §3.1.
|
||||||
if (root.selectedTabIndex === 0) {
|
if (root.selectedTabIndex === 0) {
|
||||||
root.selectedTabIndex = 2
|
root.selectedTabIndex = 2
|
||||||
}
|
}
|
||||||
@@ -63,6 +81,7 @@ Rectangle {
|
|||||||
|
|
||||||
function _doDetect() {
|
function _doDetect() {
|
||||||
root.memoryRead = false
|
root.memoryRead = false
|
||||||
|
root.waferDetected = false
|
||||||
streamController.setMode("review")
|
streamController.setMode("review")
|
||||||
streamController.stopStream()
|
streamController.stopStream()
|
||||||
deviceController.detectWafer()
|
deviceController.detectWafer()
|
||||||
@@ -72,7 +91,9 @@ Rectangle {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save Data"
|
title: "Choose a folder to save Data"
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
var dir = root.cleanFolderUrl(selectedFolder)
|
||||||
|
deviceController.setSaveDataDir(dir)
|
||||||
|
file_browser.setCurrentDirectory(dir)
|
||||||
root._doDetect()
|
root._doDetect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,8 +202,6 @@ Rectangle {
|
|||||||
Layout.preferredWidth: Theme.sideRailWidth
|
Layout.preferredWidth: Theme.sideRailWidth
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.sideRailBackground
|
color: Theme.sideRailBackground
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -201,9 +220,30 @@ Rectangle {
|
|||||||
|
|
||||||
ListModel {
|
ListModel {
|
||||||
id: pillModel
|
id: pillModel
|
||||||
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 }
|
ListElement {
|
||||||
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 }
|
label: "STATUS"
|
||||||
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 }
|
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 {
|
RowLayout {
|
||||||
@@ -284,307 +324,306 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: pillBtn.hovered
|
||||||
|
text: "<b>" + model.label + "</b>: " + model.desc
|
||||||
|
}
|
||||||
|
|
||||||
onClicked: root.selectedTabIndex = index
|
onClicked: root.selectedTabIndex = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
id: runComparisonBox
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.preferredHeight: runCompCol.implicitHeight + 28
|
||||||
Layout.minimumHeight: 120
|
visible: root.selectedTabIndex === 1
|
||||||
radius: Theme.sidePanelRadius
|
radius: Theme.sidePanelRadius
|
||||||
border.color: Theme.sideBorder
|
border.color: Theme.sideBorder
|
||||||
border.width: 1
|
border.width: 1
|
||||||
color: Theme.sidePanelBackground
|
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 {
|
ColumnLayout {
|
||||||
|
id: runCompCol
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 14
|
anchors.margins: 14
|
||||||
spacing: 8
|
spacing: 10
|
||||||
|
|
||||||
StackLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
spacing: 8
|
||||||
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
|
||||||
|
|
||||||
// ── Status tab: Hardware Actions ────────────────
|
Label {
|
||||||
ColumnLayout {
|
text: "RUN COMPARISON (DTW)"
|
||||||
spacing: 6
|
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 {
|
Label {
|
||||||
text: "HARDWARE ACTIONS"
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
color: Theme.sideMutedText
|
text: !compareRunBtn.dt ? "Select both runs"
|
||||||
font.family: Theme.uiFontFamily
|
: 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.pixelSize: Theme.fontSm
|
||||||
topPadding: 6
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
|
BusyIndicator {
|
||||||
RailActionButton {
|
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||||
label: "DETECT WAFER"
|
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||||
iconSource: "../icons/detect.svg"
|
width: 14; height: 14
|
||||||
Layout.fillWidth: true
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
enabled: !deviceController.operationInProgress
|
|
||||||
onClicked: root._doDetect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RailActionButton {
|
|
||||||
label: "READ MEMORY"
|
|
||||||
iconSource: "../icons/read.svg"
|
|
||||||
Layout.fillWidth: true
|
|
||||||
enabled: root.waferDetected
|
|
||||||
onClicked: deviceController.readMemoryAsync()
|
|
||||||
}
|
|
||||||
|
|
||||||
RailActionButton {
|
|
||||||
label: "ERASE MEMORY"
|
|
||||||
iconSource: "../icons/erase.svg"
|
|
||||||
Layout.fillWidth: true
|
|
||||||
destructive: true
|
|
||||||
enabled: root.waferDetected
|
|
||||||
onClicked: deviceController.eraseMemory(
|
|
||||||
deviceController.selectedPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillHeight: true }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Map tab: Source file browser ──────────────
|
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||||
SourcePanel {
|
StackLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: 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 ──────────────────────────
|
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||||
Rectangle {
|
ConnectionFooter {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: Theme.sideFooterConnection
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 14
|
|
||||||
spacing: 6
|
|
||||||
|
|
||||||
// Row 1: Title + status badge (unchanged)
|
|
||||||
RowLayout {
|
|
||||||
spacing: 8
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "CONNECTION FLOW"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
font.bold: 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: "#111111"
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 2: Status dot + descriptive text (replaces dotted trail)
|
|
||||||
Row {
|
|
||||||
spacing: 8
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: statusDot
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 8
|
|
||||||
height: 8
|
|
||||||
radius: 4
|
|
||||||
color: deviceController.connectionStatus === "Connected"
|
|
||||||
? Theme.statusSuccessColor : Theme.statusErrorColor
|
|
||||||
|
|
||||||
SequentialAnimation on opacity {
|
|
||||||
running: deviceController.connectionStatus === "Connected"
|
|
||||||
loops: Animation.Infinite
|
|
||||||
NumberAnimation { to: 0.4; duration: 1200 }
|
|
||||||
NumberAnimation { to: 1.0; duration: 1200 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
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.fontXs
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 3: Mode chip — neutral gray tag
|
|
||||||
Row {
|
|
||||||
spacing: 6
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
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.font2xs
|
|
||||||
font.bold: true
|
|
||||||
font.letterSpacing: 0.8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
// ── BOX 4: Utility Buttons ─────────────────────────────────
|
||||||
Rectangle {
|
UtilityFooter {
|
||||||
|
id: utilFooter
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: Theme.sideFooterUtility
|
settingsPopup: settingsPopup
|
||||||
radius: Theme.sidePanelRadius
|
aboutDialog: aboutDialog
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 4
|
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: settingsBtn
|
|
||||||
flat: true
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
icon.source: "Tabs/icons/settings.svg"
|
|
||||||
onClicked: 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: "Tabs/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: "Tabs/icons/about.svg"
|
|
||||||
onClicked: 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: "Tabs/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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,8 +633,6 @@ Rectangle {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.workspaceBackground
|
color: Theme.workspaceBackground
|
||||||
border.color: Theme.workspaceBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
StackLayout {
|
StackLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -613,6 +650,12 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
Loader {
|
Loader {
|
||||||
source: "Tabs/WaferMapTab.qml"
|
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
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -123,19 +125,17 @@ Item {
|
|||||||
ColorAnimation { duration: Theme.durationFast }
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
IconImage {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "✓"
|
source: "icons/check.svg"
|
||||||
font.pixelSize: Theme.fontMd
|
width: 12; height: 12
|
||||||
font.bold: true
|
sourceSize.width: 12
|
||||||
|
sourceSize.height: 12
|
||||||
color: Theme.panelBackground
|
color: Theme.panelBackground
|
||||||
opacity: toggle.checked ? 1.0 : 0.0
|
opacity: toggle.checked ? 1.0 : 0.0
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
NumberAnimation { duration: Theme.durationFast }
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
// Small nudge to center the checkmark perfectly
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,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
|
||||||
|
|
||||||
@@ -452,12 +452,9 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Row {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "↓ Scroll for more"
|
spacing: 4
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
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 {
|
||||||
@@ -465,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,6 +1,7 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -29,36 +30,45 @@ ColumnLayout {
|
|||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
Text {
|
Text {
|
||||||
id: valueText
|
id: valueText
|
||||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
font.pixelSize: valSize
|
font.pixelSize: valSize
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.fillHeight: true
|
||||||
}
|
Layout.fillWidth: true
|
||||||
Text {
|
horizontalAlignment: Text.AlignHCenter
|
||||||
id: labelText
|
verticalAlignment: Text.AlignVCenter
|
||||||
color: Theme.sideMutedText
|
fontSizeMode: Text.HorizontalFit
|
||||||
font.pixelSize: Theme.fontXs
|
minimumPixelSize: Theme.fontMd
|
||||||
font.weight: Font.Medium
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
component GridCell : RowLayout {
|
// 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 label: labelText.text
|
||||||
property alias value: valueText.text
|
property alias value: valueText.text
|
||||||
property bool active: root.waferDetected
|
property bool active: root.waferDetected
|
||||||
|
|
||||||
spacing: 8
|
spacing: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
Text {
|
Text {
|
||||||
id: labelText
|
id: labelText
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
@@ -66,17 +76,15 @@ ColumnLayout {
|
|||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
id: valueText
|
id: valueText
|
||||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontXl
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Aliases for external wiring ──
|
// ── Aliases for external wiring ──
|
||||||
@@ -114,21 +122,75 @@ ColumnLayout {
|
|||||||
id: saveDirDialog
|
id: saveDirDialog
|
||||||
title: "Choose a folder to save."
|
title: "Choose a folder to save."
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder));
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
root.parseAndSavePendingRead();
|
root.parseAndSavePendingRead();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// Directory-only picker for the DIRECTORY card button — sets the save dir
|
||||||
// ROW 1: Connection Status (100%)
|
// without kicking off a parse/save of pending read data.
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
FolderDialog {
|
||||||
RowLayout {
|
id: dirOnlyDialog
|
||||||
Layout.fillWidth: true
|
title: "Choose a folder to save Data"
|
||||||
Layout.preferredHeight: 90
|
onAccepted: {
|
||||||
Layout.maximumHeight: 90
|
var dir = root.cleanFolderUrl(selectedFolder);
|
||||||
spacing: 8
|
deviceController.setSaveDataDir(dir);
|
||||||
|
file_browser.setCurrentDirectory(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
||||||
|
// Cancel keeps the CSV, skips the sidecar — matching C# behavior loosely.
|
||||||
|
Dialog {
|
||||||
|
id: editCsvDialog
|
||||||
|
parent: Overlay.overlay
|
||||||
|
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, "")
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.csvPath.split("/").pop()
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
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 {
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
@@ -137,43 +199,24 @@ ColumnLayout {
|
|||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: connStatusCol
|
anchors.fill: parent
|
||||||
anchors.left: parent.left
|
anchors.margins: Theme.panelPadding
|
||||||
anchors.right: parent.right
|
spacing: 6
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
anchors.leftMargin: Theme.panelPadding
|
|
||||||
anchors.rightMargin: Theme.panelPadding
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "CONNECTION STATUS"
|
text: "CONNECTION STATUS"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
font.weight: Font.Medium
|
font.bold: true
|
||||||
font.letterSpacing: 1.2
|
font.letterSpacing: 1.5
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 0
|
spacing: 8
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: 1
|
|
||||||
Text {
|
|
||||||
text: "HOST PC"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: "localhost"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -211,7 +254,7 @@ ColumnLayout {
|
|||||||
id: statusPillText
|
id: statusPillText
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
||||||
color: "#111111"
|
color: Theme.statusBadgeText
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontXs
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
@@ -238,221 +281,204 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: 1
|
|
||||||
Text {
|
|
||||||
text: "COM PORT"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.portName
|
|
||||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: 90
|
|
||||||
Layout.maximumHeight: 90
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.preferredWidth: 65
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: 6
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 2
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "DIRECTORY"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Medium
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: deviceController.saveDataDir || "Not configured"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.italic: true
|
|
||||||
opacity: 0.7
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
visible: root.csvPath !== ""
|
|
||||||
width: sessionSavedLabel.implicitWidth + 12
|
|
||||||
height: 18
|
|
||||||
radius: 4
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: browseBtn
|
|
||||||
text: "Save As"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
implicitWidth: 70
|
|
||||||
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: saveDirDialog.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 10
|
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "WAFER & SENSOR DATA"
|
text: root.portName
|
||||||
color: Theme.sideMutedText
|
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontMd
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.letterSpacing: 0.6
|
elide: Text.ElideMiddle
|
||||||
Layout.bottomMargin: 2
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "WAFER & SENSOR DATA"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
columns: 2
|
||||||
|
rowSpacing: 6
|
||||||
|
columnSpacing: 16
|
||||||
|
|
||||||
|
GridCell {
|
||||||
|
id: waferInfoFamilyCell
|
||||||
|
label: "Family Code"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridCell {
|
||||||
|
id: waferSerialCell
|
||||||
|
label: "Serial"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GridLayout {
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.preferredHeight: 1
|
||||||
columns: 2
|
color: Theme.cardBorder
|
||||||
rowSpacing: 4
|
opacity: 0.5
|
||||||
columnSpacing: 16
|
}
|
||||||
|
|
||||||
GridCell {
|
GridCell {
|
||||||
id: waferInfoFamilyCell
|
id: waferCyclesCell
|
||||||
label: "Family Code"
|
label: "Cycles Completed"
|
||||||
value: {
|
value: {
|
||||||
var info = deviceController.lastWaferInfo;
|
var info = deviceController.lastWaferInfo;
|
||||||
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
GridCell {
|
active: root.waferDetected
|
||||||
id: waferCyclesCell
|
}
|
||||||
label: "Cycles Completed"
|
GridCell {
|
||||||
value: {
|
id: waferSensorsCell
|
||||||
var info = deviceController.lastWaferInfo;
|
label: "Sensors"
|
||||||
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
value: {
|
||||||
}
|
var info = deviceController.lastWaferInfo;
|
||||||
active: root.waferDetected
|
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.columnSpan: 2
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
opacity: 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
GridCell {
|
|
||||||
id: waferSerialCell
|
|
||||||
label: "Serial"
|
|
||||||
value: {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GridCell {
|
|
||||||
id: waferSensorsCell
|
|
||||||
label: "Sensors"
|
|
||||||
value: {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Directory: bottom-left cell ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "DIRECTORY"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.csvPath !== ""
|
||||||
|
implicitWidth: sessionSavedLabel.implicitWidth + 12
|
||||||
|
implicitHeight: 18
|
||||||
|
radius: 4
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Path pinned bottom-left
|
||||||
|
Text {
|
||||||
|
text: deviceController.saveDataDir || "Not configured"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Total Runtime: bottom center ──
|
||||||
StatCard {
|
StatCard {
|
||||||
id: runtimeCard
|
id: runtimeCard
|
||||||
Layout.preferredWidth: 35
|
label: "TOTAL RUNTIME"
|
||||||
value: {
|
value: {
|
||||||
var info = deviceController.lastWaferInfo;
|
var info = deviceController.lastWaferInfo;
|
||||||
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—";
|
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";
|
||||||
}
|
}
|
||||||
label: "TOTAL RUNTIME"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,10 +498,20 @@ ColumnLayout {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 36
|
Layout.preferredHeight: 36
|
||||||
color: Theme.subtleSectionBackground
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
// Cover bottom corners so only top-left and top-right are rounded
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: parent.radius
|
||||||
|
color: parent.color
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -483,66 +519,27 @@ ColumnLayout {
|
|||||||
anchors.rightMargin: Theme.panelPadding
|
anchors.rightMargin: Theme.panelPadding
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Text {
|
IconImage {
|
||||||
text: "📄"
|
source: "icons/file-text.svg"
|
||||||
font.pixelSize: Theme.fontSm
|
width: 14; height: 14
|
||||||
|
sourceSize.width: 14
|
||||||
|
sourceSize.height: 14
|
||||||
|
color: Theme.sideMutedText
|
||||||
visible: root.csvPath !== ""
|
visible: root.csvPath !== ""
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
|
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
|
||||||
color: Theme.bodyColor
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
|
font.letterSpacing: 1.5
|
||||||
font.weight: Font.Medium
|
font.bold: true
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
|
||||||
id: refreshSessionBtn
|
|
||||||
text: "Refresh"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: refreshSessionBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: refreshSessionBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: deviceController.clearSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: clearLogBtn
|
|
||||||
text: "Clear"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: clearLogBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: clearLogBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: deviceController.clearActivityLog()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -563,7 +560,9 @@ ColumnLayout {
|
|||||||
TextArea {
|
TextArea {
|
||||||
id: activityLog
|
id: activityLog
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
// 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
|
readOnly: true
|
||||||
font.family: "monospace"
|
font.family: "monospace"
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
@@ -574,12 +573,6 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onActivityLogUpdated(newLog) {
|
|
||||||
activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -631,6 +624,13 @@ ColumnLayout {
|
|||||||
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,6 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Dialogs
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
import ISC.Tabs.components
|
import ISC.Tabs.components
|
||||||
@@ -10,30 +10,26 @@ 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++
|
|
||||||
onRunningChanged: if (!running)
|
|
||||||
root._liveSecs = 0
|
|
||||||
}
|
|
||||||
function fmtTime(s) {
|
|
||||||
var m = Math.floor(s / 60);
|
|
||||||
var ss = s % 60;
|
|
||||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
|
||||||
}
|
|
||||||
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
|
|
||||||
// into the trend chart's data property. The slot parseJsonToData() is defined
|
|
||||||
// on the Python TrendChartItem; we use it so malformed payloads are logged
|
|
||||||
// there instead of crashing the QML binding.
|
|
||||||
Connections {
|
Connections {
|
||||||
target: streamController
|
target: streamController
|
||||||
function onTrendData(avgsJson) {
|
function onTrendDelta(deltaJson) {
|
||||||
trendChart.setDataFromJson(avgsJson);
|
trendChart.appendDelta(deltaJson);
|
||||||
|
}
|
||||||
|
function onTrendReset() {
|
||||||
|
trendChart.clearTrend();
|
||||||
|
}
|
||||||
|
function onLoadedFileChanged() {
|
||||||
|
root.loadErrorMessage = "";
|
||||||
|
}
|
||||||
|
function onLoadFileError(message) {
|
||||||
|
root.loadErrorMessage = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,110 +43,6 @@ Item {
|
|||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 10
|
spacing: 10
|
||||||
|
|
||||||
// Mode toggle
|
|
||||||
TabBar {
|
|
||||||
id: modeBar
|
|
||||||
currentIndex: 0 // Default to "Review"
|
|
||||||
spacing: 2
|
|
||||||
padding: 2
|
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.subtleSectionBackground
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: Theme.borderThin
|
|
||||||
}
|
|
||||||
TabButton {
|
|
||||||
text: "Review"
|
|
||||||
implicitWidth: 64
|
|
||||||
implicitHeight: 28
|
|
||||||
background: Rectangle {
|
|
||||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
||||||
radius: Theme.radiusSm - 1
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TabButton {
|
|
||||||
text: "Live"
|
|
||||||
enabled: deviceController.connectionStatus === "Connected"
|
|
||||||
opacity: enabled ? 1.0 : 0.4
|
|
||||||
implicitWidth: 58
|
|
||||||
implicitHeight: 28
|
|
||||||
background: Rectangle {
|
|
||||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
||||||
radius: Theme.radiusSm - 1
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: parent.text
|
|
||||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onCurrentIndexChanged: if (currentIndex === 1) {
|
|
||||||
// Guard: revert to Review if not connected
|
|
||||||
if (deviceController.connectionStatus !== "Connected") {
|
|
||||||
currentIndex = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Entering Live mode
|
|
||||||
streamController.setMode("live");
|
|
||||||
var fc = "";
|
|
||||||
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
|
||||||
fc = deviceController.lastWaferInfo[0];
|
|
||||||
}
|
|
||||||
streamController.startStream(deviceController.selectedPort, fc);
|
|
||||||
} else {
|
|
||||||
// Entering Review Mode (automatically calls stopStream in backend)
|
|
||||||
streamController.setMode("review");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live source info: WiFi icon + port · serial
|
|
||||||
RowLayout {
|
|
||||||
visible: streamController.mode === "live"
|
|
||||||
spacing: 5
|
|
||||||
// WiFi icon (Unicode approximation; swap for SVG if available)
|
|
||||||
Label {
|
|
||||||
id: liveIndicator
|
|
||||||
text: "◉"
|
|
||||||
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
|
|
||||||
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: Theme.fontXs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
@@ -205,48 +97,40 @@ Item {
|
|||||||
font.letterSpacing: 1.2
|
font.letterSpacing: 1.2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record start/stop toggle (Live mode)
|
// ── Load error banner ───────────────────────────────────────────
|
||||||
Button {
|
Rectangle {
|
||||||
visible: streamController.mode === "live"
|
Layout.fillWidth: true
|
||||||
enabled: streamController.state !== "idle" || streamController.recording
|
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||||
text: streamController.recording ? "Stop REC" : "Record"
|
visible: root.loadErrorMessage !== ""
|
||||||
onClicked: {
|
color: Theme.errorSurface
|
||||||
if (streamController.recording) {
|
border.color: Theme.statusErrorColor
|
||||||
streamController.stopRecording();
|
border.width: 1
|
||||||
} else {
|
radius: Theme.radiusSm
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
var serial = (info && info.length > 1) ? info[1] : "";
|
RowLayout {
|
||||||
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
id: errorRow
|
||||||
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
anchors.fill: parent
|
||||||
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
anchors.margins: 8
|
||||||
}
|
spacing: 8
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
source: "icons/triangle-alert.svg"
|
||||||
|
width: 16; height: 16
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.errorTextSoft
|
||||||
|
Layout.alignment: Qt.AlignTop
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Stream statistics popup (Live mode)
|
Label {
|
||||||
Button {
|
text: root.loadErrorMessage
|
||||||
visible: streamController.mode === "live"
|
color: Theme.errorTextSoft
|
||||||
text: "Stats"
|
font.pixelSize: Theme.fontSm
|
||||||
onClicked: streamStatsDialog.open()
|
wrapMode: Text.WordWrap
|
||||||
}
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
StreamStatsDialog {
|
|
||||||
id: streamStatsDialog
|
|
||||||
elapsedSecs: root._liveSecs
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
text: "Export PNG"
|
|
||||||
onClicked: exportDialog.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
FileDialog {
|
|
||||||
id: exportDialog
|
|
||||||
title: "Export Wafer Map"
|
|
||||||
fileMode: FileDialog.SaveFile
|
|
||||||
nameFilters: ["PNG files (*.png)"]
|
|
||||||
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,55 +144,78 @@ Item {
|
|||||||
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
|
showThickness: readoutPanel.showThickness
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: trendPane
|
id: trendPane
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 160
|
Layout.fillHeight: false
|
||||||
|
Layout.preferredHeight: 220
|
||||||
|
Layout.minimumHeight: 120
|
||||||
visible: trendChart.hasData && streamController.mode === "live"
|
visible: trendChart.hasData && streamController.mode === "live"
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
border.color: Theme.cardBorder
|
border.color: Theme.cardBorder
|
||||||
border.width: 1
|
border.width: 1
|
||||||
radius: Theme.radiusMd
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
TrendChartItem {
|
ColumnLayout {
|
||||||
id: trendChart
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: 8
|
anchors.margins: 8
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
TrendChartItem {
|
||||||
|
id: trendChart
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: 16
|
visible: transportBar.hasContent
|
||||||
|
implicitHeight: trendPane.visible ? 52 : 16
|
||||||
}
|
}
|
||||||
TransportBar {
|
TransportBar {
|
||||||
|
id: transportBar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Readout panel — floating wrapper box
|
// 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: "transparent"
|
|
||||||
border.color: "transparent"
|
|
||||||
border.width: 0
|
|
||||||
clip: true
|
clip: true
|
||||||
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||||
|
|
||||||
ReadoutPanel {
|
ReadoutPanel {
|
||||||
id: readoutPanel
|
id: readoutPanel
|
||||||
anchors.fill: parent
|
width: parent.width
|
||||||
anchors.margins: 0
|
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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ Rectangle {
|
|||||||
property string label: ""
|
property string label: ""
|
||||||
property string iconSource: ""
|
property string iconSource: ""
|
||||||
property bool destructive: false
|
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
|
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||||
: root.destructive ? Theme.statusErrorColor
|
: root.destructive ? Theme.statusErrorColor
|
||||||
: Theme.headingColor
|
: Theme.headingColor
|
||||||
@@ -20,7 +23,7 @@ Rectangle {
|
|||||||
|
|
||||||
implicitHeight: Theme.sideButtonHeight
|
implicitHeight: Theme.sideButtonHeight
|
||||||
radius: 8
|
radius: 8
|
||||||
color: mouseArea.containsMouse || mouseArea.pressed
|
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
||||||
? Theme.sideActiveBackground : "transparent"
|
? Theme.sideActiveBackground : "transparent"
|
||||||
border.width: 0
|
border.width: 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +1,26 @@
|
|||||||
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 =====
|
// ===== Readout Panel =====
|
||||||
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS.
|
// Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
|
||||||
// Cards match the left-rail SOURCE / CONNECTION FLOW style.
|
// 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 {
|
||||||
|
id: root
|
||||||
spacing: 6
|
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 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
|
|
||||||
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
Behavior on border.color {
|
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: "✓"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.panelBackground
|
|
||||||
opacity: toggle.checked ? 1.0 : 0.0
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation { duration: Theme.durationFast }
|
|
||||||
}
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component BadgePill: Rectangle {
|
component BadgePill: Rectangle {
|
||||||
implicitWidth: badgeLabel.implicitWidth + 10
|
implicitWidth: badgeLabel.implicitWidth + 10
|
||||||
@@ -75,279 +40,127 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
StreamControlPanel {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: readoutCard.implicitHeight + 24
|
onExportRequested: function(filePath, extra) {
|
||||||
color: Theme.sidePanelBackground
|
root.exportRequested(filePath, extra);
|
||||||
radius: Theme.sidePanelRadius
|
}
|
||||||
border.color: Theme.sideBorder
|
}
|
||||||
border.width: 1
|
|
||||||
|
// ── READOUT ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: readoutCard
|
id: readoutCol
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
spacing: 8
|
||||||
top: parent.top
|
|
||||||
topMargin: 12
|
|
||||||
}
|
|
||||||
spacing: 0
|
|
||||||
|
|
||||||
// Header Inside the Box
|
SectionTitle { text: "READOUT" }
|
||||||
Label {
|
|
||||||
text: "READOUT"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.topMargin: 2
|
|
||||||
Layout.bottomMargin: 10
|
|
||||||
}
|
|
||||||
|
|
||||||
// Min Temp Row
|
ReadoutStat {
|
||||||
RowLayout {
|
label: "Min Temp"
|
||||||
Layout.fillWidth: true
|
value: s.min !== undefined
|
||||||
Layout.leftMargin: 14
|
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
|
||||||
Layout.rightMargin: 14
|
: "—"
|
||||||
Layout.preferredHeight: 32
|
valueColor: Theme.sensorLow
|
||||||
Label {
|
|
||||||
text: "MIN TEMP"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
RowLayout {
|
|
||||||
spacing: 4
|
|
||||||
Label {
|
|
||||||
text: s.min !== undefined ? s.min : "—"
|
|
||||||
color: Theme.sensorLow
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: (s.minIndex !== undefined) && s.minIndex >= 0
|
|
||||||
text: "#" + (s.minIndex !== undefined ? s.minIndex : "")
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
Layout.alignment: Qt.AlignBottom
|
|
||||||
bottomPadding: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
label: "Max Temp"
|
||||||
Layout.fillWidth: true
|
value: s.max !== undefined
|
||||||
height: 1
|
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||||
color: Theme.cardBorder
|
: "—"
|
||||||
|
valueColor: Theme.sensorHigh
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Max Temp Row
|
label: "Diff"
|
||||||
RowLayout {
|
value: s.diff !== undefined ? s.diff : "—"
|
||||||
Layout.fillWidth: true
|
valueColor: Theme.diffAccent
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "MAX TEMP"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
RowLayout {
|
|
||||||
spacing: 4
|
|
||||||
Label {
|
|
||||||
text: s.max !== undefined ? s.max : "—"
|
|
||||||
color: Theme.sensorHigh
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
visible: (s.maxIndex !== undefined) && s.maxIndex >= 0
|
|
||||||
text: "#" + (s.maxIndex !== undefined ? s.maxIndex : "")
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
Layout.alignment: Qt.AlignBottom
|
|
||||||
bottomPadding: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
label: "Average"
|
||||||
Layout.fillWidth: true
|
value: s.avg !== undefined ? s.avg : "—"
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Differential Row
|
label: "Sigma (Σ)"
|
||||||
RowLayout {
|
value: s.sigma !== undefined ? s.sigma : "—"
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "DIFF"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.diff !== undefined ? s.diff : "—"
|
|
||||||
color: Theme.isDarkMode ? "#A78BFA" : "#8B5CF6"
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
label: "3Σ Value"
|
||||||
Layout.fillWidth: true
|
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
// Average Row
|
visible: s.ecMinDelta !== undefined
|
||||||
RowLayout {
|
label: "E-C Δ Min"
|
||||||
Layout.fillWidth: true
|
value: s.ecMinDelta !== undefined
|
||||||
Layout.leftMargin: 14
|
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||||
Layout.rightMargin: 14
|
: "—"
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "AVERAGE"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.avg !== undefined ? s.avg : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ReadoutStat {
|
||||||
Rectangle {
|
visible: s.ecMaxDelta !== undefined
|
||||||
Layout.fillWidth: true
|
label: "E-C Δ Max"
|
||||||
height: 1
|
value: s.ecMaxDelta !== undefined
|
||||||
color: Theme.cardBorder
|
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||||
}
|
: "—"
|
||||||
|
|
||||||
// Sigma Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "SIGMA (Σ)"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.sigma !== undefined ? s.sigma : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3-Sigma Row
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.leftMargin: 14
|
|
||||||
Layout.rightMargin: 14
|
|
||||||
Layout.preferredHeight: 32
|
|
||||||
Label {
|
|
||||||
text: "3Σ VALUE"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
Label {
|
|
||||||
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.family: Theme.codeFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// ── DISPLAY ─────────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: displayCard.implicitHeight + 24
|
Layout.preferredHeight: displayCard.implicitHeight + 24
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: displayCard
|
id: displayCard
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
margins: 12
|
|
||||||
}
|
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
Label {
|
SectionTitle { text: "DISPLAY" }
|
||||||
text: "DISPLAY"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.bottomMargin: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: thicknessToggle
|
id: thicknessToggle
|
||||||
text: "Show Thickness"
|
text: "Show Thickness"
|
||||||
checked: false
|
checked: false
|
||||||
font.pixelSize: Theme.fontXs
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: thicknessDialog
|
||||||
|
title: "Select Thickness Data File"
|
||||||
|
nameFilters: ["CSV files (*.csv)"]
|
||||||
|
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
||||||
|
onRejected: thicknessToggle.checked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: labelsToggle
|
id: labelsToggle
|
||||||
text: "Labels"
|
text: "Labels"
|
||||||
checked: true
|
checked: true
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
|
}
|
||||||
|
|
||||||
|
PanelCheckBox {
|
||||||
|
id: extremesToggle
|
||||||
|
text: "Highlight Min/Max"
|
||||||
|
checked: true
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: clusterAverageToggle
|
id: clusterAverageToggle
|
||||||
text: "Average Clusters"
|
text: "Average Clusters"
|
||||||
checked: streamController.clusterAveragingEnabled
|
checked: streamController.clusterAveragingEnabled
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,35 +176,25 @@ ColumnLayout {
|
|||||||
Label {
|
Label {
|
||||||
text: "Heatmap"
|
text: "Heatmap"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
Layout.preferredWidth: 52
|
Layout.preferredWidth: 52
|
||||||
}
|
}
|
||||||
Slider {
|
PanelSlider {
|
||||||
id: heatmapSlider
|
id: heatmapSlider
|
||||||
from: 0
|
from: 0
|
||||||
to: 1
|
to: 1
|
||||||
value: 0
|
value: 0
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
ToolTip.visible: hovered
|
|
||||||
ToolTip.text: Math.round(value * 100) + "%"
|
|
||||||
|
|
||||||
handle: Rectangle {
|
AppToolTip {
|
||||||
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width)
|
visible: heatmapSlider.hovered
|
||||||
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
implicitWidth: 18
|
|
||||||
implicitHeight: 18
|
|
||||||
radius: 9
|
|
||||||
color: Theme.primaryAccent
|
|
||||||
scale: heatmapSlider.pressed ? 1.15 : 1.0
|
|
||||||
Behavior on scale {
|
|
||||||
NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
Layout.preferredWidth: 32
|
Layout.preferredWidth: 32
|
||||||
horizontalAlignment: Text.AlignRight
|
horizontalAlignment: Text.AlignRight
|
||||||
@@ -400,38 +203,23 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
// ── THRESHOLDS ──────────────────────────────────────────────────
|
||||||
|
PanelBox {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: thresholdCard.implicitHeight + 24
|
Layout.preferredHeight: thresholdCard.implicitHeight + 24
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
radius: Theme.sidePanelRadius
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: thresholdCard
|
id: thresholdCard
|
||||||
anchors {
|
anchors.fill: parent
|
||||||
left: parent.left
|
anchors.margins: 12
|
||||||
right: parent.right
|
|
||||||
top: parent.top
|
|
||||||
margins: 12
|
|
||||||
}
|
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
SectionTitle { text: "THRESHOLDS" }
|
||||||
text: "THRESHOLDS"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.bottomMargin: 4
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: "Set Point (°C)"
|
text: "Set Point (°C)"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
TextField {
|
TextField {
|
||||||
id: spField
|
id: spField
|
||||||
@@ -461,7 +249,7 @@ ColumnLayout {
|
|||||||
Label {
|
Label {
|
||||||
text: "Margin (±°C)"
|
text: "Margin (±°C)"
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
}
|
}
|
||||||
TextField {
|
TextField {
|
||||||
id: mgField
|
id: mgField
|
||||||
@@ -492,7 +280,7 @@ ColumnLayout {
|
|||||||
id: autoCheck
|
id: autoCheck
|
||||||
text: "Auto range (mean ± 1σ)"
|
text: "Auto range (mean ± 1σ)"
|
||||||
checked: true
|
checked: true
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontSm
|
||||||
onCheckedChanged: pushThresholds()
|
onCheckedChanged: pushThresholds()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: ""
|
||||||
@@ -60,7 +64,7 @@ Dialog {
|
|||||||
id: overrideTag
|
id: overrideTag
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "OVERRIDE"
|
text: "OVERRIDE"
|
||||||
color: "white"
|
color: Theme.statusBadgeText
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontXs
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.letterSpacing: 1
|
font.letterSpacing: 1
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -181,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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,24 +46,9 @@ ColumnLayout {
|
|||||||
csvEditorDialog.open();
|
csvEditorDialog.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolTip {
|
AppToolTip {
|
||||||
id: editTooltip
|
|
||||||
visible: editBtn.hovered
|
visible: editBtn.hovered
|
||||||
text: "Edit"
|
text: "Edit"
|
||||||
delay: 400
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
contentItem: Text {
|
|
||||||
text: editTooltip.text
|
|
||||||
color: Theme.headingColor
|
|
||||||
font: editTooltip.font
|
|
||||||
}
|
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,24 +69,9 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
onClicked: file_browser.refreshFiles()
|
onClicked: file_browser.refreshFiles()
|
||||||
|
|
||||||
ToolTip {
|
AppToolTip {
|
||||||
id: refreshTooltip
|
|
||||||
visible: refreshBtn.hovered
|
visible: refreshBtn.hovered
|
||||||
text: "Refresh"
|
text: "Refresh"
|
||||||
delay: 400
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
contentItem: Text {
|
|
||||||
text: refreshTooltip.text
|
|
||||||
color: Theme.headingColor
|
|
||||||
font: refreshTooltip.font
|
|
||||||
}
|
|
||||||
background: Rectangle {
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusXs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,10 +92,12 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
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: Theme.fontSm
|
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
||||||
@@ -188,23 +160,52 @@ ColumnLayout {
|
|||||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
|
readonly property var dataTab: {
|
||||||
readonly property bool isActive:{
|
|
||||||
try {
|
try {
|
||||||
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
|
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
||||||
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
|
} catch (e) { return null; }
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
return streamController.mode === "review" && streamController.loadedFile !== "" && 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 ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
|
color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
ColorAnimation { duration: Theme.durationFast }
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
@@ -223,13 +224,15 @@ ColumnLayout {
|
|||||||
bottomMargin: 5
|
bottomMargin: 5
|
||||||
}
|
}
|
||||||
color: {
|
color: {
|
||||||
|
if (fileItem.isRunA) return Theme.primaryAccent;
|
||||||
|
if (fileItem.isRunB) return Theme.themeSkill;
|
||||||
var t = modelData.waferType;
|
var t = modelData.waferType;
|
||||||
if (t === "A" || t === "E" || t === "P")
|
if (t === "A" || t === "E" || t === "P")
|
||||||
return "#3B82F6";
|
return Theme.familyBlueAccent;
|
||||||
if (t === "B" || t === "C" || t === "D")
|
if (t === "B" || t === "C" || t === "D")
|
||||||
return "#10B981";
|
return Theme.familyGreenAccent;
|
||||||
if (t === "Z")
|
if (t === "Z")
|
||||||
return "#8B5CF6";
|
return Theme.familyVioletAccent;
|
||||||
return Theme.headingColor;
|
return Theme.headingColor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,19 +252,46 @@ ColumnLayout {
|
|||||||
color: {
|
color: {
|
||||||
var t = modelData.waferType;
|
var t = modelData.waferType;
|
||||||
if (t === "A" || t === "E" || t === "P")
|
if (t === "A" || t === "E" || t === "P")
|
||||||
return "#1D4ED8";
|
return Theme.familyBlueFill;
|
||||||
if (t === "B" || t === "C" || t === "D")
|
if (t === "B" || t === "C" || t === "D")
|
||||||
return "#065F46";
|
return Theme.familyGreenFill;
|
||||||
if (t === "Z")
|
if (t === "Z")
|
||||||
return "#7C3AED";
|
return Theme.familyVioletFill;
|
||||||
return "#374151";
|
return Theme.familyNeutralFill;
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: modelData.waferType || "?"
|
text: modelData.waferType || "?"
|
||||||
font.pixelSize: Theme.fontMd
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,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: Theme.fontMd
|
|
||||||
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
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ Popup {
|
|||||||
Label { text: "START (s)"; Layout.preferredWidth: 120; 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: "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 }
|
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||||
|
Item { Layout.preferredWidth: 80 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +268,31 @@ Popup {
|
|||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontSm
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +321,16 @@ Popup {
|
|||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
font.pixelSize: Theme.fontSm
|
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 ────────────────────────────────────────
|
// ── Backend Connection ────────────────────────────────────────
|
||||||
@@ -302,6 +338,7 @@ Popup {
|
|||||||
target: streamController
|
target: streamController
|
||||||
function onSplitResult(result) {
|
function onSplitResult(result) {
|
||||||
root.segmenting = false;
|
root.segmenting = false;
|
||||||
|
exportStatus.text = "";
|
||||||
if (result && result.success) {
|
if (result && result.success) {
|
||||||
root.segments = result.segments || [];
|
root.segments = result.segments || [];
|
||||||
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
||||||
@@ -310,6 +347,13 @@ Popup {
|
|||||||
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
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,128 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import QtQuick.Layouts
|
|
||||||
import QtQuick.Controls
|
|
||||||
import ISC
|
|
||||||
|
|
||||||
// ===== Stream Stats Dialog =====
|
|
||||||
// Popup showing live stream statistics: received frames, errors,
|
|
||||||
// resync count, and elapsed time. Opened from the Live toolbar.
|
|
||||||
|
|
||||||
Popup {
|
|
||||||
id: root
|
|
||||||
modal: true
|
|
||||||
dim: true
|
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
|
||||||
width: 320
|
|
||||||
implicitHeight: content.implicitHeight + 40
|
|
||||||
anchors.centerIn: Overlay.overlay
|
|
||||||
|
|
||||||
// Elapsed seconds supplied by the Live timer in WaferMapTab.
|
|
||||||
property int elapsedSecs: 0
|
|
||||||
|
|
||||||
function fmtTime(s) {
|
|
||||||
var m = Math.floor(s / 60);
|
|
||||||
var ss = s % 60;
|
|
||||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
|
||||||
}
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
component StatRow: RowLayout {
|
|
||||||
property string label
|
|
||||||
property string value
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: label
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
text: value
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: content
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 20
|
|
||||||
spacing: 16
|
|
||||||
|
|
||||||
// ── Header ────────────────────────────────────────────────
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 10
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "STREAM DATA"
|
|
||||||
font.pixelSize: Theme.fontLg
|
|
||||||
font.bold: true
|
|
||||||
color: Theme.headingColor
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
text: "✕"
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stats box ─────────────────────────────────────────────
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
implicitHeight: statRows.implicitHeight + 20
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
color: Theme.subtleSectionBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: statRows
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 10
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
StatRow {
|
|
||||||
label: "Received frames"
|
|
||||||
value: streamController.receivedCount
|
|
||||||
}
|
|
||||||
StatRow {
|
|
||||||
label: "Errors"
|
|
||||||
value: streamController.errorCount
|
|
||||||
}
|
|
||||||
StatRow {
|
|
||||||
label: "Resyncs"
|
|
||||||
value: streamController.resyncCount
|
|
||||||
}
|
|
||||||
StatRow {
|
|
||||||
label: "Elapsed"
|
|
||||||
value: root.fmtTime(root.elapsedSecs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +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
|
||||||
|
|
||||||
// ===== Control Bar =====
|
// ===== Control Bar =====
|
||||||
// Mode-aware footer: cross-fades between Live and Review layouts.
|
// Review-mode transport footer (frame counter, play/pause/step, speed).
|
||||||
// Both variants render as a single centered pill — no full-width bar.
|
// Live-mode's Received/Errors/Stop moved to ReadoutPanel's "LIVE STREAM"
|
||||||
|
// card so this bar can collapse to zero height and free vertical space for
|
||||||
|
// the trend chart while streaming.
|
||||||
Item {
|
Item {
|
||||||
id: bar
|
id: bar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -15,120 +18,13 @@ Item {
|
|||||||
}
|
}
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
// Hide entirely when in review mode with no file loaded
|
// Only shown in review mode, and only once a file is loaded
|
||||||
readonly property bool hasContent: {
|
readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
|
||||||
if (streamController.mode === "live") return true;
|
|
||||||
return streamController.loadedFile !== "";
|
|
||||||
}
|
|
||||||
readonly property bool isLive: streamController.mode === "live"
|
|
||||||
|
|
||||||
// ── LIVE MODE ─────────────────────────────────────────────────
|
|
||||||
Item {
|
|
||||||
id: liveContent
|
|
||||||
anchors.fill: parent
|
|
||||||
opacity: bar.isLive ? 1.0 : 0.0
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single centered pill for Received + Errors + Stop
|
|
||||||
Rectangle {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
implicitWidth: liveRow.implicitWidth + 48
|
|
||||||
implicitHeight: 68
|
|
||||||
radius: Theme.radiusMd
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Row {
|
|
||||||
id: liveRow
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: 24
|
|
||||||
|
|
||||||
// Received counter
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: "Received: " + streamController.receivedCount
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
Rectangle {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 1
|
|
||||||
height: 36
|
|
||||||
color: Theme.sideBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errors counter
|
|
||||||
Text {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: "Errors: " + streamController.errorCount
|
|
||||||
color: streamController.errorCount > 0
|
|
||||||
? Theme.statusWarningColor : Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
Rectangle {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 1
|
|
||||||
height: 36
|
|
||||||
color: Theme.sideBorder
|
|
||||||
}
|
|
||||||
|
|
||||||
// STOP button
|
|
||||||
Button {
|
|
||||||
id: stopBtn
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
implicitWidth: 112
|
|
||||||
implicitHeight: 48
|
|
||||||
hoverEnabled: true
|
|
||||||
text: "STOP"
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
font.letterSpacing: 1.0
|
|
||||||
|
|
||||||
background: Rectangle {
|
|
||||||
radius: 8
|
|
||||||
color: stopBtn.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: streamController.stopStream()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── REVIEW MODE ──────────────────────────────────────────────
|
// ── REVIEW MODE ──────────────────────────────────────────────
|
||||||
Item {
|
Item {
|
||||||
id: reviewContent
|
id: reviewContent
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
opacity: !bar.isLive ? 1.0 : 0.0
|
|
||||||
visible: opacity > 0
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single centered pill for frame counter + transport + speed
|
// Single centered pill for frame counter + transport + speed
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -146,15 +42,69 @@ Item {
|
|||||||
spacing: 24
|
spacing: 24
|
||||||
|
|
||||||
// Frame counter
|
// Frame counter
|
||||||
Text {
|
Row {
|
||||||
|
spacing: 6
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Frame " + (streamController.frameIndex + 1)
|
|
||||||
+ " / " + streamController.frameTotal
|
Label {
|
||||||
color: Theme.sideMutedText
|
text: "Frame"
|
||||||
font.family: Theme.uiFontFamily
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontMd
|
font.family: Theme.uiFontFamily
|
||||||
font.weight: Font.Medium
|
font.pixelSize: Theme.fontMd
|
||||||
leftPadding: 8
|
font.weight: Font.Medium
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField {
|
||||||
|
id: frameInput
|
||||||
|
text: String(streamController.frameIndex + 1)
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.codeFontFamily
|
||||||
|
color: Theme.headingColor
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
horizontalAlignment: TextInput.AlignHCenter
|
||||||
|
selectByMouse: true
|
||||||
|
inputMethodHints: Qt.ImhDigitsOnly
|
||||||
|
padding: 0
|
||||||
|
width: Math.max(44, contentWidth + 16)
|
||||||
|
height: 28
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditingFinished: {
|
||||||
|
var val = parseInt(text)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
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
|
// Separator 1
|
||||||
@@ -177,8 +127,6 @@ Item {
|
|||||||
implicitWidth: 56
|
implicitWidth: 56
|
||||||
implicitHeight: 56
|
implicitHeight: 56
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
text: "⏮"
|
|
||||||
font.pixelSize: 24
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: 8
|
radius: 8
|
||||||
color: skipStartBtn.pressed ? Theme.transportButtonHover
|
color: skipStartBtn.pressed ? Theme.transportButtonHover
|
||||||
@@ -188,12 +136,13 @@ Item {
|
|||||||
ColorAnimation { duration: Theme.durationFast }
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentItem: Text {
|
contentItem: IconImage {
|
||||||
text: parent.text
|
source: "../icons/prev.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font: parent.font
|
anchors.centerIn: parent
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
onClicked: streamController.step(-1)
|
onClicked: streamController.step(-1)
|
||||||
}
|
}
|
||||||
@@ -205,8 +154,6 @@ Item {
|
|||||||
implicitWidth: 56
|
implicitWidth: 56
|
||||||
implicitHeight: 56
|
implicitHeight: 56
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
text: "■"
|
|
||||||
font.pixelSize: 22
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: 8
|
radius: 8
|
||||||
color: reviewStopBtn.pressed ? Theme.transportButtonHover
|
color: reviewStopBtn.pressed ? Theme.transportButtonHover
|
||||||
@@ -216,12 +163,13 @@ Item {
|
|||||||
ColorAnimation { duration: Theme.durationFast }
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentItem: Text {
|
contentItem: IconImage {
|
||||||
text: parent.text
|
source: "../icons/stop.svg"
|
||||||
|
width: 20; height: 20
|
||||||
|
sourceSize.width: 20
|
||||||
|
sourceSize.height: 20
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font: parent.font
|
anchors.centerIn: parent
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
onClicked: streamController.stop()
|
onClicked: streamController.stop()
|
||||||
}
|
}
|
||||||
@@ -230,11 +178,9 @@ Item {
|
|||||||
Button {
|
Button {
|
||||||
id: playPauseBtn
|
id: playPauseBtn
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
implicitWidth: 64
|
implicitWidth: 56
|
||||||
implicitHeight: 56
|
implicitHeight: 56
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
text: streamController.playing ? "⏸" : "▶"
|
|
||||||
font.pixelSize: 24
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: 8
|
radius: 8
|
||||||
color: playPauseBtn.pressed ? Theme.transportButtonHover
|
color: playPauseBtn.pressed ? Theme.transportButtonHover
|
||||||
@@ -244,13 +190,13 @@ Item {
|
|||||||
ColorAnimation { duration: Theme.durationFast }
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentItem: Text {
|
contentItem: IconImage {
|
||||||
text: parent.text
|
source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font: parent.font
|
anchors.centerIn: parent
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
leftPadding: parent.text === "▶" ? 4 : 0
|
|
||||||
}
|
}
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (streamController.playing) {
|
if (streamController.playing) {
|
||||||
@@ -268,8 +214,6 @@ Item {
|
|||||||
implicitWidth: 56
|
implicitWidth: 56
|
||||||
implicitHeight: 56
|
implicitHeight: 56
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
text: "⏭"
|
|
||||||
font.pixelSize: 24
|
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
radius: 8
|
radius: 8
|
||||||
color: skipEndBtn.pressed ? Theme.transportButtonHover
|
color: skipEndBtn.pressed ? Theme.transportButtonHover
|
||||||
@@ -279,12 +223,13 @@ Item {
|
|||||||
ColorAnimation { duration: Theme.durationFast }
|
ColorAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentItem: Text {
|
contentItem: IconImage {
|
||||||
text: parent.text
|
source: "../icons/next.svg"
|
||||||
|
width: 22; height: 22
|
||||||
|
sourceSize.width: 22
|
||||||
|
sourceSize.height: 22
|
||||||
color: Theme.headingColor
|
color: Theme.headingColor
|
||||||
font: parent.font
|
anchors.centerIn: parent
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
onClicked: streamController.step(1)
|
onClicked: streamController.step(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ 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 showThickness: map.showThickness
|
||||||
|
property alias hasThickness: map.hasThickness
|
||||||
|
|
||||||
WaferMapItem {
|
WaferMapItem {
|
||||||
id: map
|
id: map
|
||||||
@@ -29,8 +31,28 @@ Item {
|
|||||||
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 => {
|
||||||
|
if (streamController.mode === "live")
|
||||||
|
return;
|
||||||
var idx = map.which_marker(ev.position.x, ev.position.y);
|
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||||
if (idx >= 0)
|
if (idx >= 0)
|
||||||
replaceDialog.openFor(idx);
|
replaceDialog.openFor(idx);
|
||||||
@@ -42,7 +64,11 @@ Item {
|
|||||||
id: replaceDialog
|
id: replaceDialog
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportImage(filePath) {
|
function exportImage(filePath, extra) {
|
||||||
return map.export_image(filePath);
|
return map.export_image(filePath, extra || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadThickness(filePath) {
|
||||||
|
return map.loadThickness(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,21 @@ 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
|
RailActionButton 1.0 RailActionButton.qml
|
||||||
|
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||||
SplitDialog 1.0 SplitDialog.qml
|
SplitDialog 1.0 SplitDialog.qml
|
||||||
StreamStatsDialog 1.0 StreamStatsDialog.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,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,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,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="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,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,18 +7,23 @@ import QtQuick
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Sections:
|
// Sections:
|
||||||
// 1. Mode flag
|
// 1. Mode flag
|
||||||
// 2. Typography
|
// 2. Typography (families, pixel sizes, weights)
|
||||||
// 3. Motion
|
// 3. Motion
|
||||||
// 4. Tone palette (raw tones; everything else derives from these)
|
// 4. Tone palette (raw tones; everything else derives from these)
|
||||||
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
||||||
// 6. Borders
|
// 6. Borders
|
||||||
// 7. Text (headings, body, panel titles)
|
// 7. Text (headings, body, panel titles)
|
||||||
// 8. Controls (fields, buttons, checkbox)
|
// 8. Controls (fields, buttons, checkbox, toggle)
|
||||||
// 9. Tracks (pill toggle + scrollbar)
|
// 9. Interaction overlays (hover / active washes — mode-aware)
|
||||||
// 10. Tabs (footer tab bar)
|
// 10. Tracks (pill toggle + scrollbar)
|
||||||
// 11. Side rail
|
// 11. Tabs (mode toggle pills)
|
||||||
// 12. Status (success / warning / error)
|
// 12. Side rail
|
||||||
// 13. Geometry (radius / border width / spacing / sizing)
|
// 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:
|
// Based on Codex themes:
|
||||||
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6
|
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6
|
||||||
@@ -29,7 +34,10 @@ QtObject {
|
|||||||
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
||||||
property bool isDarkMode: true
|
property bool isDarkMode: true
|
||||||
|
|
||||||
// ── 2. Typography ──────────────────────────────────────────────────────
|
// ── 2. Typography ────────────────────────────────────────────────────────
|
||||||
|
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
|
||||||
|
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
|
||||||
|
|
||||||
// pixelSize scale — map to closest token, tune globally
|
// pixelSize scale — map to closest token, tune globally
|
||||||
readonly property int font2xs: 9 // tiny metadata
|
readonly property int font2xs: 9 // tiny metadata
|
||||||
readonly property int fontXs: 11 // captions, micro labels
|
readonly property int fontXs: 11 // captions, micro labels
|
||||||
@@ -44,22 +52,20 @@ QtObject {
|
|||||||
readonly property int fontWeightMedium: 500
|
readonly property int fontWeightMedium: 500
|
||||||
readonly property int fontWeightBold: 700
|
readonly property int fontWeightBold: 700
|
||||||
|
|
||||||
// ── 3. Motion ──────────────────────────────────────────────────────────
|
// ── 3. Motion ────────────────────────────────────────────────────────────
|
||||||
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
|
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 durationBase: 180 // standard transitions (pill switch, row select)
|
||||||
readonly property int durationSlow: 260 // larger transitions (panel open/close)
|
readonly property int durationSlow: 260 // larger transitions (panel open/close)
|
||||||
readonly property var easeStandard: Easing.OutCubic
|
readonly property var easeStandard: Easing.OutCubic
|
||||||
|
|
||||||
// ── 4. Tone palette (Codex theme v1 inspired) ───────────────────────────
|
// ── 4. Tone palette (Codex theme v1 inspired) ────────────────────────────
|
||||||
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
|
|
||||||
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
|
|
||||||
|
|
||||||
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
|
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
|
||||||
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
|
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
|
||||||
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
|
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
|
||||||
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
|
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
|
||||||
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
|
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
|
||||||
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
|
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
|
||||||
|
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
|
||||||
|
|
||||||
readonly property color tone100: themeSurface
|
readonly property color tone100: themeSurface
|
||||||
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
|
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
|
||||||
@@ -71,7 +77,7 @@ QtObject {
|
|||||||
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
|
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
|
||||||
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
|
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
|
||||||
|
|
||||||
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
// ── 5. Surfaces ──────────────────────────────────────────────────────────
|
||||||
readonly property color pageBackground: tone100
|
readonly property color pageBackground: tone100
|
||||||
readonly property color cardBackground: tone150
|
readonly property color cardBackground: tone150
|
||||||
readonly property color panelBackground: tone150
|
readonly property color panelBackground: tone150
|
||||||
@@ -79,7 +85,7 @@ QtObject {
|
|||||||
readonly property color responseBackground: tone150
|
readonly property color responseBackground: tone150
|
||||||
readonly property color subtleSectionBackground: tone250
|
readonly property color subtleSectionBackground: tone250
|
||||||
|
|
||||||
// ── 4. Borders ───────────────────────────────────────────────────────────
|
// ── 6. Borders ───────────────────────────────────────────────────────────
|
||||||
readonly property color cardBorder: toneBorder
|
readonly property color cardBorder: toneBorder
|
||||||
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||||
readonly property color responseBorder: toneBorder
|
readonly property color responseBorder: toneBorder
|
||||||
@@ -88,14 +94,14 @@ QtObject {
|
|||||||
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||||
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
|
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: isDarkMode ? "#101014" : "#FFFFFF"
|
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
|
||||||
readonly property color fieldText: toneText
|
readonly property color fieldText: toneText
|
||||||
@@ -111,32 +117,48 @@ QtObject {
|
|||||||
readonly property color primaryAccent: themeAccent
|
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 ──────────────────────────────────────────────
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// ── 10. Tracks (pill toggle, scrollbar thumb) ────────────────────────────
|
||||||
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
|
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
|
||||||
|
|
||||||
// ── 8. Tabs (mode toggle pills) ──────────────────────────────────────────
|
// ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
|
||||||
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
|
|
||||||
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
// ── 12. Side rail ────────────────────────────────────────────────────────
|
||||||
readonly property color sideRailBackground: tone150
|
readonly property color sideRailBackground: tone150
|
||||||
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
|
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
|
||||||
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||||
readonly property color sideBorder: toneBorder
|
readonly property color sideBorder: toneBorder
|
||||||
readonly property color sideMutedText: toneMute
|
readonly property color sideMutedText: toneMute
|
||||||
|
|
||||||
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
|
// ── 13. Transport / toolbar surfaces ─────────────────────────────────────
|
||||||
readonly property color transportButtonBg: tone250
|
readonly property color transportButtonBg: tone250
|
||||||
readonly property color transportButtonHover: tone300
|
readonly property color transportButtonHover: tone300
|
||||||
readonly property color liveColor: themeAdded
|
readonly property color liveColor: themeAdded
|
||||||
readonly property color recordColor: themeRemoved
|
readonly property color recordColor: themeRemoved
|
||||||
|
|
||||||
// ── 10. Status ───────────────────────────────────────────────────────────
|
// ── 14. Status & badges ──────────────────────────────────────────────────
|
||||||
readonly property color statusSuccessColor: themeAdded
|
readonly property color statusSuccessColor: themeAdded
|
||||||
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
|
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
|
||||||
readonly property color statusErrorColor: themeRemoved
|
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"
|
||||||
|
|
||||||
// -- 10b. Sensor bands (wafer map dots)
|
// ── 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),
|
// ponytail: high-temp shares error red — semantically correct (dangerous value),
|
||||||
// re-hue if colorblind testing demands a different channel.
|
// re-hue if colorblind testing demands a different channel.
|
||||||
@@ -145,7 +167,31 @@ QtObject {
|
|||||||
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
|
||||||
@@ -159,7 +205,8 @@ 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: 280
|
readonly property int sideRailWidth: 300
|
||||||
|
readonly property int rightRailWidth: 280
|
||||||
readonly property int sideRailMargin: 16
|
readonly property int sideRailMargin: 16
|
||||||
readonly property int sideRailSpacing: 16
|
readonly property int sideRailSpacing: 16
|
||||||
readonly property int sideButtonHeight: 44
|
readonly property int sideButtonHeight: 44
|
||||||
|
|||||||
@@ -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 + trend_chart_item registers @QmlElement types (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,34 +42,49 @@ 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) =====
|
||||||
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
|
stream_controller = SessionController()
|
||||||
stream_controller = SessionController(settings=raw_settings_dict)
|
|
||||||
engine.rootContext().setContextProperty("streamController", stream_controller)
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
# Persist session state back to settings when it changes
|
|
||||||
def _persist_session_settings():
|
|
||||||
session_state = stream_controller.collect_settings()
|
|
||||||
for k, v in session_state.items():
|
|
||||||
if hasattr(raw_settings, k):
|
|
||||||
setattr(raw_settings, k, v)
|
|
||||||
LocalSettings.save_settings(data_dir, raw_settings)
|
|
||||||
|
|
||||||
stream_controller.settingsChanged.connect(_persist_session_settings)
|
|
||||||
|
|
||||||
# ===== QML Startup =====
|
# ===== QML Startup =====
|
||||||
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
||||||
# package directory is the import path the engine searches for qmldir.
|
# package directory is the import path the engine searches for qmldir.
|
||||||
|
|||||||
@@ -1,26 +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.file_browser import FileBrowser # noqa: F401
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
|
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
|
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
|
|
||||||
from pygui.backend.models.frame import Frame # noqa: F401
|
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
|
|
||||||
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
|
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
|
|
||||||
from pygui.backend.models.session_model import SessionModel # noqa: F401
|
|
||||||
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
|
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
|
|
||||||
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
|
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
|
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout # noqa: F401
|
|
||||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData # noqa: F401
|
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
|
|
||||||
|
|||||||
@@ -1,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"]
|
|
||||||
|
|||||||
@@ -13,24 +13,20 @@ 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.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.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__)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +40,7 @@ class DeviceController(QObject):
|
|||||||
portsUpdated = Signal(list)
|
portsUpdated = Signal(list)
|
||||||
connectionStatusChanged = Signal()
|
connectionStatusChanged = Signal()
|
||||||
operationInProgressChanged = Signal()
|
operationInProgressChanged = Signal()
|
||||||
|
waferDetectedChanged = Signal()
|
||||||
selectedPortChanged = Signal()
|
selectedPortChanged = Signal()
|
||||||
detectResult = Signal(object) # WaferInfo dict or None
|
detectResult = Signal(object) # WaferInfo dict or None
|
||||||
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
||||||
@@ -73,7 +70,17 @@ class DeviceController(QObject):
|
|||||||
self._activity_log: list[str] = []
|
self._activity_log: list[str] = []
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
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")
|
||||||
|
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
|
||||||
|
# (default lambda s: "") and add:
|
||||||
|
# def _wafer_license_level(self) -> str:
|
||||||
|
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
|
||||||
|
# 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._last_wafer_info: dict[str, Any] = {}
|
||||||
|
self._wafer_detected: bool = False
|
||||||
self._last_csv_path: str = ""
|
self._last_csv_path: str = ""
|
||||||
self._selected_port: str = ""
|
self._selected_port: str = ""
|
||||||
self._data_row_count: int = 0
|
self._data_row_count: int = 0
|
||||||
@@ -116,6 +123,11 @@ class DeviceController(QObject):
|
|||||||
"""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."""
|
||||||
@@ -129,6 +141,14 @@ class DeviceController(QObject):
|
|||||||
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()
|
||||||
|
|
||||||
|
@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(str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def openCsvFile(self, file_path: str) -> None:
|
def openCsvFile(self, file_path: str) -> None:
|
||||||
@@ -204,6 +224,17 @@ class DeviceController(QObject):
|
|||||||
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."""
|
||||||
@@ -268,6 +299,7 @@ class DeviceController(QObject):
|
|||||||
self._set_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
|
||||||
@@ -324,6 +356,7 @@ class DeviceController(QObject):
|
|||||||
self._graph_view.resetChart()
|
self._graph_view.resetChart()
|
||||||
self._activity_log.clear()
|
self._activity_log.clear()
|
||||||
self.detectResult.emit(None)
|
self.detectResult.emit(None)
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
self.parsedDataReady.emit({"success": False})
|
self.parsedDataReady.emit({"success": False})
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
@@ -349,20 +382,29 @@ class DeviceController(QObject):
|
|||||||
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._set_connection_status("Connected")
|
# Build the dict (can raise) before declaring success, so a
|
||||||
wafer_dict = self._wafer_info_to_dict(info)
|
# bad payload can't leave status="Connected" while
|
||||||
self._last_wafer_info = wafer_dict
|
# waferDetected stays false and Read/Erase stay greyed.
|
||||||
self.detectResult.emit(wafer_dict)
|
wafer_dict = self._wafer_info_to_dict(info)
|
||||||
self._save_status()
|
self._selected_port = port or ""
|
||||||
else:
|
self._last_wafer_info = wafer_dict
|
||||||
self._selected_port = ""
|
self._set_wafer_detected(True)
|
||||||
self._set_connection_status("Disconnected")
|
self._set_connection_status("Connected")
|
||||||
self._append_log("No wafer detected on any port")
|
self.detectResult.emit(wafer_dict)
|
||||||
self._last_wafer_info = {}
|
self.selectedPortChanged.emit()
|
||||||
self.detectResult.emit(None)
|
self._save_status()
|
||||||
self._set_operation_progress(False)
|
else:
|
||||||
|
self._selected_port = ""
|
||||||
|
self._set_connection_status("Disconnected")
|
||||||
|
self._append_log("No wafer detected on any port")
|
||||||
|
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
|
@slot_error_boundary
|
||||||
@@ -377,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"})
|
||||||
@@ -436,6 +488,9 @@ class DeviceController(QObject):
|
|||||||
if self._operation_in_progress:
|
if self._operation_in_progress:
|
||||||
self._append_log("Already busy — ignoring erase request")
|
self._append_log("Already busy — ignoring erase request")
|
||||||
return
|
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._set_connection_status("Erasing...")
|
self._set_connection_status("Erasing...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
@@ -502,10 +557,29 @@ class DeviceController(QObject):
|
|||||||
self._debugFinished.emit({"error": "F1 read failed"})
|
self._debugFinished.emit({"error": "F1 read failed"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
fc = (
|
||||||
|
self._last_wafer_info.get("familyCode", "")
|
||||||
|
if self._last_wafer_info
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
sensor_hex = parse_binary_data(sensor_data, fc)
|
||||||
|
debug_hex = parse_binary_data(debug_data, fc)
|
||||||
|
if not sensor_hex or not debug_hex:
|
||||||
|
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({
|
self._debugFinished.emit({
|
||||||
"success": True,
|
"success": True,
|
||||||
"sensor_bytes": len(sensor_data),
|
"sensor_bytes": len(sensor_data),
|
||||||
"debug_bytes": len(debug_data),
|
"debug_bytes": len(debug_data),
|
||||||
|
"csv_path": csv_path,
|
||||||
})
|
})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.exception("Debug worker crashed: %s", exc)
|
log.exception("Debug worker crashed: %s", exc)
|
||||||
@@ -519,10 +593,11 @@ class DeviceController(QObject):
|
|||||||
self._set_connection_status("Connected")
|
self._set_connection_status("Connected")
|
||||||
sensor_bytes = result.get("sensor_bytes", 0)
|
sensor_bytes = result.get("sensor_bytes", 0)
|
||||||
debug_bytes = result.get("debug_bytes", 0)
|
debug_bytes = result.get("debug_bytes", 0)
|
||||||
self._append_log(
|
csv_path = result.get("csv_path", "")
|
||||||
f"Debug read complete: {sensor_bytes} sensor bytes, "
|
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
|
||||||
f"{debug_bytes} debug bytes"
|
if csv_path:
|
||||||
)
|
msg += f", CSV written to {csv_path}"
|
||||||
|
self._append_log(msg)
|
||||||
else:
|
else:
|
||||||
self._set_connection_status("Disconnected")
|
self._set_connection_status("Disconnected")
|
||||||
err_msg = result.get("error", "Debug read failed")
|
err_msg = result.get("error", "Debug read failed")
|
||||||
|
|||||||
@@ -11,12 +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 (
|
||||||
from pygui.backend.models.replay_stats import ReplayStatsTracker
|
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.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
|
||||||
@@ -35,21 +40,22 @@ class SessionController(QObject):
|
|||||||
recordingChanged = Signal()
|
recordingChanged = Signal()
|
||||||
sensorsChanged = Signal()
|
sensorsChanged = Signal()
|
||||||
loadedFileChanged = Signal()
|
loadedFileChanged = Signal()
|
||||||
|
loadFileError = Signal(str)
|
||||||
clusterAveragingEnabledChanged = Signal()
|
clusterAveragingEnabledChanged = Signal()
|
||||||
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
|
||||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||||
# trend: per-frame avg for live graph
|
# trend: per-frame avg for live graph
|
||||||
trendData = Signal(str) # JSON array of floats
|
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
|
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||||
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
# 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
|
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||||
splitResult = Signal(dict) # dict with success, segments
|
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
|
_liveError = Signal() # worker-thread error ping
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None,
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
settings: dict | None = None) -> None:
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._mode = MODE_REVIEW
|
self._mode = MODE_REVIEW
|
||||||
self._model = SessionModel()
|
self._model = SessionModel()
|
||||||
@@ -57,15 +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._trend_buffer: list[float] = [] # rolling window
|
self._stream_start_time: float = 0.0
|
||||||
self._trend_timestamps: list[float] = [] # monotonic timestamps
|
|
||||||
|
|
||||||
# Trend refresh timer - update every 1s
|
|
||||||
self._trend_timer = QTimer(self)
|
|
||||||
self._trend_timer.setInterval(1000)
|
|
||||||
self._trend_timer.timeout.connect(self._flush_trend)
|
|
||||||
|
|
||||||
self._play_timer = QTimer(self)
|
self._play_timer = QTimer(self)
|
||||||
self._play_timer.timeout.connect(self._advance)
|
self._play_timer.timeout.connect(self._advance)
|
||||||
@@ -81,28 +81,18 @@ class SessionController(QObject):
|
|||||||
self._liveError.connect(self._on_live_error, 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._stats_tracker = ReplayStatsTracker()
|
|
||||||
self._received_count: int = 0
|
self._received_count: int = 0
|
||||||
self._error_count: int = 0
|
self._error_count: int = 0
|
||||||
|
|
||||||
# Restore persisted state if available
|
# Comparison cache for dynamic overlap mapping
|
||||||
self._load_settings(settings or {})
|
self._compare_recs_a: list = []
|
||||||
|
self._compare_recs_b: list = []
|
||||||
# ---- persisted settings ----
|
self._compare_alignment_map: dict[int, int] = {}
|
||||||
|
|
||||||
def _load_settings(self, settings: dict) -> None:
|
|
||||||
"""Restore session state from persisted settings dict."""
|
|
||||||
# Do NOT auto-restore the last session file — always start fresh.
|
|
||||||
|
|
||||||
def collect_settings(self) -> dict:
|
|
||||||
"""Return a dict of session state to persist."""
|
|
||||||
return {
|
|
||||||
"session_last_file": self._loaded_file,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- properties QML binds to ----
|
# ---- properties QML binds to ----
|
||||||
@Property(str, notify=modeChanged)
|
@Property(str, notify=modeChanged)
|
||||||
@@ -143,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 [
|
||||||
@@ -168,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:
|
||||||
@@ -197,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()
|
||||||
@@ -214,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
|
||||||
@@ -242,6 +260,12 @@ class SessionController(QObject):
|
|||||||
return
|
return
|
||||||
if mode != "live":
|
if mode != "live":
|
||||||
self.stopStream()
|
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()
|
||||||
@@ -268,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 = ""
|
||||||
@@ -296,22 +321,17 @@ 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:
|
||||||
self._active_clusters = group_sensors_by_radius(self._sensors)
|
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||||
self._player.load(frames)
|
self._player.load(frames)
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._stats_tracker.reset()
|
|
||||||
self._loaded_file = file_path
|
self._loaded_file = file_path
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
# Populate stats tracker from all loaded frames for trend chart (P3.1)
|
|
||||||
for frame in self._player._frames:
|
|
||||||
self._stats_tracker.track(frame.seq, frame.values)
|
|
||||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
|
||||||
self.settingsChanged.emit()
|
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -319,20 +339,28 @@ class SessionController(QObject):
|
|||||||
"""Clear the loaded file, resetting the player and frame states."""
|
"""Clear the loaded file, resetting the player and frame states."""
|
||||||
self._player.load([])
|
self._player.load([])
|
||||||
self._model.reset()
|
self._model.reset()
|
||||||
self._stats_tracker.reset()
|
self._ec_pairs = ()
|
||||||
self._loaded_file = ""
|
self._loaded_file = ""
|
||||||
self.loadedFileChanged.emit()
|
self.loadedFileChanged.emit()
|
||||||
self.sensorsChanged.emit()
|
self.sensorsChanged.emit()
|
||||||
self.settingsChanged.emit()
|
|
||||||
|
|
||||||
# ---- comparison: DTW between two CSV files ----
|
# ---- comparison: DTW between two CSV files ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def compareFiles(self, file_a: str, file_b: str) -> None:
|
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||||
"""Run DTW comparison between two CSV files and emit result."""
|
"""Run DTW comparison between two CSV files and emit result."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pygui.backend.comparison import compare_runs
|
from pygui.backend.comparison import compare_runs
|
||||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
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:
|
try:
|
||||||
if is_official_csv(file_a):
|
if is_official_csv(file_a):
|
||||||
_, recs_a = read_official_csv(file_a)
|
_, recs_a = read_official_csv(file_a)
|
||||||
@@ -350,28 +378,36 @@ class SessionController(QObject):
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use first 100 frames of each for comparison (avoid O(n²) blowup)
|
self._compare_recs_a = recs_a
|
||||||
max_frames = min(100, len(recs_a), len(recs_b))
|
self._compare_recs_b = recs_b
|
||||||
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
|
|
||||||
for i in range(max_frames)]
|
|
||||||
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
|
|
||||||
for i in range(max_frames)]
|
|
||||||
|
|
||||||
result = compare_runs(series_a, series_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
|
# Mean temporal shift along the DTW path: positive means run B
|
||||||
# reaches the same profile features later than run A.
|
# reaches the same profile features later than run A.
|
||||||
path = result["warping_path"]
|
path = result["warping_path"]
|
||||||
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
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 and take
|
# Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
|
||||||
# the largest per-sensor abs difference across all sensors, not
|
# the overlap both runs share) and take the largest per-sensor abs
|
||||||
# just the sensor[0] series used for alignment.
|
# 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
|
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
|
max_sensor_deviation = 0.0
|
||||||
for i, j in zip(result["index_a"], result["index_b"]):
|
for i, j in zip(result["index_a"], result["index_b"]):
|
||||||
if i >= max_frames or j >= max_frames:
|
|
||||||
continue
|
|
||||||
values_a = recs_a[i].values
|
values_a = recs_a[i].values
|
||||||
values_b = recs_b[j].values
|
values_b = recs_b[j].values
|
||||||
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
||||||
@@ -382,9 +418,6 @@ class SessionController(QObject):
|
|||||||
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||||
# same sensor-parsing branch as loadFile() (file_a is assumed representative
|
# same sensor-parsing branch as loadFile() (file_a is assumed representative
|
||||||
# of both files' wafer type).
|
# of both files' wafer type).
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pygui.backend.data.data_records import is_official_csv, read_official_csv
|
|
||||||
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
@@ -404,8 +437,8 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
if sensors:
|
if sensors:
|
||||||
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
||||||
last_a = recs_a[max_frames - 1].values
|
last_a = recs_a[overlap_frames - 1].values
|
||||||
last_b = recs_b[max_frames - 1].values
|
last_b = recs_b[overlap_frames - 1].values
|
||||||
n = min(len(sensors), len(last_a), len(last_b))
|
n = min(len(sensors), len(last_a), len(last_b))
|
||||||
sensor_layout = [
|
sensor_layout = [
|
||||||
{
|
{
|
||||||
@@ -428,9 +461,14 @@ class SessionController(QObject):
|
|||||||
# Lists, not tuples — tuples don't survive QVariant conversion
|
# Lists, not tuples — tuples don't survive QVariant conversion
|
||||||
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
||||||
"frame_offset": frame_offset,
|
"frame_offset": frame_offset,
|
||||||
|
"frame_offset_seconds": frame_offset_seconds,
|
||||||
"max_sensor_deviation": max_sensor_deviation,
|
"max_sensor_deviation": max_sensor_deviation,
|
||||||
"series_a": series_a,
|
"series_a": series_a,
|
||||||
"series_b": series_b,
|
"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_layout": sensor_layout,
|
||||||
"sensor_diff": sensor_diff,
|
"sensor_diff": sensor_diff,
|
||||||
"wafer_shape": wafer_shape,
|
"wafer_shape": wafer_shape,
|
||||||
@@ -443,6 +481,25 @@ class SessionController(QObject):
|
|||||||
"error": str(exc)
|
"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 ----
|
# ---- splitting: threshold-based segmentation ----
|
||||||
@Slot(str, float)
|
@Slot(str, float)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -467,6 +524,10 @@ class SessionController(QObject):
|
|||||||
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
||||||
segments = segment_profile(avg_temps, threshold)
|
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({
|
self.splitResult.emit({
|
||||||
"success": True,
|
"success": True,
|
||||||
"segments": [{
|
"segments": [{
|
||||||
@@ -483,6 +544,48 @@ class SessionController(QObject):
|
|||||||
"error": str(exc)
|
"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()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def play(self) -> None:
|
def play(self) -> None:
|
||||||
@@ -586,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:
|
||||||
@@ -616,10 +720,7 @@ 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._trend_buffer.clear()
|
self._reset_live_trend()
|
||||||
self._trend_timestamps.clear()
|
|
||||||
self._trend_timer.start()
|
|
||||||
self._stats_tracker.reset()
|
|
||||||
self._last = None
|
self._last = None
|
||||||
self._last_raw_frame = None
|
self._last_raw_frame = None
|
||||||
self._received_count = 0
|
self._received_count = 0
|
||||||
@@ -661,7 +762,6 @@ class SessionController(QObject):
|
|||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def stopStream(self) -> None:
|
def stopStream(self) -> None:
|
||||||
self._repaint_timer.stop()
|
self._repaint_timer.stop()
|
||||||
self._trend_timer.stop()
|
|
||||||
self._received_count = 0
|
self._received_count = 0
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
@@ -687,10 +787,8 @@ class SessionController(QObject):
|
|||||||
@Slot(object)
|
@Slot(object)
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
def _on_live_frame(self, frame: Frame) -> None:
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
import json
|
|
||||||
self._received_count += 1
|
self._received_count += 1
|
||||||
self.liveStatsChanged.emit()
|
self.liveStatsChanged.emit()
|
||||||
self._stats_tracker.track(frame.seq, frame.values)
|
|
||||||
# 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,
|
||||||
@@ -699,22 +797,11 @@ class SessionController(QObject):
|
|||||||
if self._recorder.is_recording:
|
if self._recorder.is_recording:
|
||||||
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
|
||||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
|
||||||
|
|
||||||
if self._last and self._last.stats:
|
if self._last and self._last.stats:
|
||||||
avg = self._last.stats.avg
|
avg = self._last.stats.avg
|
||||||
now = time.monotonic()
|
elapsed = time.monotonic() - self._stream_start_time
|
||||||
self._trend_buffer.append(avg)
|
self.trendDelta.emit(json.dumps([[elapsed, avg]]))
|
||||||
self._trend_timestamps.append(now)
|
|
||||||
|
|
||||||
# drop entries older than 60 secs
|
|
||||||
cutoff = now -60
|
|
||||||
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
|
|
||||||
self._trend_timestamps.pop(0)
|
|
||||||
self._trend_buffer.pop(0)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _flush_repaint(self) -> None:
|
def _flush_repaint(self) -> None:
|
||||||
if not self._dirty:
|
if not self._dirty:
|
||||||
@@ -770,6 +857,8 @@ class SessionController(QObject):
|
|||||||
self._sensor_editor.clear()
|
self._sensor_editor.clear()
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
def _flush_trend(self) -> None:
|
def _reset_live_trend(self) -> None:
|
||||||
import json
|
"""Mark a fresh stream start for elapsed-time trend deltas."""
|
||||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
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,14 +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.file_browser import FileBrowser
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CsvRecorder", "CSVFileMetadata",
|
|
||||||
"read_data_records", "is_official_csv", "read_official_csv",
|
|
||||||
"FileBrowser",
|
|
||||||
"LocalSettings", "LocalSettingsModel",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -47,9 +46,15 @@ class CsvRecorder:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) ->bool:
|
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.
|
"""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:
|
Args:
|
||||||
file_path: Output CSV path.
|
file_path: Output CSV path.
|
||||||
source_path: Source CSV path with full wafer data
|
source_path: Source CSV path with full wafer data
|
||||||
@@ -57,11 +62,42 @@ class CsvRecorder:
|
|||||||
end_frame: Last frame index (inclusive)
|
end_frame: Last frame index (inclusive)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success.
|
True on success (at least one frame written).
|
||||||
"""
|
"""
|
||||||
import pandas as pd
|
from pygui.backend.data.data_records import is_data_row, is_official_csv
|
||||||
df = pd.read_csv(source_path)
|
|
||||||
segment = df.iloc[start_frame:end_frame + 1]
|
|
||||||
segment.to_csv(file_path, index=False)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -13,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)
|
||||||
@@ -32,9 +32,6 @@ class LocalSettings:
|
|||||||
self.data_col_count = 0
|
self.data_col_count = 0
|
||||||
self.last_csv_path = ""
|
self.last_csv_path = ""
|
||||||
|
|
||||||
# Session persistence
|
|
||||||
self.session_last_file = ""
|
|
||||||
|
|
||||||
# ===== File Path Helpers =====
|
# ===== File Path Helpers =====
|
||||||
@classmethod
|
@classmethod
|
||||||
def _settings_path(cls, directory: str) -> Path:
|
def _settings_path(cls, directory: str) -> Path:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# ===== Visualization Sub-package =====
|
|
||||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
|
||||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
|
||||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
|
|
||||||
"interpolate_field",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -21,15 +21,60 @@ Usage from QML::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PySide6.QtCore import Property, QRectF, Qt, Signal
|
import math
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
|
||||||
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
|
||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
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_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
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
|
@QmlElement
|
||||||
class GraphQuickItem(QQuickPaintedItem):
|
class GraphQuickItem(QQuickPaintedItem):
|
||||||
"""Painted line chart; driven by series data passed via QML property bindings."""
|
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||||
@@ -40,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
xLabelChanged = Signal()
|
xLabelChanged = Signal()
|
||||||
yLabelChanged = Signal()
|
yLabelChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
|
viewportChanged = Signal()
|
||||||
|
viewStatsChanged = Signal()
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -72,16 +119,37 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._max_y: float = 150.0
|
self._max_y: float = 150.0
|
||||||
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
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) ──────────────────────────────────────────
|
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||||
|
|
||||||
@Property("QVariantList", notify=seriesDataChanged)
|
@Property("QVariantList", notify=seriesDataChanged)
|
||||||
def seriesData(self) -> list:
|
def seriesData(self) -> list:
|
||||||
return self._series_data
|
return self._series_data
|
||||||
|
|
||||||
@seriesData.setter
|
@seriesData.setter # type: ignore[no-redef]
|
||||||
def seriesData(self, val: list) -> None:
|
def seriesData(self, val: list) -> None:
|
||||||
self._series_data = [list(s) for s in (val or [])]
|
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._auto_range()
|
||||||
|
self._recompute_view_stats()
|
||||||
self.seriesDataChanged.emit()
|
self.seriesDataChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -89,17 +157,138 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def sensorNames(self) -> list:
|
def sensorNames(self) -> list:
|
||||||
return self._sensor_names
|
return self._sensor_names
|
||||||
|
|
||||||
@sensorNames.setter
|
@sensorNames.setter # type: ignore[no-redef]
|
||||||
def sensorNames(self, val: list) -> None:
|
def sensorNames(self, val: list) -> None:
|
||||||
self._sensor_names = list(val or [])
|
self._sensor_names = list(val or [])
|
||||||
|
self._recompute_view_stats()
|
||||||
self.sensorNamesChanged.emit()
|
self.sensorNamesChanged.emit()
|
||||||
self.update()
|
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)
|
@Property(str, notify=titleChanged)
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
return self._title
|
return self._title
|
||||||
|
|
||||||
@title.setter
|
@title.setter # type: ignore[no-redef]
|
||||||
def title(self, val: str) -> None:
|
def title(self, val: str) -> None:
|
||||||
self._title = str(val or "")
|
self._title = str(val or "")
|
||||||
self.titleChanged.emit()
|
self.titleChanged.emit()
|
||||||
@@ -109,7 +298,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def xLabel(self) -> str:
|
def xLabel(self) -> str:
|
||||||
return self._x_label
|
return self._x_label
|
||||||
|
|
||||||
@xLabel.setter
|
@xLabel.setter # type: ignore[no-redef]
|
||||||
def xLabel(self, val: str) -> None:
|
def xLabel(self, val: str) -> None:
|
||||||
self._x_label = str(val or "")
|
self._x_label = str(val or "")
|
||||||
self.xLabelChanged.emit()
|
self.xLabelChanged.emit()
|
||||||
@@ -119,7 +308,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def yLabel(self) -> str:
|
def yLabel(self) -> str:
|
||||||
return self._y_label
|
return self._y_label
|
||||||
|
|
||||||
@yLabel.setter
|
@yLabel.setter # type: ignore[no-redef]
|
||||||
def yLabel(self, val: str) -> None:
|
def yLabel(self, val: str) -> None:
|
||||||
self._y_label = str(val or "")
|
self._y_label = str(val or "")
|
||||||
self.yLabelChanged.emit()
|
self.yLabelChanged.emit()
|
||||||
@@ -129,7 +318,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def showLegend(self) -> bool:
|
def showLegend(self) -> bool:
|
||||||
return self._show_legend
|
return self._show_legend
|
||||||
|
|
||||||
@showLegend.setter
|
@showLegend.setter # type: ignore[no-redef]
|
||||||
def showLegend(self, val: bool) -> None:
|
def showLegend(self, val: bool) -> None:
|
||||||
self._show_legend = bool(val)
|
self._show_legend = bool(val)
|
||||||
self.colorsChanged.emit()
|
self.colorsChanged.emit()
|
||||||
@@ -141,7 +330,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def backgroundColor(self) -> QColor:
|
def backgroundColor(self) -> QColor:
|
||||||
return self._bg_color
|
return self._bg_color
|
||||||
|
|
||||||
@backgroundColor.setter
|
@backgroundColor.setter # type: ignore[no-redef]
|
||||||
def backgroundColor(self, c: QColor) -> None:
|
def backgroundColor(self, c: QColor) -> None:
|
||||||
self._bg_color = c
|
self._bg_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -150,7 +339,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def gridColor(self) -> QColor:
|
def gridColor(self) -> QColor:
|
||||||
return self._grid_color
|
return self._grid_color
|
||||||
|
|
||||||
@gridColor.setter
|
@gridColor.setter # type: ignore[no-redef]
|
||||||
def gridColor(self, c: QColor) -> None:
|
def gridColor(self, c: QColor) -> None:
|
||||||
self._grid_color = c
|
self._grid_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -159,7 +348,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def axisColor(self) -> QColor:
|
def axisColor(self) -> QColor:
|
||||||
return self._axis_color
|
return self._axis_color
|
||||||
|
|
||||||
@axisColor.setter
|
@axisColor.setter # type: ignore[no-redef]
|
||||||
def axisColor(self, c: QColor) -> None:
|
def axisColor(self, c: QColor) -> None:
|
||||||
self._axis_color = c
|
self._axis_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -168,7 +357,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
def textColor(self) -> QColor:
|
def textColor(self) -> QColor:
|
||||||
return self._text_color
|
return self._text_color
|
||||||
|
|
||||||
@textColor.setter
|
@textColor.setter # type: ignore[no-redef]
|
||||||
def textColor(self, c: QColor) -> None:
|
def textColor(self, c: QColor) -> None:
|
||||||
self._text_color = c
|
self._text_color = c
|
||||||
self.update()
|
self.update()
|
||||||
@@ -178,7 +367,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
"""Return hex strings of the current series color palette."""
|
"""Return hex strings of the current series color palette."""
|
||||||
return [c.name() for c in self._series_colors]
|
return [c.name() for c in self._series_colors]
|
||||||
|
|
||||||
@seriesColors.setter
|
@seriesColors.setter # type: ignore[no-redef]
|
||||||
def seriesColors(self, hex_list: list) -> None:
|
def seriesColors(self, hex_list: list) -> None:
|
||||||
colors = []
|
colors = []
|
||||||
for h in (hex_list or []):
|
for h in (hex_list or []):
|
||||||
@@ -192,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
# ── internal ──────────────────────────────────────────────────────────────
|
# ── 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:
|
def _auto_range(self) -> None:
|
||||||
"""Set Y range to cover all data with 10% padding."""
|
"""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] = []
|
all_vals: list[float] = []
|
||||||
for series in self._series_data:
|
for series in self._series_data:
|
||||||
for v in series:
|
hi = min(len(series) - 1, end)
|
||||||
|
for i in range(start, hi + 1):
|
||||||
try:
|
try:
|
||||||
all_vals.append(float(v))
|
all_vals.append(float(series[i]))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
if not all_vals:
|
if not all_vals:
|
||||||
@@ -275,8 +489,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
for i in range(n_y_ticks):
|
for i in range(n_y_ticks):
|
||||||
y_val = self._min_y + i * y_step
|
y_val = self._min_y + i * y_step
|
||||||
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
|
y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
|
||||||
y_px = plot_top + y_frac * plot_h
|
|
||||||
|
|
||||||
# Grid line
|
# Grid line
|
||||||
painter.setPen(grid_pen)
|
painter.setPen(grid_pen)
|
||||||
@@ -318,19 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
self._x_label,
|
self._x_label,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- X-axis ticks ---
|
# --- Viewport slice (replay chart zoom/pan; full range by default,
|
||||||
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
|
# so unset viewport reproduces the pre-viewport rendering) ---
|
||||||
if num_points > 1:
|
view_start = max(0, self._view_start)
|
||||||
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
|
view_end = self._resolved_view_end()
|
||||||
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
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):
|
for i in range(n_x_ticks):
|
||||||
idx = int(round(i * x_tick_step))
|
rel_idx = int(round(i * x_tick_step))
|
||||||
x_frac = idx / (num_points - 1)
|
x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
|
||||||
x_px = plot_left + x_frac * plot_w
|
|
||||||
painter.setPen(axis_pen)
|
painter.setPen(axis_pen)
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
int(x_px - 12), int(plot_top + plot_h + 14),
|
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||||
str(idx + 1),
|
str(view_start + rel_idx + 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Axes boundary ---
|
# --- Axes boundary ---
|
||||||
@@ -339,30 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
|
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))
|
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
||||||
|
|
||||||
# --- Figure out max series length ---
|
# --- Draw each series (sliced to the current viewport) ---
|
||||||
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
|
min_pt: tuple[float, float] | None = None
|
||||||
if max_len < 1:
|
max_pt: tuple[float, float] | None = None
|
||||||
return
|
min_seen = math.inf
|
||||||
|
max_seen = -math.inf
|
||||||
# --- Draw each series ---
|
|
||||||
for si, series in enumerate(self._series_data):
|
for si, series in enumerate(self._series_data):
|
||||||
if len(series) < 1:
|
hi = min(len(series) - 1, view_end)
|
||||||
|
sliced = series[view_start:hi + 1] if view_start <= hi else []
|
||||||
|
if not sliced:
|
||||||
continue
|
continue
|
||||||
color = self._series_colors[si % len(self._series_colors)]
|
color = self._series_colors[si % len(self._series_colors)]
|
||||||
pen = QPen(color, 2)
|
pen = QPen(color, 2)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
|
|
||||||
pts: list[tuple[float, float]] = []
|
pts: list[tuple[float, float]] = []
|
||||||
for i, val in enumerate(series):
|
for i, val in enumerate(sliced):
|
||||||
try:
|
try:
|
||||||
v = float(val)
|
v = float(val)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
|
x_px = index_to_pixel(i, view_len, plot_left, plot_w)
|
||||||
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
|
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
||||||
x_px = plot_left + x_frac * plot_w
|
|
||||||
y_px = plot_top + y_frac * plot_h
|
|
||||||
pts.append((x_px, y_px))
|
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) < 2:
|
||||||
if len(pts) == 1:
|
if len(pts) == 1:
|
||||||
@@ -375,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
|
|||||||
int(pts[i + 1][0]), int(pts[i + 1][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 ---
|
# --- Legend ---
|
||||||
if self._show_legend and self._sensor_names:
|
if self._show_legend and self._sensor_names:
|
||||||
legend_font = QFont()
|
legend_font = QFont()
|
||||||
|
|||||||
@@ -23,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}
|
||||||
|
|
||||||
@@ -227,7 +230,7 @@ class GraphView(QObject):
|
|||||||
index_a, index_b = path[i]
|
index_a, index_b = path[i]
|
||||||
if index_a < len(a) and index_b < len(b):
|
if index_a < len(a) and index_b < len(b):
|
||||||
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||||
style=Qt.DashLine))
|
style=Qt.PenStyle.DashLine))
|
||||||
self._plot_widget.addItem(line)
|
self._plot_widget.addItem(line)
|
||||||
|
|
||||||
self._plot_widget.setTitle("DTW Comparison")
|
self._plot_widget.setTitle("DTW Comparison")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.interpolate import RBFInterpolator
|
from scipy.interpolate import RBFInterpolator
|
||||||
|
from scipy.ndimage import zoom as _ndi_zoom
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import cupy as _cupy # type: ignore
|
import cupy as _cupy # type: ignore
|
||||||
@@ -17,6 +18,17 @@ except Exception:
|
|||||||
_KERNEL = "thin_plate_spline"
|
_KERNEL = "thin_plate_spline"
|
||||||
_SMOOTHING = 0.0
|
_SMOOTHING = 0.0
|
||||||
|
|
||||||
|
# Sensor density (≤244 points) carries no more spatial information than this;
|
||||||
|
# evaluating on a bigger grid than the widget's pixel size just burns CPU that
|
||||||
|
# a GPU-bilinear QImage upscale reproduces visually. See alpha-release-polish-plan.md §2.2.
|
||||||
|
GRID_RES = 100
|
||||||
|
|
||||||
|
# Bilinear-scaling the raw 100x100 field straight to widget size shows visible
|
||||||
|
# facets on real (higher-contrast) sensor data. A cheap cubic pre-smooth of the
|
||||||
|
# field (RBF grid is the expensive part; resampling a small array is not) removes
|
||||||
|
# the facets before the final QImage upscale does the rest.
|
||||||
|
REFINE_FACTOR = 2
|
||||||
|
|
||||||
|
|
||||||
def interpolate_field(
|
def interpolate_field(
|
||||||
xs: np.ndarray,
|
xs: np.ndarray,
|
||||||
@@ -28,26 +40,30 @@ def interpolate_field(
|
|||||||
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
|
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
|
||||||
round_clip: bool = False,
|
round_clip: bool = False,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Return a (height, width) float64 array of interpolated values.
|
"""Return a float64 array of interpolated values, capped at GRID_RES per side.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
xs, ys: sensor positions in mm (1-D arrays, length N)
|
xs, ys: sensor positions in mm (1-D arrays, length N)
|
||||||
vs: sensor values (length N)
|
vs: sensor values (length N)
|
||||||
width/height: output grid dimensions in pixels
|
width/height: desired output dimensions in pixels; the grid is solved at
|
||||||
|
min(dim, GRID_RES) and the caller upscales the resulting image
|
||||||
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
|
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
|
||||||
round_clip: if True, pixels outside the inscribed ellipse become NaN
|
round_clip: if True, pixels outside the inscribed ellipse become NaN
|
||||||
"""
|
"""
|
||||||
coords = np.column_stack([xs, ys])
|
coords = np.column_stack([xs, ys])
|
||||||
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
|
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
|
||||||
|
|
||||||
|
grid_w = min(width, GRID_RES)
|
||||||
|
grid_h = min(height, GRID_RES)
|
||||||
|
|
||||||
xmin, xmax, ymin, ymax = extent
|
xmin, xmax, ymin, ymax = extent
|
||||||
gx = np.linspace(xmin, xmax, width)
|
gx = np.linspace(xmin, xmax, grid_w)
|
||||||
gy = np.linspace(ymin, ymax, height)
|
gy = np.linspace(ymin, ymax, grid_h)
|
||||||
grid_x, grid_y = np.meshgrid(gx, gy)
|
grid_x, grid_y = np.meshgrid(gx, gy)
|
||||||
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
|
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
|
||||||
|
|
||||||
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
|
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
|
||||||
field = rbf(flat).reshape(height, width)
|
field = rbf(flat).reshape(grid_h, grid_w)
|
||||||
|
|
||||||
if round_clip:
|
if round_clip:
|
||||||
cx = (xmin + xmax) / 2
|
cx = (xmin + xmax) / 2
|
||||||
@@ -57,5 +73,33 @@ def interpolate_field(
|
|||||||
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
|
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
|
||||||
field = np.where(dist <= 1.0, field, np.nan)
|
field = np.where(dist <= 1.0, field, np.nan)
|
||||||
|
|
||||||
return field.astype(np.float64)
|
result: np.ndarray = field.astype(np.float64)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
|
||||||
|
"""Cubic-upsample a coarse (NaN-free) field by `factor`.
|
||||||
|
|
||||||
|
Callers that need a circular boundary should pass an *unclipped* field
|
||||||
|
(round_clip=False) and mask with ellipse_alpha() afterwards — cubic
|
||||||
|
resampling across a NaN/zero-filled hole rings at the boundary, and any
|
||||||
|
mask derived from the coarse grid keeps its staircase when upscaled.
|
||||||
|
"""
|
||||||
|
refined: np.ndarray = _ndi_zoom(field, factor, order=3)
|
||||||
|
return refined
|
||||||
|
|
||||||
|
|
||||||
|
def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray:
|
||||||
|
"""Anti-aliased coverage mask (0..1) of the ellipse inscribed in the grid.
|
||||||
|
|
||||||
|
Computed analytically at the target resolution — unlike upsampling a
|
||||||
|
binary clip mask from the coarse RBF grid, this cannot show grid steps.
|
||||||
|
`feather_px` is the half-width of the linear edge ramp, in grid pixels.
|
||||||
|
"""
|
||||||
|
y = (np.arange(height) + 0.5) / height * 2.0 - 1.0
|
||||||
|
x = (np.arange(width) + 0.5) / width * 2.0 - 1.0
|
||||||
|
yy, xx = np.meshgrid(y, x, indexing="ij")
|
||||||
|
r = np.sqrt(xx * xx + yy * yy)
|
||||||
|
f = feather_px * 2.0 / min(width, height)
|
||||||
|
return np.clip((1.0 - r) / f + 0.5, 0.0, 1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
"""QQuickPaintedItem trend chart — running-average temperature over time.
|
||||||
|
|
||||||
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
Renders a single polyline series (per-frame average temperatures) over a fixed
|
||||||
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
|
||||||
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
rolling window. Mirrors the @QmlElement registration pattern used by
|
||||||
|
`WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer`
|
||||||
|
and used as `TrendChartItem { }`.
|
||||||
|
|
||||||
The series is driven by the existing `streamController.trendData` signal, which
|
The series is driven by `streamController.trendDelta` (one new
|
||||||
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
`[elapsed_s, value]` point per live frame) and cleared on
|
||||||
streaming.
|
`streamController.trendReset` (new stream started). See
|
||||||
|
docs/adr/0001-trend-chart-axes-standardization.md.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,17 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
|
from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
WINDOW_SECONDS = 60.0
|
||||||
|
Y_MIN = 0.0
|
||||||
|
Y_MAX = 200.0
|
||||||
|
Y_TICKS = (0, 40, 80, 120, 160, 200)
|
||||||
|
|
||||||
|
|
||||||
@QmlElement
|
@QmlElement
|
||||||
class TrendChartItem(QQuickPaintedItem):
|
class TrendChartItem(QQuickPaintedItem):
|
||||||
"""Painted trend chart; driven by a list of floats via the `data` property."""
|
"""Painted trend chart; driven by incremental (elapsed_s, value) points."""
|
||||||
|
|
||||||
dataChanged = Signal()
|
|
||||||
hasDataChanged = Signal()
|
hasDataChanged = Signal()
|
||||||
lineColorChanged = Signal()
|
lineColorChanged = Signal()
|
||||||
gridColorChanged = Signal()
|
gridColorChanged = Signal()
|
||||||
@@ -42,41 +51,35 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self.setAntialiasing(True)
|
self.setAntialiasing(True)
|
||||||
self.setFillColor(QColor("transparent"))
|
self.setFillColor(QColor("transparent"))
|
||||||
|
|
||||||
self._data: list[float] = []
|
self._points: list[tuple[float, float]] = []
|
||||||
self._padding: int = 8
|
self._padding: int = 8
|
||||||
|
# Separate from `_padding` (breathing room around the plot area): these
|
||||||
|
# reserve space for the axis labels themselves, which are wider than
|
||||||
|
# `_padding` alone — that mismatch is what clipped y-axis text before.
|
||||||
|
self._margin_left: int = 48
|
||||||
|
self._margin_bottom: int = 30
|
||||||
|
# Top tick label is drawn centered on the plot's top edge (y_px - 7),
|
||||||
|
# so the plot needs headroom or the label kisses the card border.
|
||||||
|
self._margin_top: int = 10
|
||||||
self._line_color = QColor("#5B9DF5")
|
self._line_color = QColor("#5B9DF5")
|
||||||
self._grid_color = QColor("#2A3441")
|
self._grid_color = QColor("#2A3441")
|
||||||
self._text_color = QColor("#CBD5E1")
|
self._text_color = QColor("#CBD5E1")
|
||||||
|
|
||||||
# ── Qt properties ─────────────────────────────────────────────────────
|
# ── Qt properties ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Property("QVariantList", notify=dataChanged)
|
|
||||||
def data(self) -> list[float]:
|
|
||||||
return self._data
|
|
||||||
|
|
||||||
@data.setter
|
|
||||||
def data(self, val) -> None:
|
|
||||||
coerced = self._coerce_floats(val)
|
|
||||||
if coerced == self._data:
|
|
||||||
return
|
|
||||||
self._data = coerced
|
|
||||||
self.dataChanged.emit()
|
|
||||||
self.hasDataChanged.emit()
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
@Property(bool, notify=hasDataChanged)
|
@Property(bool, notify=hasDataChanged)
|
||||||
def hasData(self) -> bool:
|
def hasData(self) -> bool:
|
||||||
"""True when the data list has at least one point.
|
"""True when at least one point is buffered.
|
||||||
|
|
||||||
QML can bind to this safely (QVariantList `.length` is not bindable).
|
QML can bind to this safely (a plain list length is not bindable).
|
||||||
"""
|
"""
|
||||||
return len(self._data) > 0
|
return len(self._points) > 0
|
||||||
|
|
||||||
@Property(QColor, notify=lineColorChanged)
|
@Property(QColor, notify=lineColorChanged)
|
||||||
def lineColor(self) -> QColor:
|
def lineColor(self) -> QColor:
|
||||||
return self._line_color
|
return self._line_color
|
||||||
|
|
||||||
@lineColor.setter
|
@lineColor.setter # type: ignore[no-redef]
|
||||||
def lineColor(self, c: QColor) -> None:
|
def lineColor(self, c: QColor) -> None:
|
||||||
if c == self._line_color:
|
if c == self._line_color:
|
||||||
return
|
return
|
||||||
@@ -88,7 +91,7 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
def gridColor(self) -> QColor:
|
def gridColor(self) -> QColor:
|
||||||
return self._grid_color
|
return self._grid_color
|
||||||
|
|
||||||
@gridColor.setter
|
@gridColor.setter # type: ignore[no-redef]
|
||||||
def gridColor(self, c: QColor) -> None:
|
def gridColor(self, c: QColor) -> None:
|
||||||
if c == self._grid_color:
|
if c == self._grid_color:
|
||||||
return
|
return
|
||||||
@@ -100,7 +103,7 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
def textColor(self) -> QColor:
|
def textColor(self) -> QColor:
|
||||||
return self._text_color
|
return self._text_color
|
||||||
|
|
||||||
@textColor.setter
|
@textColor.setter # type: ignore[no-redef]
|
||||||
def textColor(self, c: QColor) -> None:
|
def textColor(self, c: QColor) -> None:
|
||||||
if c == self._text_color:
|
if c == self._text_color:
|
||||||
return
|
return
|
||||||
@@ -112,7 +115,7 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
def padding(self) -> int:
|
def padding(self) -> int:
|
||||||
return self._padding
|
return self._padding
|
||||||
|
|
||||||
@padding.setter
|
@padding.setter # type: ignore[no-redef]
|
||||||
def padding(self, val: int) -> None:
|
def padding(self, val: int) -> None:
|
||||||
v = max(0, int(val))
|
v = max(0, int(val))
|
||||||
if v == self._padding:
|
if v == self._padding:
|
||||||
@@ -121,24 +124,48 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self.paddingChanged.emit()
|
self.paddingChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
# ── slots for QML: streamController.trendDelta / trendReset ────────────
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def setDataFromJson(self, avgs_json: str) -> None:
|
def appendDelta(self, delta_json: str) -> None:
|
||||||
"""Slot for QML Connections handler: parse JSON array and update data.
|
"""Append new (elapsed_s, value) points and prune the 60s window.
|
||||||
|
|
||||||
Mirrors `streamController.trendData` (which emits JSON-encoded floats).
|
Mirrors `streamController.trendDelta`, which emits one new pair per
|
||||||
|
live frame as `[[elapsed_s, value], ...]`.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(avgs_json) if avgs_json else []
|
parsed = json.loads(delta_json) if delta_json else []
|
||||||
except (json.JSONDecodeError, TypeError) as exc:
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
log.error("TrendChartItem: failed to parse trend JSON: %s", exc)
|
log.error("TrendChartItem: failed to parse trend delta JSON: %s", exc)
|
||||||
return
|
return
|
||||||
coerced = self._coerce_floats(parsed)
|
|
||||||
if coerced == self._data:
|
had_data = len(self._points) > 0
|
||||||
|
added = False
|
||||||
|
for pair in parsed:
|
||||||
|
try:
|
||||||
|
elapsed, value = float(pair[0]), float(pair[1])
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
self._points.append((elapsed, value))
|
||||||
|
added = True
|
||||||
|
|
||||||
|
if not added:
|
||||||
return
|
return
|
||||||
self._data = coerced
|
|
||||||
self.dataChanged.emit()
|
latest_elapsed = self._points[-1][0]
|
||||||
|
cutoff = latest_elapsed - WINDOW_SECONDS
|
||||||
|
self._points = [p for p in self._points if p[0] >= cutoff]
|
||||||
|
|
||||||
|
if not had_data:
|
||||||
|
self.hasDataChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def clearTrend(self) -> None:
|
||||||
|
"""Clear all buffered points (new stream started)."""
|
||||||
|
if not self._points:
|
||||||
|
return
|
||||||
|
self._points = []
|
||||||
self.hasDataChanged.emit()
|
self.hasDataChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -153,57 +180,39 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
if w <= 0 or h <= 0:
|
if w <= 0 or h <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
plot_rect = QRectF(self._padding, self._padding,
|
left = self._padding + self._margin_left
|
||||||
max(1, w - 2 * self._padding),
|
top = self._padding + self._margin_top
|
||||||
max(1, h - 2 * self._padding))
|
bottom_inset = self._padding + self._margin_bottom
|
||||||
|
plot_rect = QRectF(left, top,
|
||||||
|
max(1, w - left - self._padding),
|
||||||
|
max(1, h - top - bottom_inset))
|
||||||
|
|
||||||
self._draw_grid(painter, plot_rect)
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
if len(self._data) < 2:
|
if len(self._points) < 2:
|
||||||
return
|
return
|
||||||
|
|
||||||
y_min, y_max = self._y_range()
|
window_end = self._points[-1][0]
|
||||||
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
window_start = max(0.0, window_end - WINDOW_SECONDS)
|
||||||
self._draw_line(painter, plot_rect, y_min, y_max)
|
self._draw_axes_labels(painter, plot_rect, window_start, window_end)
|
||||||
|
self._draw_line(painter, plot_rect, window_start, window_end)
|
||||||
|
|
||||||
# ── internals ─────────────────────────────────────────────────────────
|
# ── internals ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _coerce_floats(self, val) -> list[float]:
|
def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
|
||||||
if not val:
|
return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
|
||||||
return []
|
|
||||||
out: list[float] = []
|
|
||||||
for v in val:
|
|
||||||
try:
|
|
||||||
out.append(float(v))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _y_range(self) -> tuple[float, float]:
|
def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
|
||||||
lo = min(self._data)
|
px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
|
||||||
hi = max(self._data)
|
# Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
|
||||||
if hi - lo < 1e-9:
|
return max(plot_rect.top(), min(plot_rect.bottom(), px))
|
||||||
return lo - 5.0, hi + 5.0
|
|
||||||
buf = (hi - lo) * 0.1
|
|
||||||
return lo - buf, hi + buf
|
|
||||||
|
|
||||||
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
|
||||||
if n <= 1:
|
|
||||||
return plot_rect.left()
|
|
||||||
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
|
||||||
|
|
||||||
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
|
||||||
if y_max - y_min < 1e-9:
|
|
||||||
return plot_rect.center().y()
|
|
||||||
t = (v - y_min) / (y_max - y_min)
|
|
||||||
return plot_rect.bottom() - t * plot_rect.height()
|
|
||||||
|
|
||||||
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||||
pen = QPen(self._grid_color)
|
pen = QPen(self._grid_color)
|
||||||
pen.setWidthF(1.0)
|
pen.setWidthF(1.0)
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
rows = 4
|
rows = len(Y_TICKS) - 1
|
||||||
for i in range(rows + 1):
|
for i in range(rows + 1):
|
||||||
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
||||||
painter.drawLine(int(plot_rect.left()), int(y),
|
painter.drawLine(int(plot_rect.left()), int(y),
|
||||||
@@ -213,31 +222,69 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
self,
|
self,
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
plot_rect: QRectF,
|
plot_rect: QRectF,
|
||||||
y_min: float,
|
window_start: float,
|
||||||
y_max: float,
|
window_end: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
painter.setPen(self._text_color)
|
painter.setPen(self._text_color)
|
||||||
font = QFont(painter.font())
|
font = QFont(painter.font())
|
||||||
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
rows = 4
|
|
||||||
for i in range(rows + 1):
|
title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
|
||||||
t = i / rows
|
label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
|
||||||
y_val = y_max - t * (y_max - y_min)
|
for tick in reversed(Y_TICKS):
|
||||||
y_px = plot_rect.top() + t * plot_rect.height()
|
y_px = self._y_to_px(tick, plot_rect)
|
||||||
label = f"{y_val:.1f}"
|
painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
|
||||||
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
|
|
||||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
label)
|
str(tick))
|
||||||
|
|
||||||
|
# X-axis: elapsed seconds within the current rolling window.
|
||||||
|
cols = 4
|
||||||
|
y_px = plot_rect.bottom() + 4
|
||||||
|
for i in range(cols + 1):
|
||||||
|
t_val = window_start + (i / cols) * (window_end - window_start)
|
||||||
|
x_px = self._x_to_px(t_val, window_start, window_end, plot_rect)
|
||||||
|
label = f"{t_val:.0f}"
|
||||||
|
if i == 0:
|
||||||
|
rect = QRectF(x_px, y_px, 40, 14)
|
||||||
|
align = Qt.AlignmentFlag.AlignLeft
|
||||||
|
elif i == cols:
|
||||||
|
# Right-anchor inside the plot so the label never clips off-widget
|
||||||
|
rect = QRectF(x_px - 40, y_px, 40, 14)
|
||||||
|
align = Qt.AlignmentFlag.AlignRight
|
||||||
|
else:
|
||||||
|
rect = QRectF(x_px - 20, y_px, 40, 14)
|
||||||
|
align = Qt.AlignmentFlag.AlignHCenter
|
||||||
|
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, label)
|
||||||
|
|
||||||
|
self._draw_axis_titles(painter, plot_rect, title_w, y_px + 18)
|
||||||
|
|
||||||
|
def _draw_axis_titles(
|
||||||
|
self,
|
||||||
|
painter: QPainter,
|
||||||
|
plot_rect: QRectF,
|
||||||
|
title_w: float,
|
||||||
|
x_label_bottom: float,
|
||||||
|
) -> None:
|
||||||
|
# Y axis: "Temp", rotated upright, centered along the plot's height.
|
||||||
|
painter.save()
|
||||||
|
painter.translate(title_w / 2, plot_rect.center().y())
|
||||||
|
painter.rotate(-90)
|
||||||
|
painter.drawText(QRectF(-plot_rect.height() / 2, -7, plot_rect.height(), 14),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, "Temp (°C)")
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
# X axis: "Time", centered under the elapsed-seconds tick row.
|
||||||
|
painter.drawText(QRectF(plot_rect.left(), x_label_bottom, plot_rect.width(), 14),
|
||||||
|
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, "Time")
|
||||||
|
|
||||||
def _draw_line(
|
def _draw_line(
|
||||||
self,
|
self,
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
plot_rect: QRectF,
|
plot_rect: QRectF,
|
||||||
y_min: float,
|
window_start: float,
|
||||||
y_max: float,
|
window_end: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
n = len(self._data)
|
|
||||||
pen = QPen(self._line_color)
|
pen = QPen(self._line_color)
|
||||||
pen.setWidthF(2.0)
|
pen.setWidthF(2.0)
|
||||||
pen.setCosmetic(True)
|
pen.setCosmetic(True)
|
||||||
@@ -246,15 +293,16 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
|
|
||||||
poly = QPolygon()
|
poly = QPolygon()
|
||||||
for i, v in enumerate(self._data):
|
for elapsed, value in self._points:
|
||||||
px = self._x_to_px(i, n, plot_rect)
|
px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
|
||||||
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
py = self._y_to_px(value, plot_rect)
|
||||||
poly.append(QPoint(int(px), int(py)))
|
poly.append(QPoint(int(px), int(py)))
|
||||||
painter.drawPolyline(poly)
|
painter.drawPolyline(poly)
|
||||||
|
|
||||||
# Trailing dot at the last data point
|
# Trailing dot at the last data point
|
||||||
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
last_elapsed, last_value = self._points[-1]
|
||||||
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
last_x = int(self._x_to_px(last_elapsed, window_start, window_end, plot_rect))
|
||||||
|
last_y = int(self._y_to_px(last_value, plot_rect))
|
||||||
painter.setBrush(self._line_color)
|
painter.setBrush(self._line_color)
|
||||||
painter.setPen(Qt.PenStyle.NoPen)
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
radius = 3
|
radius = 3
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logging
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot
|
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QBrush,
|
QBrush,
|
||||||
QColor,
|
QColor,
|
||||||
@@ -27,11 +27,69 @@ from PySide6.QtGui import (
|
|||||||
from PySide6.QtQml import QmlElement
|
from PySide6.QtQml import QmlElement
|
||||||
from PySide6.QtQuick import QQuickPaintedItem
|
from PySide6.QtQuick import QQuickPaintedItem
|
||||||
|
|
||||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field
|
from pygui.backend.models.frame_stats import compute_stats
|
||||||
|
from pygui.backend.visualization.rbf_heatmap import (
|
||||||
|
ellipse_alpha,
|
||||||
|
interpolate_field,
|
||||||
|
refine_field,
|
||||||
|
)
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
|
||||||
|
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
|
||||||
|
|
||||||
|
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
|
||||||
|
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
|
||||||
|
Returns (points, error) — error is "" on success.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
|
||||||
|
points: list[list[float]] = []
|
||||||
|
slot: str | None = None
|
||||||
|
try:
|
||||||
|
with open(file_path, newline="") as fh:
|
||||||
|
reader = csv.reader(fh)
|
||||||
|
header = next(reader, None)
|
||||||
|
if header is None:
|
||||||
|
return [], "Unable to read header row from CSV file."
|
||||||
|
if header and header[-1] == "": # trailing comma
|
||||||
|
header = header[:-1]
|
||||||
|
nfields = len(header)
|
||||||
|
if nfields != 8:
|
||||||
|
return [], "Incorrect field count in CSV header row."
|
||||||
|
for lno, row in enumerate(reader, start=2):
|
||||||
|
if row and row[-1] == "":
|
||||||
|
row = row[:-1]
|
||||||
|
if len(row) != nfields:
|
||||||
|
return [], f"Incorrect field count in CSV row {lno}"
|
||||||
|
if slot is None:
|
||||||
|
slot = row[3]
|
||||||
|
elif row[3] != slot:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
t2, x, y = float(row[4]), float(row[6]), float(row[7])
|
||||||
|
except ValueError:
|
||||||
|
return [], f"Malformed value in CSV row {lno}"
|
||||||
|
points.append([x, y, t2])
|
||||||
|
except OSError as exc:
|
||||||
|
return [], f"Unable to read CSV file: {exc}"
|
||||||
|
if not points:
|
||||||
|
return [], "No data found in CSV file."
|
||||||
|
return points, ""
|
||||||
|
|
||||||
|
|
||||||
|
def readout_text(values: list[float]) -> str:
|
||||||
|
"""Full READOUT stats line for the export footer; '' when no data."""
|
||||||
|
if not values:
|
||||||
|
return ""
|
||||||
|
s = compute_stats(values)
|
||||||
|
return (f"Sensors: {len(values)} Min: {s.min:.2f}°C (#{s.min_index + 1}) "
|
||||||
|
f"Max: {s.max:.2f}°C (#{s.max_index + 1}) Diff: {s.diff:.2f} "
|
||||||
|
f"Avg: {s.avg:.2f} σ: {s.sigma:.2f} 3σ: {s.three_sigma:.2f}")
|
||||||
|
|
||||||
QML_IMPORT_NAME = "ISC.Wafer"
|
QML_IMPORT_NAME = "ISC.Wafer"
|
||||||
QML_IMPORT_MAJOR_VERSION = 1
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
@@ -47,11 +105,14 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
marginChanged = Signal()
|
marginChanged = Signal()
|
||||||
blendChanged = Signal()
|
blendChanged = Signal()
|
||||||
showLabelsChanged = Signal()
|
showLabelsChanged = Signal()
|
||||||
|
showExtremesChanged = Signal()
|
||||||
colorsChanged = Signal()
|
colorsChanged = Signal()
|
||||||
shapeChanged = Signal()
|
shapeChanged = Signal()
|
||||||
sizeChanged = Signal()
|
sizeChanged = Signal()
|
||||||
thicknessChanged = Signal()
|
thicknessChanged = Signal()
|
||||||
showThicknessChanged = Signal()
|
showThicknessChanged = Signal()
|
||||||
|
hoveredIndexChanged = Signal()
|
||||||
|
hoverScaleChanged = Signal()
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
@@ -63,11 +124,16 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._margin: float = 1.0
|
self._margin: float = 1.0
|
||||||
self._blend: float = 0.0
|
self._blend: float = 0.0
|
||||||
self._show_labels: bool = True
|
self._show_labels: bool = True
|
||||||
|
self._show_extremes: bool = True
|
||||||
self._shape: str = "round"
|
self._shape: str = "round"
|
||||||
self._size: float = 300.0
|
self._size: float = 300.0
|
||||||
self._thickness_data: list[float] = []
|
self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
|
||||||
self._show_thickness: bool = False
|
self._show_thickness: bool = False
|
||||||
self._thickness_heatmap:QImage | None = None
|
self._thickness_heatmap:QImage | None = None
|
||||||
|
# Hover highlight: index of the marker under the pointer (-1 = none)
|
||||||
|
# and a 1.0->~1.5 grow factor driven by a QML Behavior for a smooth animation.
|
||||||
|
self._hovered_index: int = -1
|
||||||
|
self._hover_scale: float = 1.0
|
||||||
|
|
||||||
# Dark-theme color defaults (match Theme.qml tokens)
|
# Dark-theme color defaults (match Theme.qml tokens)
|
||||||
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
||||||
@@ -81,6 +147,12 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
|
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
|
||||||
self._marker_r: int = 4
|
self._marker_r: int = 4
|
||||||
self._heatmap: QImage | None = None
|
self._heatmap: QImage | None = None
|
||||||
|
# Size the markers/heatmap were last computed against. On first activation
|
||||||
|
# (e.g. a freshly-created Loader item) width()/height() can still be 0/stale
|
||||||
|
# when `sensors` is first set, computing degenerate marker positions that
|
||||||
|
# never get retried. paint() re-checks this every frame so geometry that
|
||||||
|
# settles after property assignment still gets picked up.
|
||||||
|
self._last_draw_size: int = -1
|
||||||
|
|
||||||
self.widthChanged.connect(self._on_resize)
|
self.widthChanged.connect(self._on_resize)
|
||||||
self.heightChanged.connect(self._on_resize)
|
self.heightChanged.connect(self._on_resize)
|
||||||
@@ -91,7 +163,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def sensors(self) -> list:
|
def sensors(self) -> list:
|
||||||
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
|
||||||
|
|
||||||
@sensors.setter
|
@sensors.setter # type: ignore[no-redef]
|
||||||
def sensors(self, val: list) -> None:
|
def sensors(self, val: list) -> None:
|
||||||
self._sensors = [
|
self._sensors = [
|
||||||
Sensor(
|
Sensor(
|
||||||
@@ -111,7 +183,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def values(self) -> list:
|
def values(self) -> list:
|
||||||
return self._values
|
return self._values
|
||||||
|
|
||||||
@values.setter
|
@values.setter # type: ignore[no-redef]
|
||||||
def values(self, val: list) -> None:
|
def values(self, val: list) -> None:
|
||||||
self._values = list(val or [])
|
self._values = list(val or [])
|
||||||
self._rebuild_heatmap()
|
self._rebuild_heatmap()
|
||||||
@@ -122,7 +194,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def bands(self) -> list:
|
def bands(self) -> list:
|
||||||
return self._bands
|
return self._bands
|
||||||
|
|
||||||
@bands.setter
|
@bands.setter # type: ignore[no-redef]
|
||||||
def bands(self, val: list) -> None:
|
def bands(self, val: list) -> None:
|
||||||
self._bands = list(val or [])
|
self._bands = list(val or [])
|
||||||
self.bandsChanged.emit()
|
self.bandsChanged.emit()
|
||||||
@@ -132,7 +204,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def target(self) -> float:
|
def target(self) -> float:
|
||||||
return self._target
|
return self._target
|
||||||
|
|
||||||
@target.setter
|
@target.setter # type: ignore[no-redef]
|
||||||
def target(self, val: float) -> None:
|
def target(self, val: float) -> None:
|
||||||
self._target = float(val)
|
self._target = float(val)
|
||||||
self._rebuild_heatmap()
|
self._rebuild_heatmap()
|
||||||
@@ -143,7 +215,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def margin(self) -> float:
|
def margin(self) -> float:
|
||||||
return self._margin
|
return self._margin
|
||||||
|
|
||||||
@margin.setter
|
@margin.setter # type: ignore[no-redef]
|
||||||
def margin(self, val: float) -> None:
|
def margin(self, val: float) -> None:
|
||||||
self._margin = float(val)
|
self._margin = float(val)
|
||||||
self._rebuild_heatmap()
|
self._rebuild_heatmap()
|
||||||
@@ -154,7 +226,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def blend(self) -> float:
|
def blend(self) -> float:
|
||||||
return self._blend
|
return self._blend
|
||||||
|
|
||||||
@blend.setter
|
@blend.setter # type: ignore[no-redef]
|
||||||
def blend(self, val: float) -> None:
|
def blend(self, val: float) -> None:
|
||||||
self._blend = max(0.0, min(1.0, float(val)))
|
self._blend = max(0.0, min(1.0, float(val)))
|
||||||
if self._blend > 0 and self._heatmap is None:
|
if self._blend > 0 and self._heatmap is None:
|
||||||
@@ -166,17 +238,27 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def showLabels(self) -> bool:
|
def showLabels(self) -> bool:
|
||||||
return self._show_labels
|
return self._show_labels
|
||||||
|
|
||||||
@showLabels.setter
|
@showLabels.setter # type: ignore[no-redef]
|
||||||
def showLabels(self, val: bool) -> None:
|
def showLabels(self, val: bool) -> None:
|
||||||
self._show_labels = bool(val)
|
self._show_labels = bool(val)
|
||||||
self.showLabelsChanged.emit()
|
self.showLabelsChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=showExtremesChanged)
|
||||||
|
def showExtremes(self) -> bool:
|
||||||
|
return self._show_extremes
|
||||||
|
|
||||||
|
@showExtremes.setter # type: ignore[no-redef]
|
||||||
|
def showExtremes(self, val: bool) -> None:
|
||||||
|
self._show_extremes = bool(val)
|
||||||
|
self.showExtremesChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
@Property(str, notify=shapeChanged)
|
@Property(str, notify=shapeChanged)
|
||||||
def shape(self) -> str:
|
def shape(self) -> str:
|
||||||
return self._shape
|
return self._shape
|
||||||
|
|
||||||
@shape.setter
|
@shape.setter # type: ignore[no-redef]
|
||||||
def shape(self, val: str) -> None:
|
def shape(self, val: str) -> None:
|
||||||
self._shape = str(val).lower()
|
self._shape = str(val).lower()
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
@@ -186,7 +268,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
def size(self) -> float:
|
def size(self) -> float:
|
||||||
return self._size
|
return self._size
|
||||||
|
|
||||||
@size.setter
|
@size.setter # type: ignore[no-redef]
|
||||||
def size(self, val: float) -> None:
|
def size(self, val: float) -> None:
|
||||||
self._size = float(val)
|
self._size = float(val)
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
@@ -194,54 +276,95 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
@Property("QVariantList", notify=thicknessChanged)
|
@Property("QVariantList", notify=thicknessChanged)
|
||||||
def thicknessData(self) -> list:
|
def thicknessData(self) -> list:
|
||||||
|
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
|
||||||
return self._thickness_data
|
return self._thickness_data
|
||||||
|
|
||||||
@thicknessData.setter
|
@thicknessData.setter # type: ignore[no-redef]
|
||||||
def thicknessData(self, val:list) -> None:
|
def thicknessData(self, val:list) -> None:
|
||||||
self._thickness_data = list(val or [])
|
self._thickness_data = [list(p) for p in (val or [])]
|
||||||
self._rebuild_thickness()
|
self._rebuild_thickness()
|
||||||
self.thicknessChanged.emit()
|
self.thicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(bool, notify=thicknessChanged)
|
||||||
|
def hasThickness(self) -> bool:
|
||||||
|
return bool(self._thickness_data)
|
||||||
|
|
||||||
|
@Slot(str, result=str)
|
||||||
|
def loadThickness(self, file_path: str) -> str:
|
||||||
|
"""Load a customer thickness CSV; returns error message, '' on success."""
|
||||||
|
points, err = parse_thickness_csv(file_path)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
self._thickness_data = points
|
||||||
|
self._rebuild_thickness()
|
||||||
|
self.thicknessChanged.emit()
|
||||||
|
self.update()
|
||||||
|
return ""
|
||||||
self.update
|
self.update
|
||||||
|
|
||||||
@Property(bool, notify=showThicknessChanged)
|
@Property(bool, notify=showThicknessChanged)
|
||||||
def showThickness(self) -> bool:
|
def showThickness(self) -> bool:
|
||||||
return self._show_thickness
|
return self._show_thickness
|
||||||
|
|
||||||
@showThickness.setter
|
@showThickness.setter # type: ignore[no-redef]
|
||||||
def showThickness(self, val: bool) -> None:
|
def showThickness(self, val: bool) -> None:
|
||||||
self._show_thickness = bool(val)
|
self._show_thickness = bool(val)
|
||||||
self.showThicknessChanged.emit()
|
self.showThicknessChanged.emit()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
@Property(int, notify=hoveredIndexChanged)
|
||||||
|
def hoveredIndex(self) -> int:
|
||||||
|
return self._hovered_index
|
||||||
|
|
||||||
|
@hoveredIndex.setter # type: ignore[no-redef]
|
||||||
|
def hoveredIndex(self, val: int) -> None:
|
||||||
|
val = int(val)
|
||||||
|
if val == self._hovered_index:
|
||||||
|
return
|
||||||
|
self._hovered_index = val
|
||||||
|
self.hoveredIndexChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@Property(float, notify=hoverScaleChanged)
|
||||||
|
def hoverScale(self) -> float:
|
||||||
|
return self._hover_scale
|
||||||
|
|
||||||
|
@hoverScale.setter # type: ignore[no-redef]
|
||||||
|
def hoverScale(self, val: float) -> None:
|
||||||
|
self._hover_scale = float(val)
|
||||||
|
self.hoverScaleChanged.emit()
|
||||||
|
self.update()
|
||||||
|
|
||||||
# Colour properties — QML can bind these to Theme tokens
|
# Colour properties — QML can bind these to Theme tokens
|
||||||
@Property(QColor, notify=colorsChanged)
|
@Property(QColor, notify=colorsChanged)
|
||||||
def ringColor(self) -> QColor: return self._ring_color
|
def ringColor(self) -> QColor: return self._ring_color
|
||||||
@ringColor.setter
|
@ringColor.setter # type: ignore[no-redef]
|
||||||
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
|
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
|
||||||
|
|
||||||
@Property(QColor, notify=colorsChanged)
|
@Property(QColor, notify=colorsChanged)
|
||||||
def axisColor(self) -> QColor: return self._axis_color
|
def axisColor(self) -> QColor: return self._axis_color
|
||||||
@axisColor.setter
|
@axisColor.setter # type: ignore[no-redef]
|
||||||
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
|
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
|
||||||
|
|
||||||
@Property(QColor, notify=colorsChanged)
|
@Property(QColor, notify=colorsChanged)
|
||||||
def lowColor(self) -> QColor: return self._low_color
|
def lowColor(self) -> QColor: return self._low_color
|
||||||
@lowColor.setter
|
@lowColor.setter # type: ignore[no-redef]
|
||||||
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
|
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
|
||||||
|
|
||||||
@Property(QColor, notify=colorsChanged)
|
@Property(QColor, notify=colorsChanged)
|
||||||
def inRangeColor(self) -> QColor: return self._in_range_color
|
def inRangeColor(self) -> QColor: return self._in_range_color
|
||||||
@inRangeColor.setter
|
@inRangeColor.setter # type: ignore[no-redef]
|
||||||
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
|
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
|
||||||
|
|
||||||
@Property(QColor, notify=colorsChanged)
|
@Property(QColor, notify=colorsChanged)
|
||||||
def highColor(self) -> QColor: return self._high_color
|
def highColor(self) -> QColor: return self._high_color
|
||||||
@highColor.setter
|
@highColor.setter # type: ignore[no-redef]
|
||||||
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
|
def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
|
||||||
|
|
||||||
@Property(QColor, notify=colorsChanged)
|
@Property(QColor, notify=colorsChanged)
|
||||||
def textColor(self) -> QColor: return self._text_color
|
def textColor(self) -> QColor: return self._text_color
|
||||||
@textColor.setter
|
@textColor.setter # type: ignore[no-redef]
|
||||||
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
|
def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
|
||||||
|
|
||||||
# ── slots ─────────────────────────────────────────────────────────────
|
# ── slots ─────────────────────────────────────────────────────────────
|
||||||
@@ -255,70 +378,125 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
return idx
|
return idx
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
# TODO P6.1: build batch export on top of this single-frame export
|
||||||
|
# THINKING: export_image() already does the hard part (grabToImage → PNG);
|
||||||
|
# batch export is just a loop over file_browser.files that loads each CSV
|
||||||
|
# through the existing wafer-map pipeline and calls this per file into a
|
||||||
|
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
|
||||||
|
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
|
||||||
@Slot(str, result=bool)
|
@Slot(str, result=bool)
|
||||||
def export_image(self, file_path: str) -> bool:
|
@Slot(str, str, result=bool)
|
||||||
|
def export_image(self, file_path: str, extra: str = "") -> bool:
|
||||||
"""Export the current wafer map rendering to a PNG file
|
"""Export the current wafer map rendering to a PNG file
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_pat: Absolute path for the output PNG.
|
file_path: Absolute path for the output PNG.
|
||||||
|
extra: Optional second footer line (live stream metrics).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True on success, False on failure
|
True on success, False on failure
|
||||||
"""
|
"""
|
||||||
if not file_path:
|
if not file_path or not self._values:
|
||||||
|
# No file loaded / no stream played yet — only the empty template
|
||||||
|
# would render, which isn't a meaningful export.
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
result = self.grabToImage()
|
# Render synchronously via paint() into our own image —
|
||||||
img = result.image()
|
# grabToImage() resolves on a later render frame, so its result
|
||||||
img.save(file_path, "PNG")
|
# is null when read immediately (the old silent failure).
|
||||||
return True
|
w, h = int(self.width()), int(self.height())
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return False
|
||||||
|
lines = [ln for ln in (readout_text(self._values), extra) if ln]
|
||||||
|
footer_h = 28 * len(lines)
|
||||||
|
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
|
||||||
|
img.fill(0) # transparent background
|
||||||
|
painter = QPainter(img)
|
||||||
|
self.paint(painter)
|
||||||
|
if lines:
|
||||||
|
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
|
||||||
|
painter.setPen(QColor("#CBD5E1"))
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
painter.drawText(QRectF(0, h + 28 * i, w, 28),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, line)
|
||||||
|
painter.end()
|
||||||
|
return bool(img.save(file_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Export failed: %s", e)
|
log.error("Export failed: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── internal ─────────────────────────────────────────────────────────
|
# ── internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _paint_extremes(self, painter: QPainter) -> None:
|
||||||
|
"""Ring the hottest (high color) and coldest (low color) markers."""
|
||||||
|
if not self._show_extremes or not self._values or not self._markers:
|
||||||
|
return
|
||||||
|
s = compute_stats(self._values)
|
||||||
|
ring_r = self._marker_r + 5
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
for idx, color in ((s.max_index, self._high_color),
|
||||||
|
(s.min_index, self._low_color)):
|
||||||
|
if idx not in self._markers:
|
||||||
|
continue
|
||||||
|
px, py = self._markers[idx]
|
||||||
|
painter.setPen(QPen(color, 2))
|
||||||
|
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
|
||||||
|
|
||||||
def _rebuild_thickness(self) -> None:
|
def _rebuild_thickness(self) -> None:
|
||||||
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
"""Interpolate thickness points into a gray/orange heatmap QImage."""
|
||||||
if not self._sensors or not self._thickness_data:
|
if not self._thickness_data:
|
||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
r_mm = self._wafer_radius_mm()
|
r_mm = self._wafer_radius_mm()
|
||||||
xs = np.array([s.x for s in self._sensors])
|
pts = np.array(self._thickness_data, dtype=float)
|
||||||
ys = np.array([s.y for s in self._sensors])
|
xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
|
||||||
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
|
||||||
if len(vs) < len(self._sensors):
|
|
||||||
self._thickness_heatmap = None
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
|
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
||||||
field = interpolate_field(
|
field = interpolate_field(
|
||||||
xs, ys, vs,
|
xs, ys, vs,
|
||||||
width=ds, height=ds,
|
width=ds, height=ds,
|
||||||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
round_clip=(self._shape == "round"),
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._thickness_heatmap = None
|
self._thickness_heatmap = None
|
||||||
return
|
return
|
||||||
# Gray/orange colormap: map field range 0→1 to gray→orange
|
field = refine_field(field)
|
||||||
vmin, vmax = np.nanmin(field), np.nanmax(field)
|
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||||||
|
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||||||
|
# Clamp to the real data range so color only reflects measured values.
|
||||||
|
field = np.clip(field, vs.min(), vs.max())
|
||||||
|
if self._shape == "round":
|
||||||
|
alpha = ellipse_alpha(*field.shape)
|
||||||
|
else:
|
||||||
|
alpha = np.ones(field.shape)
|
||||||
|
# Color-range bounds from inside the wafer only — the unclipped field
|
||||||
|
# extrapolates wildly toward the square corners, which alpha hides but
|
||||||
|
# a corner-driven vmin/vmax would flatten the visible gradient.
|
||||||
|
inside = field[alpha >= 0.5]
|
||||||
|
vmin, vmax = inside.min(), inside.max()
|
||||||
span = vmax - vmin or 1.0
|
span = vmax - vmin or 1.0
|
||||||
|
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||||||
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
t = np.clip((field - vmin) / span, 0.0, 1.0)
|
||||||
|
|
||||||
|
gh, gw = field.shape
|
||||||
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
|
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
|
||||||
rgb = np.zeros((ds, ds, 3), dtype=np.float32)
|
rgb = np.zeros((gh, gw, 3), dtype=np.float32)
|
||||||
rgb[:, :, 0] = 0.5 + 0.5 * t
|
rgb[:, :, 0] = 0.5 + 0.5 * t
|
||||||
rgb[:, :, 1] = 0.5 + 0.15 * t
|
rgb[:, :, 1] = 0.5 + 0.15 * t
|
||||||
rgb[:, :, 2] = 0.5 - 0.5 * t
|
rgb[:, :, 2] = 0.5 - 0.5 * t
|
||||||
|
|
||||||
rgb = np.nan_to_num(rgb, nan=0.0)
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
|
||||||
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8)
|
rgba[:, :, 3] = (alpha * 180).astype(np.uint8)
|
||||||
|
|
||||||
self._thickness_heatmap = (
|
grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
|
||||||
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
self._thickness_heatmap = grid_img.scaled(
|
||||||
|
ds, ds,
|
||||||
|
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_resize(self) -> None:
|
def _on_resize(self) -> None:
|
||||||
@@ -342,6 +520,13 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
r = max(math.hypot(s.x, s.y) for s in self._sensors)
|
r = max(math.hypot(s.x, s.y) for s in self._sensors)
|
||||||
return r * 1.05
|
return r * 1.05
|
||||||
|
|
||||||
|
# TODO P6.2: reuse this for the edge-to-center delta metric
|
||||||
|
# THINKING: radial_metrics.py (new) needs the same "which sensors are near
|
||||||
|
# the edge vs. near the center" bucketing this function already computes
|
||||||
|
# for ring-line drawing — do NOT re-derive ring boundaries separately, this
|
||||||
|
# already handles round vs. square wafer shapes correctly (see family_spec.py
|
||||||
|
# square-wafer work). Outermost group ≈ edge sensors, innermost ≈ center.
|
||||||
|
# See docs/pending/alpha-release-polish-plan.md §6.2.
|
||||||
def _sensor_ring_radii_mm(self) -> list[float]:
|
def _sensor_ring_radii_mm(self) -> list[float]:
|
||||||
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||||
if not self._sensors:
|
if not self._sensors:
|
||||||
@@ -391,19 +576,34 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._heatmap = None
|
self._heatmap = None
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
# No round_clip: refine the smooth unclipped field, then mask with an
|
||||||
|
# analytic anti-aliased ellipse (a coarse-grid clip mask upscales jagged).
|
||||||
field = interpolate_field(
|
field = interpolate_field(
|
||||||
xs, ys, vs,
|
xs, ys, vs,
|
||||||
width=ds, height=ds,
|
width=ds, height=ds,
|
||||||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||||
round_clip=(self._shape == "round"),
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._heatmap = None
|
self._heatmap = None
|
||||||
return
|
return
|
||||||
self._heatmap = self._field_to_qimage(field, ds)
|
field = refine_field(field)
|
||||||
|
# ponytail: RBF overshoots around outlier sensors, inventing values no
|
||||||
|
# sensor produced (e.g. negative diffs when all diffs are positive).
|
||||||
|
# Clamp to the real data range so color only reflects measured values.
|
||||||
|
field = np.clip(field, vs.min(), vs.max())
|
||||||
|
if self._shape == "round":
|
||||||
|
alpha = ellipse_alpha(*field.shape)
|
||||||
|
else:
|
||||||
|
alpha = np.ones(field.shape)
|
||||||
|
grid_img = self._field_to_qimage(field, alpha)
|
||||||
|
self._heatmap = grid_img.scaled(
|
||||||
|
ds, ds,
|
||||||
|
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||||
|
Qt.TransformationMode.SmoothTransformation,
|
||||||
|
)
|
||||||
|
|
||||||
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage:
|
def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
|
||||||
"""Apply a band-aware tri-color gradient → RGBA QImage."""
|
"""Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
|
||||||
lo_b = self._target - self._margin
|
lo_b = self._target - self._margin
|
||||||
hi_b = self._target + self._margin
|
hi_b = self._target + self._margin
|
||||||
span = hi_b - lo_b or 1.0
|
span = hi_b - lo_b or 1.0
|
||||||
@@ -428,19 +628,28 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
lo_c * (1 - t_lo) + mid_c * t_lo,
|
lo_c * (1 - t_lo) + mid_c * t_lo,
|
||||||
mid_c * (1 - t_hi) + hi_c * t_hi,
|
mid_c * (1 - t_hi) + hi_c * t_hi,
|
||||||
)
|
)
|
||||||
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
|
|
||||||
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
|
|
||||||
rgb = np.nan_to_num(rgb, nan=0.0)
|
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||||
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
gh, gw = field.shape
|
||||||
|
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
|
||||||
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
|
||||||
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8)
|
# alpha is a continuous 0..1 coverage mask (not a hard isfinite cutoff) so the
|
||||||
|
# round-clip boundary anti-aliases instead of showing coarse-grid steps.
|
||||||
|
rgba[:, :, 3] = (alpha * 210).astype(np.uint8)
|
||||||
|
|
||||||
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
return QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
|
||||||
|
|
||||||
# ── paint ─────────────────────────────────────────────────────────────
|
# ── paint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def paint(self, painter: QPainter) -> None:
|
def paint(self, painter: QPainter) -> None:
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
|
if ds != self._last_draw_size:
|
||||||
|
# Geometry settled (or changed) since markers/heatmap were last built
|
||||||
|
# against a stale size — recompute now, at actual paint time.
|
||||||
|
self._last_draw_size = ds
|
||||||
|
self._compute_markers()
|
||||||
|
self._rebuild_heatmap()
|
||||||
|
self._rebuild_thickness()
|
||||||
|
|
||||||
r_px = int(ds / 2 - 4)
|
r_px = int(ds / 2 - 4)
|
||||||
cx, cy = self._center()
|
cx, cy = self._center()
|
||||||
|
|
||||||
@@ -461,6 +670,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setOpacity(1.0)
|
painter.setOpacity(1.0)
|
||||||
|
|
||||||
self._paint_markers(painter)
|
self._paint_markers(painter)
|
||||||
|
self._paint_extremes(painter)
|
||||||
|
|
||||||
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||||
ds = self._draw_size()
|
ds = self._draw_size()
|
||||||
@@ -524,11 +734,11 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
font_scale = 0.85
|
font_scale = 0.85
|
||||||
|
|
||||||
id_font = QFont()
|
id_font = QFont()
|
||||||
id_font.setPointSize(max(4, int(r * font_scale)))
|
id_font.setPointSize(max(9, int(r * font_scale)))
|
||||||
id_font.setBold(True)
|
id_font.setBold(True)
|
||||||
|
|
||||||
temp_font = QFont()
|
temp_font = QFont()
|
||||||
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
|
temp_font.setPointSize(max(9, int(r * font_scale)))
|
||||||
|
|
||||||
band_color = {
|
band_color = {
|
||||||
"in_range": self._in_range_color,
|
"in_range": self._in_range_color,
|
||||||
@@ -536,6 +746,16 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
"low": self._low_color,
|
"low": self._low_color,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
painter.setFont(id_font)
|
||||||
|
id_fm = painter.fontMetrics()
|
||||||
|
id_line_h = id_fm.height()
|
||||||
|
id_ascent = id_fm.ascent()
|
||||||
|
|
||||||
|
painter.setFont(temp_font)
|
||||||
|
temp_fm = painter.fontMetrics()
|
||||||
|
temp_line_h = temp_fm.height()
|
||||||
|
temp_ascent = temp_fm.ascent()
|
||||||
|
|
||||||
for i, s in enumerate(self._sensors):
|
for i, s in enumerate(self._sensors):
|
||||||
if i not in self._markers:
|
if i not in self._markers:
|
||||||
continue
|
continue
|
||||||
@@ -544,10 +764,22 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._bands[i] if i < len(self._bands) else "in_range",
|
self._bands[i] if i < len(self._bands) else "in_range",
|
||||||
self._in_range_color,
|
self._in_range_color,
|
||||||
)
|
)
|
||||||
|
is_hovered = i == self._hovered_index
|
||||||
|
marker_r = int(round(r * self._hover_scale)) if is_hovered else r
|
||||||
|
|
||||||
|
if is_hovered:
|
||||||
|
# Soft glow ring behind the marker, growing with hoverScale.
|
||||||
|
glow_r = int(round(marker_r * 1.9))
|
||||||
|
glow_color = QColor(color)
|
||||||
|
glow_color.setAlpha(70)
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.setBrush(QBrush(glow_color))
|
||||||
|
painter.drawEllipse(px - glow_r, py - glow_r, 2 * glow_r, 2 * glow_r)
|
||||||
|
|
||||||
# Filled circle with thin dark outline for contrast over heatmap
|
# Filled circle with thin dark outline for contrast over heatmap
|
||||||
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
|
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
|
||||||
painter.setBrush(QBrush(color))
|
painter.setBrush(QBrush(color))
|
||||||
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
|
painter.drawEllipse(px - marker_r, py - marker_r, 2 * marker_r, 2 * marker_r)
|
||||||
|
|
||||||
if self._show_labels:
|
if self._show_labels:
|
||||||
has_temp = i < len(self._values)
|
has_temp = i < len(self._values)
|
||||||
@@ -557,17 +789,6 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
ox = getattr(s, "offset_x", 0.0) * r
|
ox = getattr(s, "offset_x", 0.0) * r
|
||||||
oy = getattr(s, "offset_y", 0.0) * r
|
oy = getattr(s, "offset_y", 0.0) * r
|
||||||
|
|
||||||
# Pre-compute metrics using current scaled fonts
|
|
||||||
painter.setFont(id_font)
|
|
||||||
id_fm = painter.fontMetrics()
|
|
||||||
id_line_h = id_fm.height()
|
|
||||||
id_ascent = id_fm.ascent()
|
|
||||||
|
|
||||||
painter.setFont(temp_font)
|
|
||||||
temp_fm = painter.fontMetrics()
|
|
||||||
temp_line_h = temp_fm.height()
|
|
||||||
temp_ascent = temp_fm.ascent()
|
|
||||||
|
|
||||||
id_text = s.label
|
id_text = s.label
|
||||||
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
||||||
|
|
||||||
@@ -598,11 +819,11 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setPen(QPen(self._text_color))
|
painter.setPen(QPen(self._text_color))
|
||||||
y1 = ly + id_ascent
|
y1 = ly + id_ascent
|
||||||
if side in ("top", "bottom"):
|
if side in ("top", "bottom"):
|
||||||
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)
|
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) # type: ignore[arg-type]
|
||||||
elif side == "left":
|
elif side == "left":
|
||||||
painter.drawText(lx + (text_w - id_w), y1, id_text)
|
painter.drawText(lx + (text_w - id_w), y1, id_text) # type: ignore[arg-type]
|
||||||
else:
|
else:
|
||||||
painter.drawText(lx, y1, id_text)
|
painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
|
||||||
|
|
||||||
# Draw Temperature (second line)
|
# Draw Temperature (second line)
|
||||||
if has_temp:
|
if has_temp:
|
||||||
@@ -610,8 +831,8 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
painter.setPen(QPen(color))
|
painter.setPen(QPen(color))
|
||||||
y2 = ly + id_line_h + temp_ascent
|
y2 = ly + id_line_h + temp_ascent
|
||||||
if side in ("top", "bottom"):
|
if side in ("top", "bottom"):
|
||||||
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)
|
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) # type: ignore[arg-type]
|
||||||
elif side == "left":
|
elif side == "left":
|
||||||
painter.drawText(lx + (text_w - temp_w), y2, temp_text)
|
painter.drawText(lx + (text_w - temp_w), y2, temp_text) # type: ignore[arg-type]
|
||||||
else:
|
else:
|
||||||
painter.drawText(lx, y2, temp_text)
|
painter.drawText(lx, y2, temp_text) # type: ignore[arg-type]
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# ===== Wafer Sub-package =====
|
|
||||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
|
||||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ZWaferData", "Sensor", "DataRecord",
|
|
||||||
"ZWaferParser",
|
|
||||||
"load_layout", "available_families",
|
|
||||||
"sensor_count_for",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -66,9 +66,39 @@ def _family_name(raw_name: str) -> str:
|
|||||||
|
|
||||||
def _load_yaml(path: Path) -> dict:
|
def _load_yaml(path: Path) -> dict:
|
||||||
with path.open(encoding="utf-8") as f:
|
with path.open(encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f)
|
loaded: dict = yaml.safe_load(f)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
# Edge→center sensor pair tables, ported verbatim from the C# original
|
||||||
|
# (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
|
||||||
|
# ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
|
||||||
|
_EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
|
||||||
|
"AEP": tuple((e, 40 + e // 2) for e in range(16)),
|
||||||
|
"BCD": tuple((e, 28) for e in range(12)),
|
||||||
|
"F": ((0, 18), (1, 19), (2, 19), (3, 19), (4, 20), (5, 20),
|
||||||
|
(6, 20), (7, 21), (8, 21), (9, 21), (10, 18), (11, 18)),
|
||||||
|
"X": ((5, 76), (0, 76), (39, 76), (6, 77), (11, 77), (16, 77),
|
||||||
|
(17, 78), (22, 78), (27, 78), (28, 79), (33, 79), (38, 79)),
|
||||||
|
"Z": tuple((e, 0) for e in range(49, 65)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ec_pairs_for_wafer_id(wafer_id: str) -> tuple[tuple[int, int], ...]:
|
||||||
|
"""Edge-center pairs for a wafer id's family letter; empty if unknown.
|
||||||
|
|
||||||
|
Unknown families deliberately get no pairs (C# fell through to Z).
|
||||||
|
"""
|
||||||
|
prefix = wafer_id[0].upper() if wafer_id else ""
|
||||||
|
for families, key in (("AEP", "AEP"), ("BCD", "BCD"), ("F", "F"),
|
||||||
|
("X", "X"), ("Z", "Z")):
|
||||||
|
if prefix and prefix in families:
|
||||||
|
return _EC_MAPS[key]
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
# P6.3 (LayoutSelector) dropped 2026-07-10 — the C# "layout" buttons only
|
||||||
|
# exported coordinate spreadsheets, decided obsolete. See MIGRATION.md.
|
||||||
def available_families() -> list[str]:
|
def available_families() -> list[str]:
|
||||||
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class ZWaferParser:
|
|||||||
y_coords: Optional[list],
|
y_coords: Optional[list],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Build sensor list from layout arrays."""
|
"""Build sensor list from layout arrays."""
|
||||||
if not all([labels, x_coords, y_coords]):
|
if not (labels and x_coords and y_coords):
|
||||||
raise ValueError("Sensor layout section is incomplete or missing.")
|
raise ValueError("Sensor layout section is incomplete or missing.")
|
||||||
|
|
||||||
if len(labels) != len(x_coords) or len(labels) != len(y_coords):
|
if len(labels) != len(x_coords) or len(labels) != len(y_coords):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Mirrors the C# Form1.cs binary parsing pipeline:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||||
@@ -106,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_debug_temp(hex_str: str) -> float:
|
||||||
|
"""Convert a 4-char hex string to a debug/cold-junction temperature.
|
||||||
|
|
||||||
|
Mirrors C#'s ConvertBinaryArrayToDebugTemp: bits 4-14 scaled by
|
||||||
|
2**(i-8), sign bit at bit 0. No family-code branching — this formula
|
||||||
|
applies regardless of wafer family, unlike _convert_hex_to_temp.
|
||||||
|
"""
|
||||||
|
bits = _hex_to_binary(hex_str)
|
||||||
|
value = 0.0
|
||||||
|
for i in range(4, 15):
|
||||||
|
if bits[i]:
|
||||||
|
value += 2.0 ** (i - 8)
|
||||||
|
if bits[0]:
|
||||||
|
value = -value
|
||||||
|
return round(value, 2)
|
||||||
|
|
||||||
|
|
||||||
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
|
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
|
||||||
"""Convert a singi hale 4-char hex string to a float temperature."""
|
"""Convert a singi hale 4-char hex string to a float temperature."""
|
||||||
bits = _hex_to_binary(hex_str)
|
bits = _hex_to_binary(hex_str)
|
||||||
@@ -224,6 +242,15 @@ def convert_to_temperatures(
|
|||||||
return temp_value
|
return temp_value
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_debug_temperatures(hex_data: list[list[str]]) -> list[list[str]]:
|
||||||
|
"""Convert debug/cold-junction hex values to temperature strings.
|
||||||
|
|
||||||
|
Same shape contract as convert_to_temperatures, but uses the
|
||||||
|
family-independent debug formula (_convert_debug_temp).
|
||||||
|
"""
|
||||||
|
return [[str(_convert_debug_temp(h)) for h in row] for row in hex_data]
|
||||||
|
|
||||||
|
|
||||||
def remove_trailing_zeros(data: list[list[str]]) -> None:
|
def remove_trailing_zeros(data: list[list[str]]) -> None:
|
||||||
"""Remove rows of all-zero values from the end of data (in-place).
|
"""Remove rows of all-zero values from the end of data (in-place).
|
||||||
|
|
||||||
@@ -266,7 +293,6 @@ def save_to_csv(
|
|||||||
try:
|
try:
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
# Build filename: P00001-20260505_133045.csv
|
# Build filename: P00001-20260505_133045.csv
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"{serial_number}-{timestamp}.csv"
|
filename = f"{serial_number}-{timestamp}.csv"
|
||||||
@@ -294,3 +320,46 @@ def save_to_csv(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("CSV save failed: %s", exc)
|
log.error("CSV save failed: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_debug_csv(
|
||||||
|
temp_data: list[list[str]],
|
||||||
|
debug_data: list[list[str]],
|
||||||
|
output_dir: str,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Save sensor + debug temperatures side-by-side to debug_info.csv.
|
||||||
|
|
||||||
|
Mirrors C#'s writeDebugCSV: alternating Sensor{j+1},Debug{j+1} columns,
|
||||||
|
truncated to the shorter of the two row/column counts. Fixed filename
|
||||||
|
(not timestamped), unlike save_to_csv.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
temp_data: 2D array of sensor temperature strings (D1, decoded via
|
||||||
|
convert_to_temperatures).
|
||||||
|
debug_data: 2D array of debug temperature strings (F1, decoded via
|
||||||
|
convert_to_debug_temperatures).
|
||||||
|
output_dir: Directory to save the CSV file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full file path on success, None on empty input or write failure.
|
||||||
|
"""
|
||||||
|
if not temp_data or not debug_data:
|
||||||
|
log.error("Debug CSV save failed: temp or debug data is empty")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
num_cols = min(len(temp_data[0]), len(debug_data[0]))
|
||||||
|
num_rows = min(len(temp_data), len(debug_data))
|
||||||
|
filepath = os.path.join(output_dir, "debug_info.csv")
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
headers = [f"Sensor{j + 1},Debug{j + 1}" for j in range(num_cols)]
|
||||||
|
f.write(",".join(headers) + "\n")
|
||||||
|
for i in range(num_rows):
|
||||||
|
row = [f"{temp_data[i][j]},{debug_data[i][j]}" for j in range(num_cols)]
|
||||||
|
f.write(",".join(row) + "\n")
|
||||||
|
|
||||||
|
log.info("Saved debug CSV: %d rows × %d cols to %s", num_rows, num_cols, filepath)
|
||||||
|
return filepath
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Debug CSV save failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class SerialPort:
|
|||||||
)
|
)
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
kwargs["timeout"] = timeout
|
kwargs["timeout"] = timeout
|
||||||
|
# Write timeout must never be None (= block forever): on macOS,
|
||||||
|
# pseudo-ports like Bluetooth-Incoming-Port accept the open and then
|
||||||
|
# hang the write, which froze detect in "Detecting..." permanently.
|
||||||
|
kwargs["write_timeout"] = timeout if timeout is not None else 2.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class StreamReader:
|
|||||||
Reads up to 256 bytes in one shot. If nothing is immediately
|
Reads up to 256 bytes in one shot. If nothing is immediately
|
||||||
available, yields briefly to avoid a busy spin.
|
available, yields briefly to avoid a busy spin.
|
||||||
"""
|
"""
|
||||||
chunk = self._transport.read(max(min_bytes, 256))
|
chunk: bytes = self._transport.read(max(min_bytes, 256))
|
||||||
if not chunk:
|
if not chunk:
|
||||||
time.sleep(0.005)
|
time.sleep(0.005)
|
||||||
return chunk
|
return chunk
|
||||||
@@ -106,7 +106,7 @@ class StreamReader:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _run_binary(self, initial_bytes: bytes) -> None:
|
def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
|
||||||
log.info("StreamReader: starting binary stream parsing")
|
log.info("StreamReader: starting binary stream parsing")
|
||||||
buf = bytearray(initial_bytes)
|
buf = bytearray(initial_bytes)
|
||||||
resync_attempts = 0
|
resync_attempts = 0
|
||||||
@@ -149,7 +149,7 @@ class StreamReader:
|
|||||||
payload = buf[7:7 + payload_len]
|
payload = buf[7:7 + payload_len]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
frame = self._parse(payload, seq)
|
frame = self._parse(payload, seq) # type: ignore[arg-type]
|
||||||
self._on_frame(frame)
|
self._on_frame(frame)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.error_count += 1
|
self.error_count += 1
|
||||||
@@ -238,7 +238,7 @@ class StreamReader:
|
|||||||
for hex_val in valid_hex_words:
|
for hex_val in valid_hex_words:
|
||||||
try:
|
try:
|
||||||
swapped = hex_val[2:4] + hex_val[0:2]
|
swapped = hex_val[2:4] + hex_val[0:2]
|
||||||
t = _convert_hex_to_temp(swapped, self._family_code)
|
t = _convert_hex_to_temp(swapped, self._family_code) # type: ignore[arg-type]
|
||||||
values.append(t)
|
values.append(t)
|
||||||
except Exception:
|
except Exception:
|
||||||
values.append(0.0)
|
values.append(0.0)
|
||||||
@@ -262,7 +262,7 @@ class StreamReader:
|
|||||||
line = line_bytes.decode('utf-8', errors='ignore').strip()
|
line = line_bytes.decode('utf-8', errors='ignore').strip()
|
||||||
if line:
|
if line:
|
||||||
try:
|
try:
|
||||||
frame = self._parse(line, seq)
|
frame = self._parse(line, seq) # type: ignore[arg-type]
|
||||||
if frame:
|
if frame:
|
||||||
self._on_frame(frame)
|
self._on_frame(frame)
|
||||||
seq += 1
|
seq += 1
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Wafer ID=A12
|
||||||
|
Acquisition Date=03/26/2025
|
||||||
|
Label,1,2,3
|
||||||
|
X (mm),0,10,-10
|
||||||
|
Y (mm),10,-10,-10
|
||||||
|
data
|
||||||
|
0.0,150.0,149.5,151.6
|
||||||
|
0.5,150.2,149.4,151.7
|
||||||
|
1.0,150.1,149.6,151.5
|
||||||
|
1.5,150.3,149.7,151.4
|
||||||
|
2.0,150.4,149.8,151.3
|
||||||
|
@@ -0,0 +1,52 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from pygui.backend.visualization.chart_geometry import (
|
||||||
|
elapsed_to_pixel,
|
||||||
|
index_to_pixel,
|
||||||
|
value_to_pixel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_min_maps_to_bottom():
|
||||||
|
assert value_to_pixel(0.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(210.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_max_maps_to_top():
|
||||||
|
assert value_to_pixel(100.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_midpoint():
|
||||||
|
assert value_to_pixel(50.0, 0.0, 100.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_to_pixel_degenerate_range_centers():
|
||||||
|
assert value_to_pixel(5.0, 5.0, 5.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_to_pixel_first_and_last():
|
||||||
|
assert index_to_pixel(0, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(0.0)
|
||||||
|
assert index_to_pixel(4, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_to_pixel_single_point_centers():
|
||||||
|
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_window_start_maps_to_left():
|
||||||
|
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_window_end_maps_to_right():
|
||||||
|
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_midpoint():
|
||||||
|
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_elapsed_to_pixel_degenerate_window_pins_right():
|
||||||
|
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
|
||||||
|
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Tests for src/pygui/backend/cluster_average.py."""
|
"""Tests for src/pygui/backend/cluster_average.py."""
|
||||||
import math
|
import math
|
||||||
import pytest
|
|
||||||
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.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for src/pygui/backend/comparison.py."""
|
"""Tests for src/pygui/backend/comparison.py."""
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from pygui.backend.comparison import compare_runs
|
from pygui.backend.comparison import compare_runs
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,70 @@ def test_recorded_data_rows_are_readable(tmp_path):
|
|||||||
assert records[0].values == [149.0, 148.0]
|
assert records[0].values == [149.0, 148.0]
|
||||||
assert records[1].time == 0.5
|
assert records[1].time == 0.5
|
||||||
assert records[1].values == [149.5, 148.5]
|
assert records[1].values == [149.5, 148.5]
|
||||||
|
|
||||||
|
# ===== write_segment (P3.3) =====
|
||||||
|
def _make_zwafer_csv(tmp_path, rows=5):
|
||||||
|
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
|
||||||
|
path = tmp_path / "src.csv"
|
||||||
|
rec = CsvRecorder()
|
||||||
|
rec.start(str(path), sensors, serial="A12")
|
||||||
|
for i in range(rows):
|
||||||
|
rec.write(Frame(seq=i, time=i * 0.5, values=[100.0 + i, 200.0 + i]))
|
||||||
|
rec.stop()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_zwafer_slice(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=5)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 1, 3) is True
|
||||||
|
|
||||||
|
records = read_data_records(str(dest))
|
||||||
|
assert len(records) == 3
|
||||||
|
assert records[0].values == [101.0, 201.0]
|
||||||
|
assert records[-1].values == [103.0, 203.0]
|
||||||
|
|
||||||
|
# header survives → still parses as a wafer CSV
|
||||||
|
data, _ = ZWaferParser().parse(str(dest))
|
||||||
|
assert data is not None
|
||||||
|
assert [s.label for s in data.sensors] == ["1", "2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_full_range(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=4)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 0, 3) is True
|
||||||
|
assert len(read_data_records(str(dest))) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_out_of_range(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=3)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 10, 20) is False
|
||||||
|
assert not dest.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_invalid_range(tmp_path):
|
||||||
|
src = _make_zwafer_csv(tmp_path, rows=3)
|
||||||
|
dest = tmp_path / "seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 2, 1) is False
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), -1, 1) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_segment_official_csv(tmp_path):
|
||||||
|
# official format: row 0 = sensor names, rows 1+ = values only
|
||||||
|
src = tmp_path / "A00001-x.csv"
|
||||||
|
src.write_text(
|
||||||
|
"Sensor1,Sensor2\n"
|
||||||
|
"10.0,20.0\n"
|
||||||
|
"11.0,21.0\n"
|
||||||
|
"12.0,22.0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
dest = tmp_path / "A00001-x_seg.csv"
|
||||||
|
assert CsvRecorder.write_segment(str(dest), str(src), 1, 2) is True
|
||||||
|
lines = dest.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert lines[0] == "Sensor1,Sensor2"
|
||||||
|
assert lines[1] == "11.0,21.0"
|
||||||
|
assert lines[2] == "12.0,22.0"
|
||||||
|
assert len(lines) == 3
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"""Tests for serialcomm/data_parser.py binary parsing pipeline."""
|
"""Tests for serialcomm/data_parser.py binary parsing pipeline."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
|
_convert_debug_temp,
|
||||||
|
_convert_hex_to_temp,
|
||||||
|
convert_to_debug_temperatures,
|
||||||
|
convert_to_temperatures,
|
||||||
csv_column_count,
|
csv_column_count,
|
||||||
parse_binary_data,
|
parse_binary_data,
|
||||||
convert_to_temperatures,
|
|
||||||
remove_trailing_zeros,
|
remove_trailing_zeros,
|
||||||
|
save_debug_csv,
|
||||||
save_to_csv,
|
save_to_csv,
|
||||||
_convert_hex_to_temp,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── csv_column_count ──────────────────────────────────────────────────────────
|
# ── csv_column_count ──────────────────────────────────────────────────────────
|
||||||
@@ -271,7 +275,7 @@ class TestSaveToCsv:
|
|||||||
result = save_to_csv(data, "P", "P00001", str(tmp_path))
|
result = save_to_csv(data, "P", "P00001", str(tmp_path))
|
||||||
assert result is not None
|
assert result is not None
|
||||||
headers = open(result).readline().strip().split(",")
|
headers = open(result).readline().strip().split(",")
|
||||||
assert len(headers) == 60, f"data width 60 should win over display 48"
|
assert len(headers) == 60, "data width 60 should win over display 48"
|
||||||
assert headers[0] == "Sensor1"
|
assert headers[0] == "Sensor1"
|
||||||
assert headers[-1] == "Sensor60"
|
assert headers[-1] == "Sensor60"
|
||||||
|
|
||||||
@@ -320,3 +324,97 @@ class TestSaveToCsv:
|
|||||||
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
|
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
|
||||||
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
|
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
|
||||||
assert data_row[243] == "243.0", "last column value must be preserved"
|
assert data_row[243] == "243.0", "last column value must be preserved"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Debug temperature conversion ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertDebugTemp:
|
||||||
|
"""Spot-check known hex → debug temperature values.
|
||||||
|
|
||||||
|
Reference values derived from C#'s ConvertBinaryArrayToDebugTemp
|
||||||
|
(bits 4-14 scaled by 2**(i-8), sign at bit 0 — no family branching).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_zero_hex_gives_zero(self):
|
||||||
|
assert _convert_debug_temp("0000") == 0.0
|
||||||
|
|
||||||
|
def test_bit8_gives_one(self):
|
||||||
|
# bit index 8 set (value 0x0080) → contributes 2**(8-8) = 1.0
|
||||||
|
assert _convert_debug_temp("0080") == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_sign_bit_negates(self):
|
||||||
|
# sign bit (bit 0) + bit 8 → -1.0
|
||||||
|
assert _convert_debug_temp("8080") == pytest.approx(-1.0, abs=0.01)
|
||||||
|
|
||||||
|
def test_no_family_branching(self):
|
||||||
|
# Debug conversion takes no family code — same result regardless
|
||||||
|
assert _convert_debug_temp("0080") == _convert_debug_temp("0080")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertToDebugTemperatures:
|
||||||
|
def test_returns_same_shape(self):
|
||||||
|
hex_data = [["0080", "0000"], ["8080", "0000"]]
|
||||||
|
result = convert_to_debug_temperatures(hex_data)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert len(result[0]) == 2
|
||||||
|
|
||||||
|
def test_values_are_strings(self):
|
||||||
|
result = convert_to_debug_temperatures([["0080"]])
|
||||||
|
assert isinstance(result[0][0], str)
|
||||||
|
|
||||||
|
def test_converts_known_value(self):
|
||||||
|
result = convert_to_debug_temperatures([["0080"]])
|
||||||
|
assert float(result[0][0]) == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Debug CSV export ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveDebugCsv:
|
||||||
|
def test_creates_file_with_fixed_name(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0"]], [["1.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
assert result.endswith("debug_info.csv")
|
||||||
|
|
||||||
|
def test_header_alternates_sensor_and_debug(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
header = open(result).readline().strip()
|
||||||
|
assert header == "Sensor1,Debug1,Sensor2,Debug2"
|
||||||
|
|
||||||
|
def test_data_row_alternates_values(self, tmp_path):
|
||||||
|
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
lines = open(result).readlines()
|
||||||
|
assert lines[1].strip() == "25.0,1.0,24.5,2.0"
|
||||||
|
|
||||||
|
def test_truncates_to_shorter_row_count(self, tmp_path):
|
||||||
|
temp_data = [["25.0"], ["24.0"], ["23.0"]]
|
||||||
|
debug_data = [["1.0"], ["2.0"]]
|
||||||
|
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
lines = open(result).readlines()
|
||||||
|
assert len(lines) == 3 # 1 header + 2 data rows (shorter of the two)
|
||||||
|
|
||||||
|
def test_truncates_to_shorter_column_count(self, tmp_path):
|
||||||
|
temp_data = [["25.0", "24.0", "23.0"]]
|
||||||
|
debug_data = [["1.0", "2.0"]]
|
||||||
|
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
|
||||||
|
assert result is not None
|
||||||
|
header = open(result).readline().strip()
|
||||||
|
assert header == "Sensor1,Debug1,Sensor2,Debug2"
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_temp_data(self, tmp_path):
|
||||||
|
assert save_debug_csv([], [["1.0"]], str(tmp_path)) is None
|
||||||
|
|
||||||
|
def test_returns_none_on_empty_debug_data(self, tmp_path):
|
||||||
|
assert save_debug_csv([["25.0"]], [], str(tmp_path)) is None
|
||||||
|
|
||||||
|
def test_creates_output_dir_if_missing(self, tmp_path):
|
||||||
|
nested = str(tmp_path / "a" / "b")
|
||||||
|
result = save_debug_csv([["25.0"]], [["1.0"]], nested)
|
||||||
|
assert result is not None
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
assert Path(result).exists()
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import pytest
|
|
||||||
import logging
|
import logging
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pygui.backend.controllers.device_controller import DeviceController
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.serialcomm.serial_port import WaferInfo
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_settings():
|
def mock_settings():
|
||||||
s = LocalSettings()
|
s = LocalSettings()
|
||||||
@@ -16,8 +19,8 @@ def mock_settings():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def controller(mock_settings):
|
def controller(mock_settings):
|
||||||
import tempfile
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
temp_dir = tempfile.mkdtemp()
|
temp_dir = tempfile.mkdtemp()
|
||||||
yield DeviceController(mock_settings, temp_dir)
|
yield DeviceController(mock_settings, temp_dir)
|
||||||
shutil.rmtree(temp_dir)
|
shutil.rmtree(temp_dir)
|
||||||
@@ -130,6 +133,64 @@ def test_read_debug_handler(controller):
|
|||||||
assert controller.connectionStatus == "Connected"
|
assert controller.connectionStatus == "Connected"
|
||||||
assert not controller.operationInProgress
|
assert not controller.operationInProgress
|
||||||
|
|
||||||
|
def _make_p_family_bytes(value: int = 0x0100) -> bytes:
|
||||||
|
"""Synthetic single-block P-family binary: 244 valid words + 12 overhead."""
|
||||||
|
data = bytearray()
|
||||||
|
for i in range(256):
|
||||||
|
word = value if i < 244 else 0
|
||||||
|
data += word.to_bytes(2, byteorder="little")
|
||||||
|
return bytes(data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_worker_decodes_and_saves_csv(controller, tmp_path):
|
||||||
|
controller._last_wafer_info = {"familyCode": "P"}
|
||||||
|
controller._save_data_dir = str(tmp_path)
|
||||||
|
controller._service.read_wafer_data = MagicMock(
|
||||||
|
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
controller._debugFinished.connect(captured.update)
|
||||||
|
|
||||||
|
controller._debug_worker("COM1")
|
||||||
|
|
||||||
|
assert captured.get("success") is True
|
||||||
|
assert captured.get("sensor_bytes") == 512
|
||||||
|
assert captured.get("debug_bytes") == 512
|
||||||
|
assert "csv_path" in captured
|
||||||
|
from pathlib import Path
|
||||||
|
assert Path(captured["csv_path"]).exists()
|
||||||
|
assert Path(captured["csv_path"]).name == "debug_info.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_worker_reports_error_when_csv_save_fails(controller, tmp_path):
|
||||||
|
controller._last_wafer_info = {"familyCode": "P"}
|
||||||
|
# Point save dir somewhere save_debug_csv cannot write to, so it returns None.
|
||||||
|
controller._save_data_dir = str(tmp_path / "debug_info.csv") # a file, not a dir
|
||||||
|
(tmp_path / "debug_info.csv").write_text("occupied")
|
||||||
|
controller._service.read_wafer_data = MagicMock(
|
||||||
|
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
controller._debugFinished.connect(captured.update)
|
||||||
|
|
||||||
|
controller._debug_worker("COM1")
|
||||||
|
|
||||||
|
assert captured.get("success") is not True
|
||||||
|
assert "error" in captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_handler_logs_csv_path(controller):
|
||||||
|
controller._handle_debug_finished({
|
||||||
|
"success": True,
|
||||||
|
"sensor_bytes": 512,
|
||||||
|
"debug_bytes": 512,
|
||||||
|
"csv_path": "/tmp/debug_info.csv",
|
||||||
|
})
|
||||||
|
assert "/tmp/debug_info.csv" in controller.activityLog
|
||||||
|
|
||||||
|
|
||||||
def test_parse_and_save_data_and_get_chart_data(controller):
|
def test_parse_and_save_data_and_get_chart_data(controller):
|
||||||
res = controller.getChartData()
|
res = controller.getChartData()
|
||||||
assert res == {"success": False}
|
assert res == {"success": False}
|
||||||
@@ -159,8 +220,8 @@ def test_logging_handler(controller):
|
|||||||
assert any("Hello QML Activity Log" in line for line in controller._activity_log)
|
assert any("Hello QML Activity Log" in line for line in controller._activity_log)
|
||||||
|
|
||||||
def test_fresh_session_on_startup(mock_settings):
|
def test_fresh_session_on_startup(mock_settings):
|
||||||
import tempfile
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
|
||||||
# Configure mock settings with some data to simulate previous session
|
# Configure mock settings with some data to simulate previous session
|
||||||
mock_settings.activity_log = ["[12:00:00] Old message"]
|
mock_settings.activity_log = ["[12:00:00] Old message"]
|
||||||
@@ -191,7 +252,7 @@ def test_detect_wafer_clears_old_session(controller):
|
|||||||
# We mock detect_all_ports to not run long or fail
|
# We mock detect_all_ports to not run long or fail
|
||||||
controller._service.detect_all_ports = MagicMock(return_value=(None, None))
|
controller._service.detect_all_ports = MagicMock(return_value=(None, None))
|
||||||
|
|
||||||
with patch("threading.Thread") as mock_thread:
|
with patch("threading.Thread"):
|
||||||
controller.detectWafer()
|
controller.detectWafer()
|
||||||
|
|
||||||
# Verify it cleared all session variables and logs immediately at start of detect
|
# Verify it cleared all session variables and logs immediately at start of detect
|
||||||
@@ -227,6 +288,15 @@ def test_device_service_port_caching(controller):
|
|||||||
|
|
||||||
# Clear cache and scan again should invoke subprocess
|
# Clear cache and scan again should invoke subprocess
|
||||||
service.clear_port_scan_cache()
|
service.clear_port_scan_cache()
|
||||||
ports3 = service.enumerate_ports()
|
service.enumerate_ports()
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dir_created_under_save_data_dir(controller):
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
result = controller.exportDir()
|
||||||
|
|
||||||
|
assert result == str(Path(controller.saveDataDir) / "Export")
|
||||||
|
assert Path(result).is_dir()
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2,
|
||||||
|
# docs/pending/license-gating-plan.md). Two cases:
|
||||||
|
# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync()
|
||||||
|
# emits readResult {"error": ...} and spawns no thread.
|
||||||
|
# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService).
|
||||||
|
# THINKING: guard is a trust boundary (plan §2.1); these fail if someone
|
||||||
|
# reorders the busy/license checks or renames the "serialNumber" key.
|
||||||
|
# Depends on P1.2 + P2.1 being implemented.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(path, wafer_id="P00001"):
|
||||||
|
path.write_text(
|
||||||
|
f"Wafer ID={wafer_id}\n"
|
||||||
|
"Acquisition Date=03/26/2025\n"
|
||||||
|
"Label,1,2,3\n"
|
||||||
|
"X (mm),0,10,-10\n"
|
||||||
|
"Y (mm),10,-10,-10\n"
|
||||||
|
"data\n"
|
||||||
|
"0.0,149.0,148.5,150.6\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
|
||||||
|
"""Files named live_<serial>_<timestamp>.csv (SessionController.startRecording)
|
||||||
|
must be distinguishable in the browser from read-memory dumps."""
|
||||||
|
_write_csv(tmp_path / "live_P00001_20260706_143022.csv")
|
||||||
|
_write_csv(tmp_path / "P00002-20260706_143500.csv")
|
||||||
|
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser._set_current_directory(tmp_path)
|
||||||
|
browser.refreshFiles()
|
||||||
|
|
||||||
|
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
|
||||||
|
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
|
||||||
|
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path):
|
||||||
|
"""setCurrentDirectory (no native dialog) is how the Status tab's save-dir
|
||||||
|
picker keeps the Source panel pointed at the same folder."""
|
||||||
|
_write_csv(tmp_path / "P00003-20260706_143500.csv")
|
||||||
|
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser.setCurrentDirectory(str(tmp_path))
|
||||||
|
|
||||||
|
assert browser.currentDirectory == str(tmp_path)
|
||||||
|
assert len(browser.files) == 1
|
||||||
|
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from pygui.backend.models.frame import Frame
|
from pygui.backend.models.frame import Frame
|
||||||
from pygui.backend.models.frame_player import FramePlayer
|
from pygui.backend.models.frame_player import FramePlayer, all_sensor_series
|
||||||
|
|
||||||
|
|
||||||
def frames(n):
|
def frames(n):
|
||||||
return [Frame(seq=i, time=float(i), values=[149.0 + i])for i in range(n)]
|
return [Frame(seq=i, time=float(i), values=[149.0 + i])for i in range(n)]
|
||||||
@@ -28,3 +29,24 @@ def test_at_end():
|
|||||||
assert not p.at_end
|
assert not p.at_end
|
||||||
p.seek(1)
|
p.seek(1)
|
||||||
assert p.at_end
|
assert p.at_end
|
||||||
|
|
||||||
|
def test_frames_property_exposes_loaded_frames():
|
||||||
|
p = FramePlayer(); p.load(frames(3))
|
||||||
|
assert p.frames == tuple(frames(3))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- all_sensor_series (Graph tab whole-run assembly) ----
|
||||||
|
|
||||||
|
def test_all_sensor_series_empty_frames():
|
||||||
|
assert all_sensor_series([]) == ([], [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_sensor_series_names_and_columns():
|
||||||
|
fs = [
|
||||||
|
Frame(seq=0, time=0.0, values=[150.0, 148.0]),
|
||||||
|
Frame(seq=1, time=1.0, values=[151.0, 147.5]),
|
||||||
|
Frame(seq=2, time=2.0, values=[152.0, 147.0]),
|
||||||
|
]
|
||||||
|
names, series = all_sensor_series(fs)
|
||||||
|
assert names == ["Sensor 1", "Sensor 2"]
|
||||||
|
assert series == [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pygui.backend.models.frame_stats import compute_stats, Stats
|
|
||||||
|
from pygui.backend.models.frame_stats import (
|
||||||
|
EdgeCenterStats,
|
||||||
|
Stats,
|
||||||
|
compute_edge_center,
|
||||||
|
compute_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_basic_stat():
|
def test_basic_stat():
|
||||||
@@ -20,3 +27,49 @@ def test_empty_values_returns_zeros():
|
|||||||
|
|
||||||
def test_ignores_nan():
|
def test_ignores_nan():
|
||||||
s = compute_stats([149.0, float("nan"), 151.0])
|
s = compute_stats([149.0, float("nan"), 151.0])
|
||||||
|
assert s.min == 149.0 and s.min_index == 0
|
||||||
|
assert s.max == 151.0 and s.max_index == 2
|
||||||
|
assert s.diff == pytest.approx(2.0)
|
||||||
|
assert s.avg == pytest.approx(150.0)
|
||||||
|
assert s.sigma == pytest.approx(1.0)
|
||||||
|
assert s.three_sigma == pytest.approx(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- edge-center delta (ADR-0002) ----
|
||||||
|
|
||||||
|
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_min_max_pairs():
|
||||||
|
# e0 e1 e2 e3 c4 c5
|
||||||
|
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||||
|
ec = compute_edge_center(values, PAIRS, excluded=set())
|
||||||
|
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
|
||||||
|
assert ec == EdgeCenterStats(
|
||||||
|
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
|
||||||
|
min_delta=pytest.approx(0.1),
|
||||||
|
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
|
||||||
|
max_delta=pytest.approx(1.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_skips_excluded_sensors():
|
||||||
|
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||||
|
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
|
||||||
|
assert ec is not None
|
||||||
|
assert ec.max_delta == pytest.approx(1.0)
|
||||||
|
# excluding a center sensor kills all its pairs
|
||||||
|
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_skips_nan_and_out_of_range():
|
||||||
|
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
|
||||||
|
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
|
||||||
|
assert ec is not None
|
||||||
|
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
|
||||||
|
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_center_none_when_no_pairs():
|
||||||
|
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
|
||||||
|
assert compute_edge_center([], PAIRS, excluded=set()) is None
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Tests for src/pygui/backend/visualization/graph_quick_item.py."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_initially():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
assert item.seriesData == []
|
||||||
|
assert item.sensorNames == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_setter_stores_series():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
assert item.seriesData == [[150.0, 151.0], [148.0, 147.5]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_auto_ranges_y_with_padding():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||||
|
|
||||||
|
# min=100, max=200, range=100, 10% pad => [90, 210]
|
||||||
|
assert item._min_y == pytest.approx(90.0)
|
||||||
|
assert item._max_y == pytest.approx(210.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_data_empty_keeps_default_range():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = []
|
||||||
|
|
||||||
|
assert item._min_y == 0.0
|
||||||
|
assert item._max_y == 150.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_names_setter_stores_names():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
assert item.sensorNames == ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_numeric_values_are_skipped_not_crashing():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, "bad", 151.0]]
|
||||||
|
|
||||||
|
assert item._min_y == pytest.approx(149.9)
|
||||||
|
assert item._max_y == pytest.approx(151.1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- viewport_stats (replay chart min/max/avg readouts) ----
|
||||||
|
# Independently hand-computed against PopupChartForm.cs's recalc_stats
|
||||||
|
# semantics: aggregate across every sensor's points in [start, end], plus
|
||||||
|
# which sensor achieved each extreme.
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_full_range_aggregate():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
names = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(147.0)
|
||||||
|
assert vmax == pytest.approx(152.0)
|
||||||
|
assert avg == pytest.approx(149.25)
|
||||||
|
assert min_sensor == "Sensor 2"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_restricted_to_subrange():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
names = ["Sensor 1", "Sensor 2"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(147.5)
|
||||||
|
assert vmax == pytest.approx(151.0)
|
||||||
|
assert avg == pytest.approx(149.125)
|
||||||
|
assert min_sensor == "Sensor 2"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_empty_series_returns_zeros():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_stats_skips_non_numeric():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||||
|
|
||||||
|
series = [[150.0, "bad", 151.0]]
|
||||||
|
names = ["Sensor 1"]
|
||||||
|
|
||||||
|
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||||
|
|
||||||
|
assert vmin == pytest.approx(150.0)
|
||||||
|
assert vmax == pytest.approx(151.0)
|
||||||
|
assert avg == pytest.approx(150.5)
|
||||||
|
assert min_sensor == "Sensor 1"
|
||||||
|
assert max_sensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- GraphQuickItem viewport properties (replay chart) ----
|
||||||
|
# Default viewport (0, -1) means "whole series" so the already-shipped Graph
|
||||||
|
# tab (which never sets these) is unaffected — see docs/adr/0004.
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewport_defaults_to_whole_range_and_markers_off():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
assert item.showMinMaxMarkers is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_stats_reflect_default_full_range_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|
||||||
|
assert item.viewMin == pytest.approx(147.0)
|
||||||
|
assert item.viewMax == pytest.approx(152.0)
|
||||||
|
assert item.viewAvg == pytest.approx(149.25)
|
||||||
|
assert item.viewMinSensor == "Sensor 2"
|
||||||
|
assert item.viewMaxSensor == "Sensor 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_stats_restrict_to_narrowed_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||||
|
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||||
|
|
||||||
|
item.viewStartIndex = 0
|
||||||
|
item.viewEndIndex = 1
|
||||||
|
|
||||||
|
assert item.viewMin == pytest.approx(147.5)
|
||||||
|
assert item.viewMax == pytest.approx(151.0)
|
||||||
|
assert item.viewAvg == pytest.approx(149.125)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_range_y_restricted_to_narrowed_viewport():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||||
|
# Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210]
|
||||||
|
assert item._min_y == pytest.approx(90.0)
|
||||||
|
assert item._max_y == pytest.approx(210.0)
|
||||||
|
|
||||||
|
# Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad
|
||||||
|
item.viewStartIndex = 0
|
||||||
|
item.viewEndIndex = 0
|
||||||
|
|
||||||
|
assert item._min_y == pytest.approx(145.0)
|
||||||
|
assert item._max_y == pytest.approx(205.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ----
|
||||||
|
# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on
|
||||||
|
# whole numbers -- avoids rounding ambiguity in the independent hand trace.
|
||||||
|
|
||||||
|
|
||||||
|
def _eleven_point_item():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[float(i) for i in range(11)]]
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_in_centers_on_fraction():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
|
||||||
|
# Full range is (0, 10), width 10. Zooming to 40% width centered at the
|
||||||
|
# midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7.
|
||||||
|
item.zoomAtFraction(0.5, 0.4)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 3
|
||||||
|
assert item.viewEndIndex == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_out_clamps_to_data_bounds():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 3
|
||||||
|
item.viewEndIndex = 7
|
||||||
|
|
||||||
|
# width=4, zoom out 3x centered at midpoint (frac=0.5): requested
|
||||||
|
# new_width=12 exceeds the data's max width of 10, so it clamps to the
|
||||||
|
# full range (0, 10).
|
||||||
|
item.zoomAtFraction(0.5, 3.0)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_shifts_viewport_by_fraction_of_width():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 4
|
||||||
|
item.viewEndIndex = 8
|
||||||
|
|
||||||
|
# width=4, pan by 50% of width (2 indices): (4,8) -> (6,10)
|
||||||
|
item.panByFraction(0.5)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 6
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_clamps_at_right_edge_preserving_width():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 4
|
||||||
|
item.viewEndIndex = 8
|
||||||
|
|
||||||
|
# width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is
|
||||||
|
# out of bounds (max index 10), so the whole window shifts back by 2 to
|
||||||
|
# stay in bounds while keeping width=4: (6,10).
|
||||||
|
item.panByFraction(1.0)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 6
|
||||||
|
assert item.viewEndIndex == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_zoom_restores_full_range_sentinel():
|
||||||
|
item = _eleven_point_item()
|
||||||
|
item.viewStartIndex = 3
|
||||||
|
item.viewEndIndex = 7
|
||||||
|
|
||||||
|
item.resetZoom()
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_noop_when_fewer_than_two_points():
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[42.0]]
|
||||||
|
|
||||||
|
item.zoomAtFraction(0.5, 0.5)
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_new_series_while_zoomed_resets_viewport_to_full_range():
|
||||||
|
"""Regression: zooming near the tail of a long run, then loading a new
|
||||||
|
(shorter) file, left the stale viewport pointing past the new data --
|
||||||
|
every per-series slice became empty and the chart went blank with a
|
||||||
|
stale Y-range and zeroed-out min/max/avg readouts. New data must reset
|
||||||
|
the viewport to the full range (PopupChartForm.cs's SetData() parity).
|
||||||
|
"""
|
||||||
|
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||||
|
|
||||||
|
item = GraphQuickItem()
|
||||||
|
item.seriesData = [[float(i) for i in range(20)]]
|
||||||
|
item.viewStartIndex = 15
|
||||||
|
item.viewEndIndex = 19
|
||||||
|
|
||||||
|
item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]]
|
||||||
|
|
||||||
|
assert item.viewStartIndex == 0
|
||||||
|
assert item.viewEndIndex == -1
|
||||||
|
assert item.viewMin == pytest.approx(100.0)
|
||||||
|
assert item.viewMax == pytest.approx(104.0)
|
||||||
|
assert item.viewAvg == pytest.approx(102.0)
|
||||||