Compare commits
42 Commits
0d5cf9e4ff
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3bec9031 | |||
| fed4d9b590 | |||
| 4bb855a940 | |||
| 94f917b116 | |||
| 25fa7507ce | |||
| 034f13b717 | |||
| 69753e35f9 | |||
| 278a48a2e4 | |||
| e2ce8778e0 | |||
| bdc667b2ed | |||
| b6249c2b8e | |||
| 2fe8aef805 | |||
| 4ee139ad8f | |||
| 7b20ffa376 | |||
| ac4448ed44 | |||
| ca158a7946 | |||
| 637b51b999 | |||
| bb9b5d709a | |||
| b6903af625 | |||
| 92f130b3bd | |||
| 1e03227788 | |||
| 7df7fd4c6f | |||
| e2d05d2c33 | |||
| 799155f249 | |||
| 015642eea0 | |||
| d63332d619 | |||
| 88b0214582 | |||
| 0014ccc184 | |||
| 1989ba8e5a | |||
| 915720085b | |||
| e2d71662ff | |||
| 52cc747536 | |||
| 185e66ea3a | |||
| 3b919f576f | |||
| f604308466 | |||
| 0f813b2376 | |||
| 6996df73f8 | |||
| 3cc10ea7fe | |||
| ecab3d81b1 | |||
| 9e28a25695 | |||
| da43aeb1f6 | |||
| 0a2d6b78ba |
@@ -40,3 +40,21 @@ dist/
|
||||
*.spec
|
||||
*.icns
|
||||
*.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:
|
||||
uv sync
|
||||
|
||||
test:
|
||||
test: lint
|
||||
uv run pytest tests/
|
||||
|
||||
lint:
|
||||
uv run ruff check src/
|
||||
uv run ruff check src/ tests/
|
||||
|
||||
fix:
|
||||
uv run ruff check src/ --fix
|
||||
uv run ruff check src/ tests/ --fix
|
||||
|
||||
typecheck:
|
||||
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.
|
||||
|
||||
### App Showcase
|
||||
|
||||

|
||||
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
|
||||
|
||||
> [!NOTE]
|
||||
> **Take Screenshots for Visual Placeholders:**
|
||||
>
|
||||
> - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here.
|
||||
> - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here.
|
||||
|
||||
@@ -42,12 +44,15 @@ During the refactoring from the flat-layout prototype (`SettingTab` branch) to t
|
||||
From the project root:
|
||||
|
||||
### Option A: Using `uv` (Recommended)
|
||||
|
||||
If you have [uv](https://docs.astral.sh/uv/) installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Option B: Using `pip`
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||
@@ -59,19 +64,21 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
|
||||
## Run
|
||||
|
||||
### Launch PySide6 QML App
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
|
||||
|
||||
## Development
|
||||
|
||||
The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests.
|
||||
The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests.
|
||||
|
||||
A `Makefile` is provided to simplify common development and verification tasks:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| --------- | ------------- |
|
||||
| `make install` | Sync dependencies and set up the local `.venv` using `uv` |
|
||||
| `make run` | Launch the ISenseCloud application |
|
||||
| `make test` | Run the `pytest` test suite |
|
||||
@@ -83,6 +90,7 @@ A `Makefile` is provided to simplify common development and verification tasks:
|
||||
### Ruff Configuration
|
||||
|
||||
Ruff is configured in `pyproject.toml` to enforce:
|
||||
|
||||
- **E/W**: Pycodestyle errors and warnings
|
||||
- **F**: Pyflakes linter rules
|
||||
- **I**: Import sorting (isort parity)
|
||||
@@ -174,11 +182,13 @@ Window {
|
||||
- If the app does not start, verify the virtual environment is active and dependencies are installed:
|
||||
|
||||
*Using `uv`:*
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
*Using `pip`:*
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
||||
pip install -r requirements.txt
|
||||
@@ -186,18 +196,21 @@ Window {
|
||||
|
||||
- If no window appears, ensure you are running in a desktop session with GUI access.
|
||||
|
||||
- **Windows COM Port Connection Issues:**
|
||||
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||
- **Windows COM Port Connection Issues:**
|
||||
- Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
|
||||
|
||||
### Step-by-Step Windows Simulator Connection Guide
|
||||
|
||||
#### Step 1: Create the Virtual Serial Port Bridge
|
||||
|
||||
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
|
||||
|
||||

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

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

|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"scipy>=1.17.1",
|
||||
"pyyaml>=6.0.3",
|
||||
"dtw-python",
|
||||
"cryptography>=41.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
@@ -8,10 +9,12 @@ Popup {
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: 420
|
||||
height: 340
|
||||
width: 600
|
||||
height: 500
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
onOpened: licenseModel.refresh()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
@@ -19,10 +22,17 @@ Popup {
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: licenseFileDialog
|
||||
title: "Load License"
|
||||
nameFilters: ["License files (*.bin)"]
|
||||
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 24
|
||||
spacing: 16
|
||||
anchors.margins: 28
|
||||
spacing: 20
|
||||
|
||||
// ── Title ──
|
||||
Label {
|
||||
@@ -34,27 +44,20 @@ Popup {
|
||||
|
||||
// ── App info ──
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "ISenseCloud (ISC)"
|
||||
font.pixelSize: Theme.fontLg
|
||||
text: "ISenseCloud"
|
||||
font.pixelSize: Theme.fontXl
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Version 0.1.0"
|
||||
font.pixelSize: Theme.fontSm
|
||||
text: "Version " + appVersion
|
||||
font.pixelSize: Theme.fontMd
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Temperature-sensing wafer monitoring"
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: Theme.bodyColor
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Separator ──
|
||||
@@ -67,7 +70,8 @@ Popup {
|
||||
// ── License grid ──
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 100
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 150
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
@@ -75,23 +79,38 @@ Popup {
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 2
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "LICENSE"
|
||||
color: Theme.panelTitleText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.letterSpacing: 0.5
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Label {
|
||||
text: "LICENSE"
|
||||
color: Theme.panelTitleText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.5
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Label {
|
||||
id: loadFailedLabel
|
||||
visible: false
|
||||
text: "Invalid license file"
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
}
|
||||
|
||||
// Grid header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 }
|
||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 }
|
||||
spacing: 8
|
||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
|
||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -100,10 +119,27 @@ Popup {
|
||||
color: Theme.softBorder
|
||||
}
|
||||
|
||||
ListView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
visible: licenseModel.licenses.length > 0
|
||||
model: licenseModel.licenses
|
||||
delegate: RowLayout {
|
||||
width: ListView.view.width
|
||||
spacing: 8
|
||||
Label { text: modelData.serial; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 150 }
|
||||
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
|
||||
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
|
||||
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: licenseModel.licenses.length === 0
|
||||
text: "No license loaded"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: Theme.fontMd
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
@@ -112,25 +148,55 @@ Popup {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close ──
|
||||
Button {
|
||||
text: "Close"
|
||||
Layout.alignment: Qt.AlignRight
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: root.close()
|
||||
// ── Buttons ──
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
Button {
|
||||
text: "Load License"
|
||||
Layout.preferredWidth: 140
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: licenseFileDialog.open()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
text: "Close"
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: root.close()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,45 @@ Rectangle {
|
||||
|
||||
// ===== View State =====
|
||||
property int selectedTabIndex: 0
|
||||
onSelectedTabIndexChanged: {
|
||||
if (selectedTabIndex === 2) {
|
||||
file_browser.refreshFiles();
|
||||
streamController.unloadFile()
|
||||
} else if (streamController.mode === "live") {
|
||||
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||
streamController.setMode("review")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
property bool memoryRead: false
|
||||
property bool 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 {
|
||||
target: deviceController
|
||||
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) {
|
||||
if (result.success && 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) {
|
||||
root.selectedTabIndex = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +81,7 @@ Rectangle {
|
||||
|
||||
function _doDetect() {
|
||||
root.memoryRead = false
|
||||
root.waferDetected = false
|
||||
streamController.setMode("review")
|
||||
streamController.stopStream()
|
||||
deviceController.detectWafer()
|
||||
@@ -64,11 +91,18 @@ Rectangle {
|
||||
id: saveDirDialog
|
||||
title: "Choose a folder to save Data"
|
||||
onAccepted: {
|
||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||
var dir = root.cleanFolderUrl(selectedFolder)
|
||||
deviceController.setSaveDataDir(dir)
|
||||
file_browser.setCurrentDirectory(dir)
|
||||
root._doDetect()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Split Dialog (Threshold Segmentation) =====
|
||||
SplitDialog {
|
||||
id: splitDialog
|
||||
}
|
||||
|
||||
// ===== Settings Popup =====
|
||||
Popup {
|
||||
id: settingsPopup
|
||||
@@ -168,8 +202,6 @@ Rectangle {
|
||||
Layout.preferredWidth: Theme.sideRailWidth
|
||||
Layout.fillHeight: true
|
||||
color: Theme.sideRailBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
@@ -188,9 +220,30 @@ Rectangle {
|
||||
|
||||
ListModel {
|
||||
id: pillModel
|
||||
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 }
|
||||
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 }
|
||||
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 }
|
||||
ListElement {
|
||||
label: "STATUS"
|
||||
icon: "Tabs/icons/status.svg"
|
||||
expandW: 86
|
||||
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
|
||||
}
|
||||
ListElement {
|
||||
label: "DATA"
|
||||
icon: "Tabs/icons/data.svg"
|
||||
expandW: 66
|
||||
desc: "Manage, review, compare (DTW), and split historical sensor data records."
|
||||
}
|
||||
ListElement {
|
||||
label: "MAP"
|
||||
icon: "Tabs/icons/map.svg"
|
||||
expandW: 58
|
||||
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
|
||||
}
|
||||
ListElement {
|
||||
label: "GRAPH"
|
||||
icon: "Tabs/icons/bar-chart.svg"
|
||||
expandW: 70
|
||||
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
@@ -271,345 +324,306 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
AppToolTip {
|
||||
visible: pillBtn.hovered
|
||||
text: "<b>" + model.label + "</b>: " + model.desc
|
||||
}
|
||||
|
||||
onClicked: root.selectedTabIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||
// ── BOX 1.5: Run Comparison (Data tab only) ────────────────
|
||||
Rectangle {
|
||||
id: runComparisonBox
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
Layout.preferredHeight: runCompCol.implicitHeight + 28
|
||||
visible: root.selectedTabIndex === 1
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
readonly property var dataTab: dataTabLoader.item
|
||||
|
||||
component RunSlot: Rectangle {
|
||||
id: slotBox
|
||||
property string title
|
||||
property string filePath: ""
|
||||
property color accent: Theme.primaryAccent
|
||||
property bool active: false
|
||||
property bool locked: false
|
||||
property string placeholderText: "Click to select"
|
||||
signal cleared()
|
||||
signal armed()
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 58
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.fieldBackground
|
||||
opacity: slotBox.locked ? 0.65 : 1.0
|
||||
border.width: slotBox.active ? 2 : 1
|
||||
border.color: slotBox.active ? accent : Theme.sideBorder
|
||||
|
||||
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !slotBox.locked
|
||||
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: slotBox.armed()
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
width: 8; height: 8; radius: 4
|
||||
color: slotBox.accent
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Label {
|
||||
text: slotBox.title
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
text: slotBox.filePath !== ""
|
||||
? slotBox.filePath.split("/").pop()
|
||||
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: slotBox.filePath !== "" && !slotBox.locked
|
||||
flat: true
|
||||
implicitWidth: 24; implicitHeight: 24
|
||||
onClicked: slotBox.cleared()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "Tabs/icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: runCompCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 8
|
||||
spacing: 10
|
||||
|
||||
StackLayout {
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: root.selectedTabIndex
|
||||
spacing: 8
|
||||
|
||||
// ── Status tab: Hardware Actions ────────────────
|
||||
ColumnLayout {
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "HARDWARE ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
topPadding: 6
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "DETECT WAFER"
|
||||
iconSource: "../icons/detect.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: !deviceController.isDetecting
|
||||
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 }
|
||||
}
|
||||
|
||||
// ── Data tab: Data Operations ───────────────────
|
||||
ColumnLayout {
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "DATA OPERATIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
topPadding: 6
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "OPEN IN EXCEL"
|
||||
iconSource: "../icons/excel.svg"
|
||||
Layout.fillWidth: true
|
||||
onClicked: deviceController.openCsvFile()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "COMPARE CSV"
|
||||
iconSource: "../icons/compare.svg"
|
||||
Layout.fillWidth: true
|
||||
onClicked: console.log("[P3.1] Compare dialog not yet built")
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "SPLIT DATA"
|
||||
iconSource: "../icons/split.svg"
|
||||
Layout.fillWidth: true
|
||||
onClicked: console.log("[P3.2] Split dialog not yet built")
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// ── Map tab: Source file browser ──────────────
|
||||
SourcePanel {
|
||||
Label {
|
||||
text: "RUN COMPARISON (DTW)"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Button {
|
||||
id: resetRunBtn
|
||||
flat: true
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
visible: true
|
||||
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "Tabs/icons/rotate-ccw.svg"
|
||||
width: 18; height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
AppToolTip {
|
||||
visible: resetRunBtn.hovered
|
||||
text: "Reset comparison"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: useMasterCheck
|
||||
Layout.fillWidth: true
|
||||
text: "Compare vs Master File"
|
||||
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
onToggled: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.useMasterFile = checked
|
||||
if (checked) runComparisonBox.dataTab.activeBox = 2
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
|
||||
accent: Theme.primaryAccent
|
||||
locked: useMaster
|
||||
placeholderText: useMaster
|
||||
? (runComparisonBox.dataTab.runBWaferType !== ""
|
||||
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
|
||||
: "Select Run B to resolve master")
|
||||
: "Click to select"
|
||||
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
|
||||
filePath: runComparisonBox.dataTab
|
||||
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
|
||||
: ""
|
||||
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileA = ""
|
||||
runComparisonBox.dataTab.activeBox = 1
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
title: "SESSION (RUN B)"
|
||||
accent: Theme.themeSkill
|
||||
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
|
||||
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
|
||||
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileB = ""
|
||||
runComparisonBox.dataTab.activeBox = 2
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: compareRunBtn
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
readonly property var dt: runComparisonBox.dataTab
|
||||
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
||||
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
||||
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
||||
readonly property string gateError: !!dt ? dt.familyGateError : ""
|
||||
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
|
||||
AppToolTip {
|
||||
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
|
||||
text: compareRunBtn.masterMissing
|
||||
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
|
||||
: "Choose two different runs to compare"
|
||||
}
|
||||
onClicked: {
|
||||
dt.comparing = true
|
||||
dt.clearResults()
|
||||
streamController.compareFiles(fileA, dt.compareFileB)
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: compareRunBtn.enabled
|
||||
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||
: Theme.buttonNeutralBackground
|
||||
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 8
|
||||
anchors.centerIn: parent
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: !compareRunBtn.dt ? "Select both runs"
|
||||
: compareRunBtn.dt.comparing
|
||||
? "Comparing…"
|
||||
: compareRunBtn.enabled
|
||||
? "Run DTW Comparison"
|
||||
: (compareRunBtn.masterMissing ? "No master for this type"
|
||||
: compareRunBtn.sameFile ? "Choose different runs"
|
||||
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
|
||||
: "Select both runs")
|
||||
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
BusyIndicator {
|
||||
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
width: 14; height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||
|
||||
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||
StatusActionsPanel {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
// ── Map tab: Source file browser ──────────────
|
||||
Rectangle {
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
SourcePanel {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||
Rectangle {
|
||||
ConnectionFooter {
|
||||
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 ─────────────────────────────────
|
||||
Rectangle {
|
||||
UtilityFooter {
|
||||
id: utilFooter
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Theme.sideFooterUtility
|
||||
radius: Theme.sidePanelRadius
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
settingsPopup: settingsPopup
|
||||
aboutDialog: aboutDialog
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,8 +633,6 @@ Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.workspaceBackground
|
||||
border.color: Theme.workspaceBorder
|
||||
border.width: 1
|
||||
|
||||
StackLayout {
|
||||
anchors.fill: parent
|
||||
@@ -632,11 +644,18 @@ Rectangle {
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
Loader {
|
||||
id: dataTabLoader
|
||||
source: "Tabs/DataTab.qml"
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
Loader {
|
||||
source: "Tabs/WaferMapTab.qml"
|
||||
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
|
||||
// so the tab never instantiates while locked. See plan §3.1.
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
Loader {
|
||||
source: "Tabs/GraphTab.qml"
|
||||
active: StackLayout.index === root.selectedTabIndex
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +1,373 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
import ISC.Wafer
|
||||
|
||||
// ===== Data Tab =====
|
||||
// Displays parsed temperature data in a scrollable table.
|
||||
// Column 0 = Row index, remaining columns = Sensor1, Sensor2, ...
|
||||
// ===== Data Tab (Compare Content Only) =====
|
||||
// Per docs/design/navigation-mock.html: the Data tab shows only the Compare
|
||||
// Runs content — the raw CSV table ("Inspect Table" mode in earlier drafts)
|
||||
// is intentionally not displayed in the application.
|
||||
// Layout: left column = aligned-curves chart with alignment scrubber + wafer
|
||||
// overlap map; right side panel = run selection dropdowns, overlap settings,
|
||||
// and DTW readout cards.
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
readonly property int rowCount: deviceController.dataRowCount
|
||||
// Properties for DTW Comparison
|
||||
property int activeBox: 1 // 1 for Reference (Run A), 2 for Session (Run B), 0 for inactive
|
||||
property string compareFileA: ""
|
||||
property string compareFileB: ""
|
||||
property bool useMasterFile: false // Run A comes from settingsModel.masters[runBWaferType] instead of manual selection
|
||||
|
||||
// Re-layout columns whenever the model is reset
|
||||
function waferTypeForFile(path) {
|
||||
if (!path) return ""
|
||||
// A file registered as a master carries that slot's family — this is
|
||||
// the only way a live recording gets a family (its filename/metadata
|
||||
// don't encode one reliably).
|
||||
for (var fam in settingsModel.masters)
|
||||
if (settingsModel.masters[fam] === path) return fam.toUpperCase()
|
||||
var rows = file_browser.files
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (rows[i].fileName === path) {
|
||||
if (rows[i].isRecording) return (rows[i].masterType || "").toUpperCase()
|
||||
return (rows[i].waferType || "").toUpperCase()
|
||||
}
|
||||
}
|
||||
var base = path.split("/").pop()
|
||||
if (base.toLowerCase().indexOf("live_") === 0) return ""
|
||||
return base ? base.charAt(0).toUpperCase() : ""
|
||||
}
|
||||
|
||||
// Master file must match Run B's wafer family — looking it up by that
|
||||
// family is itself the comparability gate (a mismatched-family master
|
||||
// simply won't exist under this key).
|
||||
readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB)
|
||||
readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : ""
|
||||
readonly property string runAWaferType: root.useMasterFile ? root.runBWaferType : root.waferTypeForFile(root.compareFileA)
|
||||
// Family gate: both runs must resolve to the same wafer family. An empty
|
||||
// family means an unassigned live recording — comparable only after it is
|
||||
// registered as a master file (Settings → Master Files).
|
||||
readonly property string familyGateError: {
|
||||
var fileA = root.useMasterFile ? root.masterFileForRunB : root.compareFileA
|
||||
if (fileA === "" || root.compareFileB === "") return ""
|
||||
if (root.runAWaferType === "" || root.runBWaferType === "")
|
||||
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
|
||||
if (root.runAWaferType !== root.runBWaferType)
|
||||
return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
||||
return ""
|
||||
}
|
||||
property real warpingDistance: -1
|
||||
property int frameOffset: 0
|
||||
property real frameOffsetSeconds: 0
|
||||
property real maxSensorDeviation: -1
|
||||
property bool comparing: false
|
||||
property var trendDataA: []
|
||||
property var trendDataB: []
|
||||
property var timeDataA: []
|
||||
property var timeDataB: []
|
||||
property int frameCountA: 0
|
||||
property int frameCountB: 0
|
||||
property string errorMessage: ""
|
||||
property string mismatchMessage: ""
|
||||
|
||||
// Wafer overlap properties
|
||||
property var compareSensorLayout: []
|
||||
property var compareSensorDiff: []
|
||||
property string compareWaferShape: "round"
|
||||
property real compareWaferSize: 300.0
|
||||
|
||||
readonly property bool hasSeries: trendDataA.length > 1 && trendDataB.length > 1
|
||||
readonly property int seriesLen: Math.max(trendDataA.length, trendDataB.length)
|
||||
|
||||
function diffBands(diffs) {
|
||||
return diffs.map(d => d > 1.0 ? "high" : d < -1.0 ? "low" : "in_range")
|
||||
}
|
||||
|
||||
function shortName(path) {
|
||||
return path ? path.split("/").pop() : ""
|
||||
}
|
||||
|
||||
function scrubTimeLabel(idx) {
|
||||
var t = idx < root.timeDataA.length ? root.timeDataA[idx]
|
||||
: (idx < root.timeDataB.length ? root.timeDataB[idx] : null)
|
||||
return t !== null ? " (" + t.toFixed(1) + "s)" : ""
|
||||
}
|
||||
|
||||
function formatMinutes(secondsValue) {
|
||||
var mins = Math.floor(secondsValue / 60)
|
||||
var secs = secondsValue % 60
|
||||
return mins + ":" + (secs < 10 ? "0" : "") + secs.toFixed(1)
|
||||
}
|
||||
|
||||
function frameForTime(t) {
|
||||
var times = root.timeDataA.length > 0 ? root.timeDataA : root.timeDataB
|
||||
if (times.length === 0) return 0
|
||||
var closestIdx = 0
|
||||
var minDiff = Math.abs(times[0] - t)
|
||||
for (var i = 1; i < times.length; i++) {
|
||||
var diff = Math.abs(times[i] - t)
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff
|
||||
closestIdx = i
|
||||
}
|
||||
}
|
||||
return closestIdx
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
root.warpingDistance = -1
|
||||
root.frameOffset = 0
|
||||
root.frameOffsetSeconds = 0
|
||||
root.maxSensorDeviation = -1
|
||||
root.trendDataA = []
|
||||
root.trendDataB = []
|
||||
root.timeDataA = []
|
||||
root.timeDataB = []
|
||||
root.frameCountA = 0
|
||||
root.frameCountB = 0
|
||||
root.compareSensorLayout = []
|
||||
root.compareSensorDiff = []
|
||||
root.errorMessage = ""
|
||||
root.mismatchMessage = ""
|
||||
mismatchHideTimer.stop()
|
||||
}
|
||||
|
||||
function resetComparison() {
|
||||
root.compareFileA = ""
|
||||
root.compareFileB = ""
|
||||
root.useMasterFile = false
|
||||
root.comparing = false
|
||||
root.activeBox = 1
|
||||
clearResults()
|
||||
}
|
||||
|
||||
Component.onCompleted: file_browser.refreshFiles()
|
||||
|
||||
Timer {
|
||||
id: mismatchHideTimer
|
||||
interval: 6000
|
||||
onTriggered: root.mismatchMessage = ""
|
||||
}
|
||||
|
||||
// Clicking a file in the left rail's browser populates the armed run slot
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onParsedDataReady(result) {
|
||||
target: streamController
|
||||
function onLoadedFileChanged() {
|
||||
if (streamController.loadedFile && root.activeBox > 0) {
|
||||
if (root.activeBox === 1) {
|
||||
root.compareFileA = streamController.loadedFile
|
||||
root.activeBox = 2 // Auto-advance to box 2
|
||||
} else if (root.activeBox === 2) {
|
||||
root.compareFileB = streamController.loadedFile
|
||||
root.activeBox = 0 // Finished selecting
|
||||
}
|
||||
}
|
||||
}
|
||||
function onComparisonResult(result) {
|
||||
root.comparing = false
|
||||
if (result && result.success) {
|
||||
dataTable.forceLayout();
|
||||
root.warpingDistance = result.distance
|
||||
root.frameOffset = result.frame_offset !== undefined ? result.frame_offset : 0
|
||||
root.frameOffsetSeconds = result.frame_offset_seconds !== undefined ? result.frame_offset_seconds : 0
|
||||
root.maxSensorDeviation = result.max_sensor_deviation !== undefined ? result.max_sensor_deviation : -1
|
||||
root.trendDataA = result.series_a || []
|
||||
root.trendDataB = result.series_b || []
|
||||
root.timeDataA = result.time_a || []
|
||||
root.timeDataB = result.time_b || []
|
||||
root.frameCountA = result.frame_count_a || 0
|
||||
root.frameCountB = result.frame_count_b || 0
|
||||
root.compareSensorLayout = result.sensor_layout || []
|
||||
root.compareSensorDiff = result.sensor_diff || []
|
||||
root.compareWaferShape = result.wafer_shape || "round"
|
||||
root.compareWaferSize = result.wafer_size || 300.0
|
||||
root.errorMessage = ""
|
||||
chartCard.frameIndex = Math.floor(root.seriesLen / 2)
|
||||
chartCard.focusScrubber()
|
||||
if (root.frameCountA !== root.frameCountB) {
|
||||
var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
|
||||
var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
|
||||
root.mismatchMessage = "Run A: " + root.frameCountA + " frames (" + timeA.toFixed(1) + "s) · "
|
||||
+ "Run B: " + root.frameCountB + " frames (" + timeB.toFixed(1) + "s). "
|
||||
+ "Deviation beyond the shorter run shows as \"none\"."
|
||||
mismatchHideTimer.restart()
|
||||
} else {
|
||||
root.mismatchMessage = ""
|
||||
mismatchHideTimer.stop()
|
||||
}
|
||||
} else {
|
||||
root.clearResults()
|
||||
root.errorMessage = (result && result.error) ? result.error : "Comparison failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Empty State =====
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: 16
|
||||
visible: root.rowCount === 0
|
||||
|
||||
Label {
|
||||
text: "No Data"
|
||||
font.pixelSize: Theme.font2xl
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Read and parse wafer data to see temperature readings here."
|
||||
font.pixelSize: Theme.fontMd
|
||||
color: Theme.bodyColor
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Data Table =====
|
||||
// ── Main Layout ───────────────────────────────────────────────
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
visible: root.rowCount > 0
|
||||
|
||||
// --- Header row ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
Label {
|
||||
text: "Temperature Data"
|
||||
font.pixelSize: Theme.fontXl
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Label {
|
||||
text: deviceController.dataRowCount + " rows × " + deviceController.dataColCount + " sensors"
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
|
||||
// --- Toolbar ---
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Button {
|
||||
text: "Open in Excel"
|
||||
icon.source: "icons/excel.svg"
|
||||
icon.color: Theme.headingColor
|
||||
onClicked: deviceController.openCsvFile()
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Compare"
|
||||
onClicked: compareDialog.open()
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Split"
|
||||
onClicked: splitDialog.open()
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
// --- Table container ---
|
||||
// ── Error Banner ───────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: Theme.borderThin
|
||||
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||
visible: root.errorMessage !== ""
|
||||
color: Theme.errorSurface
|
||||
border.color: Theme.statusErrorColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
RowLayout {
|
||||
id: errorRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 1
|
||||
spacing: 0
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
// Column header strip
|
||||
HorizontalHeaderView {
|
||||
id: headerView
|
||||
Layout.fillWidth: true
|
||||
syncView: dataTable
|
||||
clip: true
|
||||
|
||||
delegate: Rectangle {
|
||||
implicitHeight: 28
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
// 'display' is the Qt.DisplayRole value from headerData()
|
||||
text: display !== undefined ? display : ""
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.errorTextSoft
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
// Data rows
|
||||
TableView {
|
||||
id: dataTable
|
||||
Label {
|
||||
id: errorLabel
|
||||
text: root.errorMessage
|
||||
color: Theme.errorTextSoft
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body: main column | side panel ─────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 16
|
||||
|
||||
// --- Left Column: chart + scrubber, wafer overlap map ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumWidth: 350
|
||||
spacing: 12
|
||||
|
||||
// Wafer Map Overlap View card
|
||||
OverlapMapCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
model: deviceController.dataModel
|
||||
clip: true
|
||||
reuseItems: true
|
||||
Layout.minimumHeight: 220
|
||||
sensorLayout: root.compareSensorLayout
|
||||
diffValues: streamController.getSensorDiffAt(Math.round(chartCard.frameIndex))
|
||||
diffBands: root.diffBands
|
||||
waferShape: root.compareWaferShape
|
||||
waferSize: root.compareWaferSize
|
||||
overlapEnabled: sidePanel.overlapEnabled
|
||||
blendAmount: sidePanel.blendAmount
|
||||
}
|
||||
|
||||
columnWidthProvider: function (col) {
|
||||
if (col === 0)
|
||||
return 52; // row index column
|
||||
const sensorCols = Math.max(1, dataTable.columns - 1);
|
||||
const available = dataTable.width - 52;
|
||||
return Math.max(52, Math.min(90, available / sensorCols));
|
||||
}
|
||||
// Chart card with alignment scrubber
|
||||
ComparisonChartCard {
|
||||
id: chartCard
|
||||
Layout.fillWidth: true
|
||||
trendDataA: root.trendDataA
|
||||
trendDataB: root.trendDataB
|
||||
seriesLen: root.seriesLen
|
||||
hasSeries: root.hasSeries
|
||||
comparing: root.comparing
|
||||
}
|
||||
}
|
||||
|
||||
rowHeightProvider: function () {
|
||||
return 24;
|
||||
}
|
||||
// --- Right Column: run selection, overlap settings, readout ---
|
||||
ComparisonSidePanel {
|
||||
id: sidePanel
|
||||
Layout.fillWidth: false
|
||||
Layout.preferredWidth: Theme.rightRailWidth
|
||||
Layout.minimumWidth: Theme.rightRailWidth
|
||||
Layout.maximumWidth: Theme.rightRailWidth
|
||||
Layout.fillHeight: true
|
||||
warpingDistance: root.warpingDistance
|
||||
frameOffset: root.frameOffset
|
||||
frameOffsetSeconds: root.frameOffsetSeconds
|
||||
maxSensorDeviation: root.maxSensorDeviation
|
||||
seriesLen: root.seriesLen
|
||||
hasSeries: root.hasSeries
|
||||
trendDataA: root.trendDataA
|
||||
trendDataB: root.trendDataB
|
||||
timeDataA: root.timeDataA
|
||||
timeDataB: root.timeDataB
|
||||
frameForTime: root.frameForTime
|
||||
frameIndex: chartCard.frameIndex
|
||||
onFrameIndexRequested: (idx) => chartCard.frameIndex = idx
|
||||
onScrubberFocusRequested: chartCard.focusScrubber()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delegate: Rectangle {
|
||||
color: row % 2 === 0 ? Theme.panelBackground : Theme.cardBackground
|
||||
// ── Frame Mismatch Banner (amber, floats over content, auto-hides) ──
|
||||
Rectangle {
|
||||
id: mismatchBanner
|
||||
visible: root.mismatchMessage !== ""
|
||||
z: 100
|
||||
anchors.top: parent.top
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.topMargin: 12
|
||||
width: Math.min(parent.width - 48, 640)
|
||||
height: mismatchRow.implicitHeight + 16
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.statusWarningColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: display !== undefined ? display : ""
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
id: mismatchRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
ScrollBar.horizontal: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.statusWarningColor
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.mismatchMessage
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
flat: true
|
||||
implicitWidth: 24
|
||||
implicitHeight: 24
|
||||
Layout.alignment: Qt.AlignTop
|
||||
onClicked: {
|
||||
root.mismatchMessage = ""
|
||||
mismatchHideTimer.stop()
|
||||
}
|
||||
background: Rectangle { color: "transparent" }
|
||||
contentItem: Label {
|
||||
text: "✕"
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,222 +1,49 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Wafer
|
||||
import ISC.Tabs.components
|
||||
|
||||
// ===== Graph Tab =====
|
||||
// Displays sensor temperature line charts using GraphQuickItem.
|
||||
// Gets data from deviceController.getChartData() (parsed CSV, batch read)
|
||||
// or from streamController.trendData (live streaming trend).
|
||||
// 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
|
||||
|
||||
property var _chartData: null // cached result from getChartData()
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: Theme.rightPaneGap
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Label {
|
||||
text: "Line Chart"
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
// Refresh button — pulls data from deviceController
|
||||
Button {
|
||||
id: refreshBtn
|
||||
text: "REFRESH"
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Medium
|
||||
implicitHeight: 26
|
||||
implicitWidth: 72
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
onClicked: reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode selector (Trend / Full chart) ─────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: "Source:"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: sourceSelector
|
||||
model: ["Parsed Data", "Live Trend"]
|
||||
currentIndex: 0
|
||||
implicitHeight: 26
|
||||
font.pixelSize: Theme.fontXs
|
||||
background: Rectangle {
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
contentItem: Text {
|
||||
text: sourceSelector.displayText
|
||||
color: Theme.fieldText
|
||||
font: sourceSelector.font
|
||||
horizontalAlignment: Text.AlignLeft
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: 8
|
||||
}
|
||||
onCurrentIndexChanged: reloadData()
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Label {
|
||||
id: statusLabel
|
||||
text: "Ready"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.italic: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chart Area ─────────────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
clip: true
|
||||
|
||||
GraphQuickItem {
|
||||
id: graph
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
backgroundColor: Theme.cardBackground
|
||||
gridColor: Theme.softBorder
|
||||
axisColor: Theme.bodyColor
|
||||
textColor: Theme.headingColor
|
||||
seriesColors: [Theme.sensorLow, Theme.sensorHigh, Theme.sensorInRange, "#FFA726", "#AB47BC", "#00BCD4"]
|
||||
showLegend: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
function reloadData() {
|
||||
if (sourceSelector.currentIndex === 0) {
|
||||
// Parsed data from DeviceController
|
||||
loadParsedData();
|
||||
} else {
|
||||
// Live trend from SessionController
|
||||
loadLiveTrend();
|
||||
}
|
||||
}
|
||||
|
||||
function loadParsedData() {
|
||||
statusLabel.text = "Loading...";
|
||||
var result = deviceController.getChartData();
|
||||
if (!result || !result.success) {
|
||||
graph.seriesData = [];
|
||||
graph.sensorNames = [];
|
||||
graph.title = "Sensor Temperature Over Time";
|
||||
statusLabel.text = "No data — read a wafer first";
|
||||
return;
|
||||
}
|
||||
|
||||
graph.seriesData = result.series || [];
|
||||
graph.sensorNames = result.sensor_names || [];
|
||||
graph.title = "Sensor Temperature Over Time";
|
||||
statusLabel.text = (result.series ? result.series.length : 0) + " sensors loaded";
|
||||
}
|
||||
|
||||
function loadLiveTrend() {
|
||||
// Live trend: connects to streamController.trendData
|
||||
// The trend data comes as a JSON array of per-frame averages
|
||||
statusLabel.text = "Connecting to live trend...";
|
||||
// We need at least one data point
|
||||
if (streamController.stats && streamController.stats.avg !== undefined) {
|
||||
// Build a single-series graph from the trend buffer
|
||||
// For now show a placeholder — the trendData signal handles updates
|
||||
graph.title = "Live Average Temperature";
|
||||
graph.yLabel = "Avg Temperature (°C)";
|
||||
graph.xLabel = "Time (s)";
|
||||
statusLabel.text = "Live trend — waiting for data...";
|
||||
} else {
|
||||
statusLabel.text = "No live data — start a stream on the Wafer Map tab";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connections ─────────────────────────────────────────────────────────────
|
||||
|
||||
Connections {
|
||||
target: deviceController
|
||||
function onParsedDataReady(result) {
|
||||
if (sourceSelector.currentIndex === 0) {
|
||||
root.reloadData();
|
||||
}
|
||||
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 onTrendData(avgsJson) {
|
||||
if (sourceSelector.currentIndex === 1) {
|
||||
try {
|
||||
var avgs = JSON.parse(avgsJson);
|
||||
if (avgs && avgs.length > 0) {
|
||||
graph.seriesData = [avgs];
|
||||
graph.sensorNames = ["Average"];
|
||||
graph.title = "Live Average Temperature";
|
||||
graph.yLabel = "Avg Temperature (°C)";
|
||||
graph.xLabel = "Frame";
|
||||
statusLabel.text = "Live: " + avgs.length + " data points";
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
function onLoadedFileChanged() { root.refresh() }
|
||||
}
|
||||
|
||||
// On startup, try to load parsed data if available
|
||||
Component.onCompleted: {
|
||||
if (deviceController.dataRowCount > 0) {
|
||||
reloadData();
|
||||
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.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -123,19 +125,17 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
|
||||
Text {
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
text: "✓"
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
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 }
|
||||
}
|
||||
// Small nudge to center the checkmark perfectly
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ Item {
|
||||
width: 22
|
||||
height: 22
|
||||
radius: height / 2
|
||||
color: "white"
|
||||
color: Theme.toggleThumb
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Theme.isDarkMode ? parent.width - width - 3 : 3
|
||||
|
||||
@@ -452,12 +452,9 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
text: "↓ Scroll for more"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
color: Theme.bodyColor
|
||||
spacing: 4
|
||||
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
|
||||
|
||||
Behavior on opacity {
|
||||
@@ -465,6 +462,22 @@ Item {
|
||||
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.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
|
||||
@@ -29,52 +30,61 @@ ColumnLayout {
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 4
|
||||
Text {
|
||||
id: labelText
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.5
|
||||
font.family: Theme.uiFontFamily
|
||||
}
|
||||
Text {
|
||||
id: valueText
|
||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||
font.pixelSize: valSize
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
Text {
|
||||
id: labelText
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Medium
|
||||
font.letterSpacing: 1.2
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
fontSizeMode: Text.HorizontalFit
|
||||
minimumPixelSize: Theme.fontMd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stacked stat block: small label over big value, centered in its grid
|
||||
// quadrant so the card's height is used instead of leaving dead space.
|
||||
component GridCell : ColumnLayout {
|
||||
property alias label: labelText.text
|
||||
property alias value: valueText.text
|
||||
property bool isRight: false
|
||||
property bool active: root.waferDetected
|
||||
|
||||
spacing: 1
|
||||
Layout.alignment: isRight ? Qt.AlignRight : Qt.AlignLeft
|
||||
spacing: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
Text {
|
||||
id: labelText
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.alignment: isRight ? Qt.AlignRight : Qt.AlignLeft
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
id: valueText
|
||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.pixelSize: Theme.fontXl
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.alignment: isRight ? Qt.AlignRight : Qt.AlignLeft
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// ── Aliases for external wiring ──
|
||||
@@ -112,21 +122,75 @@ ColumnLayout {
|
||||
id: saveDirDialog
|
||||
title: "Choose a folder to save."
|
||||
onAccepted: {
|
||||
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder));
|
||||
var dir = root.cleanFolderUrl(selectedFolder);
|
||||
deviceController.setSaveDataDir(dir);
|
||||
file_browser.setCurrentDirectory(dir);
|
||||
root.parseAndSavePendingRead();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ROW 1: Connection Status (100%)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 90
|
||||
Layout.maximumHeight: 90
|
||||
spacing: 8
|
||||
// Directory-only picker for the DIRECTORY card button — sets the save dir
|
||||
// without kicking off a parse/save of pending read data.
|
||||
FolderDialog {
|
||||
id: dirOnlyDialog
|
||||
title: "Choose a folder to save Data"
|
||||
onAccepted: {
|
||||
var dir = root.cleanFolderUrl(selectedFolder);
|
||||
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 {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
@@ -135,43 +199,24 @@ ColumnLayout {
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
id: connStatusCol
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: Theme.panelPadding
|
||||
anchors.rightMargin: Theme.panelPadding
|
||||
spacing: 8
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: "CONNECTION STATUS"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Medium
|
||||
font.letterSpacing: 1.2
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.5
|
||||
font.family: Theme.uiFontFamily
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
spacing: 8
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
@@ -209,7 +254,7 @@ ColumnLayout {
|
||||
id: statusPillText
|
||||
anchors.centerIn: parent
|
||||
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
||||
color: "#111111"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
@@ -236,218 +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
|
||||
}
|
||||
Text {
|
||||
text: root.portName
|
||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 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
|
||||
// ── Wafer & Sensor Data: far right, spans both rows ──
|
||||
Rectangle {
|
||||
Layout.rowSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 8
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
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: 4
|
||||
|
||||
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
|
||||
|
||||
Button {
|
||||
id: browseBtn
|
||||
text: "Browse"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.uiFontFamily
|
||||
implicitHeight: 24
|
||||
implicitWidth: 60
|
||||
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()
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Row {
|
||||
spacing: 4
|
||||
visible: root.isConnected
|
||||
Rectangle {
|
||||
width: 6; height: 6; radius: 3
|
||||
color: Theme.statusSuccessColor
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
columns: 2
|
||||
rowSpacing: 6
|
||||
columnSpacing: 16
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 8
|
||||
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] : "—";
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Rectangle {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredHeight: 1
|
||||
color: Theme.cardBorder
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
anchors.fill: parent
|
||||
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: waferCyclesCell
|
||||
label: "Cycles Completed"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||
}
|
||||
isRight: true
|
||||
active: root.waferDetected
|
||||
}
|
||||
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]) : "—";
|
||||
}
|
||||
isRight: true
|
||||
}
|
||||
GridCell {
|
||||
id: waferCyclesCell
|
||||
label: "Cycles Completed"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||
}
|
||||
active: root.waferDetected
|
||||
}
|
||||
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 {
|
||||
id: runtimeCard
|
||||
Layout.preferredWidth: 35
|
||||
label: "TOTAL RUNTIME"
|
||||
value: {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,10 +498,20 @@ ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 36
|
||||
color: Theme.subtleSectionBackground
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 36
|
||||
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 {
|
||||
anchors.fill: parent
|
||||
@@ -478,66 +519,27 @@ ColumnLayout {
|
||||
anchors.rightMargin: Theme.panelPadding
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: "📄"
|
||||
font.pixelSize: Theme.fontSm
|
||||
IconImage {
|
||||
source: "icons/file-text.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.sideMutedText
|
||||
visible: root.csvPath !== ""
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
|
||||
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
|
||||
font.weight: Font.Medium
|
||||
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
font.family: Theme.uiFontFamily
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -558,7 +560,9 @@ ColumnLayout {
|
||||
TextArea {
|
||||
id: activityLog
|
||||
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
|
||||
font.family: "monospace"
|
||||
font.pixelSize: Theme.fontSm
|
||||
@@ -569,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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -626,6 +624,13 @@ ColumnLayout {
|
||||
root.dataRows = result.rows;
|
||||
root.dataCols = result.cols;
|
||||
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 {
|
||||
root.dataParsed = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
@@ -10,30 +10,26 @@ Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
// Live elapsed-time counter
|
||||
property int _liveSecs: 0
|
||||
Timer {
|
||||
id: liveTimer
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||
onTriggered: root._liveSecs++
|
||||
onRunningChanged: if (!running)
|
||||
root._liveSecs = 0
|
||||
}
|
||||
function fmtTime(s) {
|
||||
var m = Math.floor(s / 60);
|
||||
var ss = s % 60;
|
||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
||||
}
|
||||
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
|
||||
// into the trend chart's data property. The slot parseJsonToData() is defined
|
||||
// on the Python TrendChartItem; we use it so malformed payloads are logged
|
||||
// there instead of crashing the QML binding.
|
||||
// Wire streamController.trendDelta (Signal(str) carrying one new
|
||||
// [elapsed_s, value] pair per live frame) into the trend chart, which
|
||||
// accumulates its own bounded 60s window. trendReset clears it when a
|
||||
// new stream starts. Parsing happens on the Python TrendChartItem side
|
||||
// so malformed payloads are logged there instead of crashing the QML
|
||||
// binding.
|
||||
property string loadErrorMessage: ""
|
||||
Connections {
|
||||
target: streamController
|
||||
function onTrendData(avgsJson) {
|
||||
trendChart.setDataFromJson(avgsJson);
|
||||
function onTrendDelta(deltaJson) {
|
||||
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
|
||||
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 {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
@@ -205,44 +97,40 @@ Item {
|
||||
font.letterSpacing: 1.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LIVE timer
|
||||
// RowLayout {
|
||||
// spacing: 5
|
||||
// visible: streamController.mode === "live" && streamController.state !== "idle"
|
||||
// Rectangle {
|
||||
// width: 7; height: 7; radius: 4
|
||||
// color: Theme.liveColor
|
||||
// }
|
||||
// Label {
|
||||
// text: "LIVE " + root.fmtTime(root._liveSecs)
|
||||
// color: Theme.liveColor
|
||||
// font.pixelSize: Theme.fontXs
|
||||
// font.weight: Font.Medium
|
||||
// font.letterSpacing: 0.8
|
||||
// }
|
||||
// }
|
||||
// ── Load error banner ───────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||
visible: root.loadErrorMessage !== ""
|
||||
color: Theme.errorSurface
|
||||
border.color: Theme.statusErrorColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
// IDLE label (review / not connected)
|
||||
// Label {
|
||||
// visible: streamController.mode === "review" || streamController.state === "idle"
|
||||
// text: streamController.state.toUpperCas`e`()
|
||||
// color: Theme.bodyColor
|
||||
// font.pixelSize: Theme.fontXs
|
||||
// font.weight: Font.Medium
|
||||
// font.letterSpacing: 1.2
|
||||
// }
|
||||
Button {
|
||||
text: "Export PNG"
|
||||
onClicked: exportDialog.open()
|
||||
}
|
||||
RowLayout {
|
||||
id: errorRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
FileDialog {
|
||||
id: exportDialog
|
||||
title: "Export Wafer Map"
|
||||
fileMode: FileDialog.SaveFile
|
||||
nameFilters: ["PNG files (*.png)"]
|
||||
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.errorTextSoft
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.loadErrorMessage
|
||||
color: Theme.errorTextSoft
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,55 +144,78 @@ Item {
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 8
|
||||
spacing: 16
|
||||
|
||||
WaferMapView {
|
||||
id: waferView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredHeight: 400
|
||||
Layout.minimumHeight: 280
|
||||
blend: readoutPanel.heatmapBlend
|
||||
showLabels: readoutPanel.showLabels
|
||||
showExtremes: readoutPanel.showExtremes
|
||||
showThickness: readoutPanel.showThickness
|
||||
}
|
||||
Rectangle {
|
||||
id: trendPane
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 160
|
||||
Layout.fillHeight: false
|
||||
Layout.preferredHeight: 220
|
||||
Layout.minimumHeight: 120
|
||||
visible: trendChart.hasData && streamController.mode === "live"
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
|
||||
TrendChartItem {
|
||||
id: trendChart
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 10
|
||||
|
||||
TrendChartItem {
|
||||
id: trendChart
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 16
|
||||
visible: transportBar.hasContent
|
||||
implicitHeight: trendPane.visible ? 52 : 16
|
||||
}
|
||||
TransportBar {
|
||||
id: transportBar
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// Readout panel — floating wrapper box
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 220
|
||||
// Readout panel — scrolls instead of clipping so the last card
|
||||
// (Thresholds) is always reachable even if content outgrows
|
||||
// the available height (e.g. once E-C delta rows appear).
|
||||
ScrollView {
|
||||
Layout.preferredWidth: Theme.rightRailWidth
|
||||
Layout.fillHeight: true
|
||||
color: "transparent"
|
||||
border.color: "transparent"
|
||||
border.width: 0
|
||||
clip: true
|
||||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||
|
||||
ReadoutPanel {
|
||||
id: readoutPanel
|
||||
anchors.fill: parent
|
||||
anchors.margins: 0
|
||||
width: parent.width
|
||||
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 iconSource: ""
|
||||
property bool destructive: false
|
||||
// Set true to show the button as "active" (e.g. a one-shot toggle
|
||||
// that's mid-operation), independent of hover/press state.
|
||||
property bool toggled: false
|
||||
property color _textColor: !root.enabled ? Theme.sideMutedText
|
||||
: root.destructive ? Theme.statusErrorColor
|
||||
: Theme.headingColor
|
||||
@@ -20,7 +23,7 @@ Rectangle {
|
||||
|
||||
implicitHeight: Theme.sideButtonHeight
|
||||
radius: 8
|
||||
color: mouseArea.containsMouse || mouseArea.pressed
|
||||
color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
|
||||
? Theme.sideActiveBackground : "transparent"
|
||||
border.width: 0
|
||||
|
||||
|
||||
@@ -1,61 +1,26 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ===== Readout Panel =====
|
||||
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS.
|
||||
// Cards match the left-rail SOURCE / CONNECTION FLOW style.
|
||||
// Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
|
||||
// THRESHOLDS. Uses the same PanelBox / SectionTitle / ReadoutStat /
|
||||
// PanelCheckBox / PanelSlider components as the Data tab's
|
||||
// ComparisonSidePanel, so both right rails share one visual language.
|
||||
ColumnLayout {
|
||||
id: root
|
||||
spacing: 6
|
||||
property var s: streamController.stats
|
||||
property alias showLabels: labelsToggle.checked
|
||||
property alias showExtremes: extremesToggle.checked
|
||||
property alias heatmapBlend: heatmapSlider.value
|
||||
property alias showThickness: thicknessToggle.checked
|
||||
property bool hasThicknessData: false
|
||||
|
||||
component PanelCheckBox: CheckBox {
|
||||
id: toggle
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 18
|
||||
implicitHeight: 18
|
||||
x: toggle.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: Theme.radiusXs
|
||||
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||
border.width: Theme.borderThin
|
||||
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
signal exportRequested(string filePath, string extra)
|
||||
signal thicknessFileChosen(string filePath)
|
||||
|
||||
component BadgePill: Rectangle {
|
||||
implicitWidth: badgeLabel.implicitWidth + 10
|
||||
@@ -75,279 +40,127 @@ ColumnLayout {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
StreamControlPanel {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: readoutCard.implicitHeight + 24
|
||||
color: Theme.sidePanelBackground
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
onExportRequested: function(filePath, extra) {
|
||||
root.exportRequested(filePath, extra);
|
||||
}
|
||||
}
|
||||
|
||||
// ── READOUT ─────────────────────────────────────────────────────
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: readoutCol.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: readoutCard
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
topMargin: 12
|
||||
}
|
||||
spacing: 0
|
||||
id: readoutCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
// Header Inside the Box
|
||||
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
|
||||
}
|
||||
SectionTitle { text: "READOUT" }
|
||||
|
||||
// Min Temp Row
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 14
|
||||
Layout.rightMargin: 14
|
||||
Layout.preferredHeight: 32
|
||||
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 {
|
||||
label: "Min Temp"
|
||||
value: s.min !== undefined
|
||||
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
|
||||
: "—"
|
||||
valueColor: Theme.sensorLow
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
ReadoutStat {
|
||||
label: "Max Temp"
|
||||
value: s.max !== undefined
|
||||
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||
: "—"
|
||||
valueColor: Theme.sensorHigh
|
||||
}
|
||||
|
||||
// Max Temp Row
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
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 {
|
||||
label: "Diff"
|
||||
value: s.diff !== undefined ? s.diff : "—"
|
||||
valueColor: Theme.diffAccent
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
ReadoutStat {
|
||||
label: "Average"
|
||||
value: s.avg !== undefined ? s.avg : "—"
|
||||
}
|
||||
|
||||
// Differential Row
|
||||
RowLayout {
|
||||
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 {
|
||||
label: "Sigma (Σ)"
|
||||
value: s.sigma !== undefined ? s.sigma : "—"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
ReadoutStat {
|
||||
label: "3Σ Value"
|
||||
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||
}
|
||||
|
||||
// Average Row
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 14
|
||||
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 {
|
||||
visible: s.ecMinDelta !== undefined
|
||||
label: "E-C Δ Min"
|
||||
value: s.ecMinDelta !== undefined
|
||||
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||
: "—"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
ReadoutStat {
|
||||
visible: s.ecMaxDelta !== undefined
|
||||
label: "E-C Δ Max"
|
||||
value: s.ecMaxDelta !== undefined
|
||||
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||
: "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// ── DISPLAY ─────────────────────────────────────────────────────
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: displayCard.implicitHeight + 24
|
||||
color: Theme.sidePanelBackground
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
Layout.preferredHeight: displayCard.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: displayCard
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 12
|
||||
}
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: "DISPLAY"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 4
|
||||
}
|
||||
SectionTitle { text: "DISPLAY" }
|
||||
|
||||
PanelCheckBox {
|
||||
id: thicknessToggle
|
||||
text: "Show Thickness"
|
||||
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 {
|
||||
id: labelsToggle
|
||||
text: "Labels"
|
||||
checked: true
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: extremesToggle
|
||||
text: "Highlight Min/Max"
|
||||
checked: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: clusterAverageToggle
|
||||
text: "Average Clusters"
|
||||
checked: streamController.clusterAveragingEnabled
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
onCheckedChanged: streamController.clusterAveragingEnabled = checked
|
||||
}
|
||||
|
||||
@@ -363,35 +176,25 @@ ColumnLayout {
|
||||
Label {
|
||||
text: "Heatmap"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
Layout.preferredWidth: 52
|
||||
}
|
||||
Slider {
|
||||
PanelSlider {
|
||||
id: heatmapSlider
|
||||
from: 0
|
||||
to: 1
|
||||
value: 0
|
||||
Layout.fillWidth: true
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: Math.round(value * 100) + "%"
|
||||
|
||||
handle: Rectangle {
|
||||
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width)
|
||||
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2
|
||||
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 }
|
||||
}
|
||||
AppToolTip {
|
||||
visible: heatmapSlider.hovered
|
||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: Math.round(heatmapSlider.value * 100) + "%"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
Layout.preferredWidth: 32
|
||||
horizontalAlignment: Text.AlignRight
|
||||
@@ -400,38 +203,23 @@ ColumnLayout {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// ── THRESHOLDS ──────────────────────────────────────────────────
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: thresholdCard.implicitHeight + 24
|
||||
color: Theme.sidePanelBackground
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
Layout.preferredHeight: thresholdCard.implicitHeight + 24
|
||||
|
||||
ColumnLayout {
|
||||
id: thresholdCard
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 12
|
||||
}
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "THRESHOLDS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 4
|
||||
}
|
||||
SectionTitle { text: "THRESHOLDS" }
|
||||
|
||||
Label {
|
||||
text: "Set Point (°C)"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
TextField {
|
||||
id: spField
|
||||
@@ -461,7 +249,7 @@ ColumnLayout {
|
||||
Label {
|
||||
text: "Margin (±°C)"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
TextField {
|
||||
id: mgField
|
||||
@@ -492,7 +280,7 @@ ColumnLayout {
|
||||
id: autoCheck
|
||||
text: "Auto range (mean ± 1σ)"
|
||||
checked: true
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
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
|
||||
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
|
||||
property int sensorIndex: -1
|
||||
property string sensorLabel: ""
|
||||
@@ -60,7 +64,7 @@ Dialog {
|
||||
id: overrideTag
|
||||
anchors.centerIn: parent
|
||||
text: "OVERRIDE"
|
||||
color: "white"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
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.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.qmlmodels
|
||||
import ISC
|
||||
@@ -181,10 +182,17 @@ Dialog {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
text: "✕"
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
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();
|
||||
}
|
||||
|
||||
ToolTip {
|
||||
id: editTooltip
|
||||
AppToolTip {
|
||||
visible: editBtn.hovered
|
||||
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()
|
||||
|
||||
ToolTip {
|
||||
id: refreshTooltip
|
||||
AppToolTip {
|
||||
visible: refreshBtn.hovered
|
||||
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 {
|
||||
spacing: 6
|
||||
Label {
|
||||
text: "▤"
|
||||
IconImage {
|
||||
source: "../icons/folder.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
Label {
|
||||
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
||||
@@ -188,16 +160,52 @@ ColumnLayout {
|
||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||
}
|
||||
|
||||
// Active only in review mode; no highlight during live streaming
|
||||
readonly property bool isActive: streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile
|
||||
readonly property var dataTab: {
|
||||
try {
|
||||
return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// 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
|
||||
height: matchesFilter ? implicitHeight : 0
|
||||
enabled: !liveFileBlocked
|
||||
opacity: liveFileBlocked ? 0.45 : 1.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
|
||||
onClicked: streamController.loadFile(modelData.fileName)
|
||||
|
||||
background: Rectangle {
|
||||
color: fileItem.isActive ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
|
||||
color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
|
||||
radius: Theme.radiusSm
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
@@ -216,13 +224,15 @@ ColumnLayout {
|
||||
bottomMargin: 5
|
||||
}
|
||||
color: {
|
||||
if (fileItem.isRunA) return Theme.primaryAccent;
|
||||
if (fileItem.isRunB) return Theme.themeSkill;
|
||||
var t = modelData.waferType;
|
||||
if (t === "A" || t === "E" || t === "P")
|
||||
return "#3B82F6";
|
||||
return Theme.familyBlueAccent;
|
||||
if (t === "B" || t === "C" || t === "D")
|
||||
return "#10B981";
|
||||
return Theme.familyGreenAccent;
|
||||
if (t === "Z")
|
||||
return "#8B5CF6";
|
||||
return Theme.familyVioletAccent;
|
||||
return Theme.headingColor;
|
||||
}
|
||||
}
|
||||
@@ -242,19 +252,46 @@ ColumnLayout {
|
||||
color: {
|
||||
var t = modelData.waferType;
|
||||
if (t === "A" || t === "E" || t === "P")
|
||||
return "#1D4ED8";
|
||||
return Theme.familyBlueFill;
|
||||
if (t === "B" || t === "C" || t === "D")
|
||||
return "#065F46";
|
||||
return Theme.familyGreenFill;
|
||||
if (t === "Z")
|
||||
return "#7C3AED";
|
||||
return "#374151";
|
||||
return Theme.familyVioletFill;
|
||||
return Theme.familyNeutralFill;
|
||||
}
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.waferType || "?"
|
||||
font.pixelSize: Theme.fontMd
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,14 +300,57 @@ ColumnLayout {
|
||||
spacing: 1
|
||||
Layout.fillWidth: true
|
||||
|
||||
// Primary: wafer type + serial number
|
||||
Label {
|
||||
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
elide: Text.ElideRight
|
||||
// Primary: wafer type + serial number, plus a REC badge for recordings
|
||||
RowLayout {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
|
||||
// ===== Split Dialog (Threshold Segmentation) =====
|
||||
// Modal popup for segmenting temperature profile into Ramp/Soak/Cool phases.
|
||||
// Backend: splitter.segment_profile() returns list of DataSegment objects.
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: Math.min(parent.width - 100, 800)
|
||||
height: Math.min(parent.height - 100, 600)
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
property real threshold: 40.0
|
||||
property bool segmenting: false
|
||||
property var segments: []
|
||||
property string currentFile: streamController.loadedFile || ""
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
// ── Header ────────────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Label {
|
||||
text: "SPLIT DATA"
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "\u2715"
|
||||
flat: true
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: root.close()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.bodyColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Threshold Input ───────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 16
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: "THRESHOLD TEMPERATURE (°C)"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
SpinBox {
|
||||
id: thresholdSpin
|
||||
from: 0
|
||||
to: 200
|
||||
value: root.threshold
|
||||
stepSize: 1
|
||||
editable: true
|
||||
Layout.fillWidth: true
|
||||
background: Rectangle {
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "°C"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: "SESSION CSV"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
text: root.currentFile !== ""
|
||||
? root.currentFile.split("/").pop()
|
||||
: "No file loaded"
|
||||
color: root.currentFile !== "" ? Theme.bodyColor : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Split Button ──────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Button {
|
||||
id: splitBtn
|
||||
text: "SPLIT"
|
||||
Layout.fillWidth: true
|
||||
enabled: !root.segmenting && root.currentFile !== ""
|
||||
onClicked: {
|
||||
root.threshold = thresholdSpin.value;
|
||||
root.segmenting = true;
|
||||
streamController.splitData(root.currentFile, root.threshold);
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: splitBtn.enabled ? Theme.primaryAccent : Theme.buttonNeutralBackground
|
||||
border.color: splitBtn.enabled ? Theme.primaryAccent : "transparent"
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 8
|
||||
anchors.centerIn: parent
|
||||
|
||||
Label {
|
||||
text: splitBtn.enabled ? (root.segmenting ? "Segmenting..." : "SPLIT") : "LOAD DATA FIRST"
|
||||
color: splitBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 1.0
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
running: root.segmenting
|
||||
visible: root.segmenting
|
||||
width: 16
|
||||
height: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Segments Table ────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 0
|
||||
spacing: 0
|
||||
|
||||
// Table header
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 36
|
||||
color: Theme.subtleSectionBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Label { text: "PHASE"; Layout.preferredWidth: 100; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
|
||||
Item { Layout.preferredWidth: 80 }
|
||||
}
|
||||
}
|
||||
|
||||
// Table body (scrollable)
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
width: parent.width - 32
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.segments.length > 0 ? root.segments : []
|
||||
delegate: Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 40
|
||||
color: index % 2 === 0 ? Theme.cardBackground : "transparent"
|
||||
border.color: Theme.cardBorder
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: modelData.label || ""
|
||||
Layout.preferredWidth: 100
|
||||
color: modelData.label === "Soak" ? Theme.statusSuccessColor :
|
||||
modelData.label === "Ramp" ? Theme.statusWarningColor : Theme.bodyColor
|
||||
font.bold: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: (modelData.start_frame || 0).toString()
|
||||
Layout.preferredWidth: 120
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: (modelData.end_frame || 0).toString()
|
||||
Layout.preferredWidth: 120
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Label {
|
||||
text: (modelData.avg_temp || 0).toFixed(2) + " °C"
|
||||
Layout.fillWidth: true
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
Button {
|
||||
id: exportBtn
|
||||
text: "Export"
|
||||
Layout.preferredWidth: 80
|
||||
Layout.preferredHeight: 26
|
||||
hoverEnabled: true
|
||||
onClicked: streamController.exportSegment(index)
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: exportBtn.pressed ? Theme.buttonNeutralPressed
|
||||
: exportBtn.hovered ? Theme.buttonNeutralHover
|
||||
: Theme.buttonNeutralBackground
|
||||
border.color: Theme.fieldBorder
|
||||
border.width: 1
|
||||
}
|
||||
contentItem: Label {
|
||||
text: exportBtn.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontXs
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
Label {
|
||||
visible: root.segments.length === 0
|
||||
text: root.currentFile !== "" ? "Click SPLIT to segment data" : "Load a CSV file first"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 100
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary line ──────────────────────────────────────────
|
||||
Label {
|
||||
visible: root.segments.length > 0
|
||||
text: root.segments.length + " segment" + (root.segments.length === 1 ? "" : "s")
|
||||
+ " found above " + root.threshold.toFixed(0) + "°C threshold."
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
// ── Export feedback ───────────────────────────────────────
|
||||
Label {
|
||||
id: exportStatus
|
||||
visible: text !== ""
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backend Connection ────────────────────────────────────────
|
||||
Connections {
|
||||
target: streamController
|
||||
function onSplitResult(result) {
|
||||
root.segmenting = false;
|
||||
exportStatus.text = "";
|
||||
if (result && result.success) {
|
||||
root.segments = result.segments || [];
|
||||
console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
|
||||
} else {
|
||||
root.segments = [];
|
||||
console.log("[SplitDialog] Split failed:", result.error || "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
function onSegmentExported(result) {
|
||||
if (result && result.success)
|
||||
exportStatus.text = "Exported: " + result.path.split("/").pop();
|
||||
else
|
||||
exportStatus.text = "Export failed: " + (result.error || "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
|
||||
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||
ColumnLayout {
|
||||
spacing: Theme.sideRailSpacing
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
ColumnLayout {
|
||||
id: hwActionsCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "HARDWARE ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "DETECT WAFER"
|
||||
iconSource: "../icons/detect.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: !deviceController.operationInProgress
|
||||
onClicked: root._doDetect()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "READ MEMORY"
|
||||
iconSource: "../icons/read.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
onClicked: deviceController.readMemoryAsync()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "ERASE MEMORY"
|
||||
iconSource: "../icons/erase.svg"
|
||||
Layout.fillWidth: true
|
||||
destructive: true
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
onClicked: deviceController.eraseMemory(
|
||||
deviceController.selectedPort)
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "READ DEBUG"
|
||||
iconSource: "../icons/read.svg"
|
||||
Layout.fillWidth: true
|
||||
// One-shot toggle: "on" while the debug read is in flight,
|
||||
// resets automatically once deviceController flips status.
|
||||
toggled: deviceController.connectionStatus === "Reading debug..."
|
||||
enabled: deviceController.waferDetected && !deviceController.operationInProgress
|
||||
onClicked: deviceController.readDebug(deviceController.selectedPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: logActionsCol.implicitHeight + 28
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
color: Theme.sidePanelBackground
|
||||
|
||||
ColumnLayout {
|
||||
id: logActionsCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: "LOG ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "REFRESH SESSION"
|
||||
iconSource: "../icons/refresh.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: !deviceController.operationInProgress
|
||||
onClicked: deviceController.clearSession()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "CLEAR LOG"
|
||||
iconSource: "../icons/x.svg"
|
||||
Layout.fillWidth: true
|
||||
onClicked: deviceController.clearActivityLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: contentCol.implicitHeight + 24
|
||||
|
||||
color: Theme.sidePanelBackground
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
signal exportRequested(string filePath, string extra)
|
||||
|
||||
property int _liveSecs: 0
|
||||
Connections {
|
||||
target: streamController
|
||||
function onModeChanged() {
|
||||
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
|
||||
modeBar.currentIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
id: liveTimer
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||
onTriggered: root._liveSecs++
|
||||
onRunningChanged: if (!running) root._liveSecs = 0
|
||||
}
|
||||
|
||||
function fmtTime(s) {
|
||||
var m = Math.floor(s / 60);
|
||||
var ss = s % 60;
|
||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
||||
}
|
||||
|
||||
function switchToReview() {
|
||||
modeBar.currentIndex = 0;
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: contentCol
|
||||
anchors {
|
||||
fill: parent
|
||||
topMargin: 10
|
||||
leftMargin: 12
|
||||
rightMargin: 12
|
||||
bottomMargin: 12
|
||||
}
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
TabBar {
|
||||
id: modeBar
|
||||
currentIndex: 0
|
||||
spacing: 2
|
||||
padding: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 28
|
||||
background: Rectangle {
|
||||
color: Theme.subtleSectionBackground
|
||||
radius: Theme.radiusXs
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
TabButton {
|
||||
text: "Review"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 24
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||
background: Rectangle {
|
||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||
radius: Theme.radiusXs - 1
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
TabButton {
|
||||
id: liveTab
|
||||
text: "Live"
|
||||
enabled: deviceController.connectionStatus === "Connected"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 24
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||
|
||||
AppToolTip {
|
||||
visible: disabledHover.containsMouse && !liveTab.enabled
|
||||
text: "Connect a wafer to enable Live mode"
|
||||
delay: 300
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: disabledHover
|
||||
anchors.fill: parent
|
||||
enabled: !liveTab.enabled
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
||||
radius: Theme.radiusXs - 1
|
||||
}
|
||||
|
||||
contentItem: Item {
|
||||
Row {
|
||||
spacing: 4
|
||||
anchors.centerIn: parent
|
||||
Rectangle {
|
||||
id: liveTabDot
|
||||
width: 5; height: 5; radius: 2.5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: streamController.mode === "live" && streamController.state !== "idle"
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
||||
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
||||
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: liveTab.text
|
||||
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
||||
font: liveTab.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
height: parent.height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentIndexChanged: {
|
||||
if (currentIndex === 1) {
|
||||
if (deviceController.connectionStatus !== "Connected") {
|
||||
currentIndex = 0;
|
||||
return;
|
||||
}
|
||||
streamController.setMode("live");
|
||||
var fc = "";
|
||||
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
||||
fc = deviceController.lastWaferInfo[0];
|
||||
}
|
||||
streamController.startStream(deviceController.selectedPort, fc);
|
||||
} else {
|
||||
streamController.setMode("review");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "METRICS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 2
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Received Frames"
|
||||
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Errors"
|
||||
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
|
||||
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Resyncs"
|
||||
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
|
||||
}
|
||||
|
||||
MetricRow {
|
||||
label: "Elapsed"
|
||||
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 2
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Button {
|
||||
id: recordBtn
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 28
|
||||
enabled: streamController.mode === "live"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
text: streamController.recording ? "Stop REC" : "Record"
|
||||
|
||||
AppToolTip {
|
||||
visible: recordBtn.hovered && !recordBtn.enabled
|
||||
text: "Connect to Live to start recording"
|
||||
delay: 300
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (streamController.recording) {
|
||||
streamController.stopRecording();
|
||||
} else {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
var serial = (info && info.length > 1) ? info[1] : "";
|
||||
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
||||
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
||||
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: recordBtn.down ? Theme.transportButtonHover
|
||||
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.sideBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: recordBtn.text
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: exportBtn
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 28
|
||||
text: "Export"
|
||||
// Nothing to export until a file is loaded or a stream has played
|
||||
enabled: streamController.sensorValues.length > 0
|
||||
opacity: enabled ? 1 : 0.4
|
||||
|
||||
onClicked: {
|
||||
// Default into <saveDataDir>/Export (created on demand)
|
||||
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
|
||||
exportDialog.open()
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: exportBtn.down ? Theme.transportButtonHover
|
||||
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.sideBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: exportBtn.text
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: exportDialog
|
||||
title: "Export Wafer Map"
|
||||
fileMode: FileDialog.SaveFile
|
||||
defaultSuffix: "png"
|
||||
nameFilters: ["PNG files (*.png)"]
|
||||
onAccepted: {
|
||||
var path = String(selectedFile).replace(/^file:\/\//, "");
|
||||
var extra = "";
|
||||
if (streamController.mode === "live") {
|
||||
extra = "Frames: " + streamController.receivedCount
|
||||
+ " Errors: " + streamController.errorCount
|
||||
+ " Resyncs: " + streamController.resyncCount
|
||||
+ " Elapsed: " + root.fmtTime(root._liveSecs);
|
||||
}
|
||||
root.exportRequested(path, extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
Button {
|
||||
id: primaryBtn
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 32
|
||||
text: streamController.mode === "live" ? "STOP" : "START"
|
||||
enabled: streamController.mode === "live"
|
||||
? true
|
||||
: deviceController.connectionStatus === "Connected"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
|
||||
AppToolTip {
|
||||
visible: primaryBtn.hovered && !primaryBtn.enabled
|
||||
text: "Connect a wafer to enable Live mode"
|
||||
delay: 300
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (streamController.mode === "live") {
|
||||
streamController.stopStream();
|
||||
modeBar.currentIndex = 0;
|
||||
} else {
|
||||
modeBar.currentIndex = 1;
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: primaryBtn.down ? Theme.transportButtonHover
|
||||
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.sideBorder
|
||||
}
|
||||
contentItem: Text {
|
||||
text: primaryBtn.text
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
font.letterSpacing: 1.0
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
// ===== Control Bar =====
|
||||
// Mode-aware footer: cross-fades between Live and Review layouts.
|
||||
// Both variants render as a single centered pill — no full-width bar.
|
||||
// Review-mode transport footer (frame counter, play/pause/step, speed).
|
||||
// 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 {
|
||||
id: bar
|
||||
Layout.fillWidth: true
|
||||
@@ -15,120 +18,13 @@ Item {
|
||||
}
|
||||
clip: true
|
||||
|
||||
// Hide entirely when in review mode with no file loaded
|
||||
readonly property bool hasContent: {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only shown in review mode, and only once a file is loaded
|
||||
readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
|
||||
|
||||
// ── REVIEW MODE ──────────────────────────────────────────────
|
||||
Item {
|
||||
id: reviewContent
|
||||
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
|
||||
Rectangle {
|
||||
@@ -146,15 +42,69 @@ Item {
|
||||
spacing: 24
|
||||
|
||||
// Frame counter
|
||||
Text {
|
||||
Row {
|
||||
spacing: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Frame " + (streamController.frameIndex + 1)
|
||||
+ " / " + streamController.frameTotal
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
leftPadding: 8
|
||||
|
||||
Label {
|
||||
text: "Frame"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
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
|
||||
@@ -177,8 +127,6 @@ Item {
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: "⏮"
|
||||
font.pixelSize: 24
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: skipStartBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -188,12 +136,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: "../icons/prev.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.step(-1)
|
||||
}
|
||||
@@ -205,8 +154,6 @@ Item {
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: "■"
|
||||
font.pixelSize: 22
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: reviewStopBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -216,12 +163,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: "../icons/stop.svg"
|
||||
width: 20; height: 20
|
||||
sourceSize.width: 20
|
||||
sourceSize.height: 20
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.stop()
|
||||
}
|
||||
@@ -230,11 +178,9 @@ Item {
|
||||
Button {
|
||||
id: playPauseBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: 64
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: streamController.playing ? "⏸" : "▶"
|
||||
font.pixelSize: 24
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: playPauseBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -244,13 +190,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: parent.text === "▶" ? 4 : 0
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: {
|
||||
if (streamController.playing) {
|
||||
@@ -268,8 +214,6 @@ Item {
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: "⏭"
|
||||
font.pixelSize: 24
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: skipEndBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -279,12 +223,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: "../icons/next.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
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
|
||||
property real blend: 0.0
|
||||
property bool showLabels: true
|
||||
property alias showExtremes: map.showExtremes
|
||||
property alias showThickness: map.showThickness
|
||||
property alias hasThickness: map.hasThickness
|
||||
|
||||
WaferMapItem {
|
||||
id: map
|
||||
@@ -29,8 +31,28 @@ Item {
|
||||
highColor: Theme.sensorHigh
|
||||
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 {
|
||||
onTapped: ev => {
|
||||
if (streamController.mode === "live")
|
||||
return;
|
||||
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||
if (idx >= 0)
|
||||
replaceDialog.openFor(idx);
|
||||
@@ -42,7 +64,11 @@ Item {
|
||||
id: replaceDialog
|
||||
}
|
||||
|
||||
function exportImage(filePath) {
|
||||
return map.export_image(filePath);
|
||||
function exportImage(filePath, extra) {
|
||||
return map.export_image(filePath, extra || "");
|
||||
}
|
||||
|
||||
function loadThickness(filePath) {
|
||||
return map.loadThickness(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,21 @@ TransportBar 1.0 TransportBar.qml
|
||||
WaferMapView 1.0 WaferMapView.qml
|
||||
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
|
||||
RailActionButton 1.0 RailActionButton.qml
|
||||
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||
SplitDialog 1.0 SplitDialog.qml
|
||||
StreamControlPanel 1.0 StreamControlPanel.qml
|
||||
StatusActionsPanel 1.0 StatusActionsPanel.qml
|
||||
ConnectionFooter 1.0 ConnectionFooter.qml
|
||||
UtilityFooter 1.0 UtilityFooter.qml
|
||||
PanelCheckBox 1.0 PanelCheckBox.qml
|
||||
ReadoutStat 1.0 ReadoutStat.qml
|
||||
MetricRow 1.0 MetricRow.qml
|
||||
AppToolTip 1.0 AppToolTip.qml
|
||||
SectionTitle 1.0 SectionTitle.qml
|
||||
PanelBox 1.0 PanelBox.qml
|
||||
PanelSlider 1.0 PanelSlider.qml
|
||||
EditableScrubStat 1.0 EditableScrubStat.qml
|
||||
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
||||
OverlapMapCard 1.0 OverlapMapCard.qml
|
||||
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
|
||||
RunChart 1.0 RunChart.qml
|
||||
@@ -0,0 +1,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 |
@@ -1,9 +1,7 @@
|
||||
# ===== ISC Tabs Module =====
|
||||
module ISC.Tabs
|
||||
|
||||
GraphTab 1.0 GraphTab.qml
|
||||
WaferMapTab 1.0 WaferMapTab.qml
|
||||
DataTab 1.0 DataTab.qml
|
||||
SettingsTab 1.0 SettingsTab.qml
|
||||
StatusTab 1.0 StatusTab.qml
|
||||
SelectFileDialog 1.0 SelectFileDialog.qml
|
||||
|
||||
@@ -7,18 +7,23 @@ import QtQuick
|
||||
// =============================================================================
|
||||
// Sections:
|
||||
// 1. Mode flag
|
||||
// 2. Typography
|
||||
// 2. Typography (families, pixel sizes, weights)
|
||||
// 3. Motion
|
||||
// 4. Tone palette (raw tones; everything else derives from these)
|
||||
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
||||
// 4. Tone palette (raw tones; everything else derives from these)
|
||||
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
||||
// 6. Borders
|
||||
// 7. Text (headings, body, panel titles)
|
||||
// 8. Controls (fields, buttons, checkbox)
|
||||
// 9. Tracks (pill toggle + scrollbar)
|
||||
// 10. Tabs (footer tab bar)
|
||||
// 11. Side rail
|
||||
// 12. Status (success / warning / error)
|
||||
// 13. Geometry (radius / border width / spacing / sizing)
|
||||
// 7. Text (headings, body, panel titles)
|
||||
// 8. Controls (fields, buttons, checkbox, toggle)
|
||||
// 9. Interaction overlays (hover / active washes — mode-aware)
|
||||
// 10. Tracks (pill toggle + scrollbar)
|
||||
// 11. Tabs (mode toggle pills)
|
||||
// 12. Side rail
|
||||
// 13. Transport / toolbar
|
||||
// 14. Status & badges (success / warning / error, badge text)
|
||||
// 15. Sensor bands (wafer map dots)
|
||||
// 16. Wafer family badges (source list avatars + accent bars)
|
||||
// 17. Comparison & charts (metric grading, diff accent, canvas grid)
|
||||
// 18. Geometry (radius / border width / spacing / sizing)
|
||||
// =============================================================================
|
||||
// Based on Codex themes:
|
||||
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6
|
||||
@@ -29,7 +34,10 @@ QtObject {
|
||||
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
||||
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
|
||||
readonly property int font2xs: 9 // tiny metadata
|
||||
readonly property int fontXs: 11 // captions, micro labels
|
||||
@@ -44,25 +52,20 @@ QtObject {
|
||||
readonly property int fontWeightMedium: 500
|
||||
readonly property int fontWeightBold: 700
|
||||
|
||||
// Legacy shim
|
||||
readonly property int tabFontSize: fontSm
|
||||
|
||||
// ── 3. Motion ──────────────────────────────────────────────────────────
|
||||
// ── 3. Motion ────────────────────────────────────────────────────────────
|
||||
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
|
||||
readonly property int durationBase: 180 // standard transitions (pill switch, row select)
|
||||
readonly property int durationSlow: 260 // larger transitions (panel open/close)
|
||||
readonly property var easeStandard: Easing.OutCubic
|
||||
|
||||
// ── 4. Tone palette (Codex theme v1 inspired) ───────────────────────────
|
||||
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
|
||||
|
||||
// ── 4. Tone palette (Codex theme v1 inspired) ────────────────────────────
|
||||
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
|
||||
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
|
||||
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
|
||||
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
|
||||
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
|
||||
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
|
||||
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
|
||||
|
||||
readonly property color tone100: themeSurface
|
||||
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
|
||||
@@ -74,7 +77,7 @@ QtObject {
|
||||
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
|
||||
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
|
||||
|
||||
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
||||
// ── 5. Surfaces ──────────────────────────────────────────────────────────
|
||||
readonly property color pageBackground: tone100
|
||||
readonly property color cardBackground: tone150
|
||||
readonly property color panelBackground: tone150
|
||||
@@ -82,7 +85,7 @@ QtObject {
|
||||
readonly property color responseBackground: tone150
|
||||
readonly property color subtleSectionBackground: tone250
|
||||
|
||||
// ── 4. Borders ───────────────────────────────────────────────────────────
|
||||
// ── 6. Borders ───────────────────────────────────────────────────────────
|
||||
readonly property color cardBorder: toneBorder
|
||||
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||
readonly property color responseBorder: toneBorder
|
||||
@@ -91,14 +94,14 @@ QtObject {
|
||||
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
|
||||
|
||||
// ── 5. Text ──────────────────────────────────────────────────────────────
|
||||
// ── 7. Text ──────────────────────────────────────────────────────────────
|
||||
readonly property color headingColor: toneText
|
||||
readonly property color bodyColor: toneMute
|
||||
readonly property color subheadingColor: toneMute
|
||||
readonly property color panelTitleText: toneMute
|
||||
readonly property color disabledText: toneMute
|
||||
|
||||
// ── 6. Controls ──────────────────────────────────────────────────────────
|
||||
// ── 8. Controls ──────────────────────────────────────────────────────────
|
||||
// Fields
|
||||
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
|
||||
readonly property color fieldText: toneText
|
||||
@@ -114,42 +117,48 @@ QtObject {
|
||||
readonly property color primaryAccent: themeAccent
|
||||
// Checkbox
|
||||
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"
|
||||
|
||||
// ── 8. Tabs (footer tab bar) ─────────────────────────────────────────────
|
||||
readonly property color tabBarBackground: tone200
|
||||
readonly property color tabBackground: "transparent"
|
||||
// ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
|
||||
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||
readonly property color tabHoverBackground: tone250
|
||||
readonly property color tabBorder: "transparent"
|
||||
readonly property color tabText: toneMute
|
||||
readonly property color tabActiveText: toneText
|
||||
|
||||
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
||||
// ── 12. Side rail ────────────────────────────────────────────────────────
|
||||
readonly property color sideRailBackground: tone150
|
||||
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
|
||||
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||
readonly property color sideBorder: toneBorder
|
||||
readonly property color sideMutedText: toneMute
|
||||
readonly property color sideAccent: themeAccent
|
||||
readonly property color sideRowHover: tone250
|
||||
readonly property color sideFieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
|
||||
|
||||
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
|
||||
readonly property color transportBackground: isDarkMode ? "#101014" : "#EFEFED"
|
||||
// ── 13. Transport / toolbar surfaces ─────────────────────────────────────
|
||||
readonly property color transportButtonBg: tone250
|
||||
readonly property color transportButtonHover: tone300
|
||||
readonly property color liveColor: themeAdded
|
||||
readonly property color recordColor: themeRemoved
|
||||
|
||||
// ── 10. Status ───────────────────────────────────────────────────────────
|
||||
// ── 14. Status & badges ──────────────────────────────────────────────────
|
||||
readonly property color statusSuccessColor: themeAdded
|
||||
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
|
||||
readonly property color statusErrorColor: themeRemoved
|
||||
// Text placed ON a filled status pill / saturated chip:
|
||||
readonly property color statusBadgeText: "#111111" // dark ink on bright status fills, both modes
|
||||
readonly property color textOnColor: "#FFFFFF" // white text on deep saturated fills (avatars etc.)
|
||||
// Error banner (soft red surface + readable red text)
|
||||
readonly property color errorSurface: Qt.alpha(statusErrorColor, 0.15)
|
||||
readonly property color errorTextSoft: isDarkMode ? "#FCA5A5" : "#B91C1C"
|
||||
|
||||
// -- 10b. Sensor bands (wafer map dots)
|
||||
// ── 15. Sensor bands (wafer map dots) ────────────────────────────────────
|
||||
readonly property color sensorInRange: statusSuccessColor
|
||||
// ponytail: high-temp shares error red — semantically correct (dangerous value),
|
||||
// re-hue if colorblind testing demands a different channel.
|
||||
@@ -158,7 +167,31 @@ QtObject {
|
||||
readonly property color waferRingColor: toneBorder
|
||||
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
|
||||
readonly property int radiusXs: 6 // fields, tight elements
|
||||
readonly property int radiusSm: 8 // buttons
|
||||
@@ -172,21 +205,15 @@ QtObject {
|
||||
readonly property int rightPaneGap: 6
|
||||
readonly property int panelPadding: 12
|
||||
// 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 sideRailSpacing: 16
|
||||
readonly property int sideButtonHeight: 44
|
||||
readonly property int sidePanelRadius: 10
|
||||
readonly property int sideFooterConnection: 100
|
||||
readonly property int sideFooterUtility: 46
|
||||
// Tab bar layout
|
||||
readonly property int tabBarHeight: 34
|
||||
readonly property int sidePillHeight: 54
|
||||
readonly property int sidePillButtonHeight: 36
|
||||
readonly property int tabBarPadding: 6
|
||||
readonly property int tabSpacing: 2
|
||||
readonly property int tabRadius: 8
|
||||
readonly property int tabButtonMinWidth: 80
|
||||
// Settings page layout
|
||||
readonly property int settingsPanelMaxWidth: 760
|
||||
readonly property int settingsOuterMargin: 40
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import sys
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
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.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.local_settings import LocalSettings
|
||||
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).
|
||||
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 =====
|
||||
@@ -30,34 +42,49 @@ def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
engine = QQmlApplicationEngine()
|
||||
|
||||
# ===== Shared Models =====
|
||||
settings_model = LocalSettingsModel()
|
||||
# ===== Shared Settings =====
|
||||
# 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()
|
||||
settings_model.loadSettings()
|
||||
engine.rootContext().setContextProperty("settingsModel", settings_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) =====
|
||||
data_dir = str(settings_model._data_dir)
|
||||
raw_settings = LocalSettings.read_settings(data_dir)
|
||||
# TODO P1.1: construct LicenseModel BEFORE DeviceController and pass
|
||||
# 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)
|
||||
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) =====
|
||||
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {}
|
||||
stream_controller = SessionController(settings=raw_settings_dict)
|
||||
stream_controller = SessionController()
|
||||
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||
|
||||
# Persist session state back to settings when it changes
|
||||
def _persist_session_settings():
|
||||
session_state = stream_controller.collect_settings()
|
||||
for k, v in session_state.items():
|
||||
if hasattr(raw_settings, k):
|
||||
setattr(raw_settings, k, v)
|
||||
LocalSettings.save_settings(data_dir, raw_settings)
|
||||
|
||||
stream_controller.settingsChanged.connect(_persist_session_settings)
|
||||
|
||||
# ===== QML Startup =====
|
||||
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
||||
# package directory is the import path the engine searches for qmldir.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -3,31 +3,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
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.models.data_model import TemperatureTableModel
|
||||
from pygui.backend.utils import slot_error_boundary
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
from pygui.serialcomm.data_parser import (
|
||||
convert_to_debug_temperatures,
|
||||
convert_to_temperatures,
|
||||
parse_binary_data,
|
||||
remove_trailing_zeros,
|
||||
save_debug_csv,
|
||||
save_to_csv,
|
||||
)
|
||||
from pygui.serialcomm.device_service import DeviceService
|
||||
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__)
|
||||
|
||||
|
||||
@@ -39,6 +38,10 @@ class DeviceController(QObject):
|
||||
|
||||
# ---- public signals ----
|
||||
portsUpdated = Signal(list)
|
||||
connectionStatusChanged = Signal()
|
||||
operationInProgressChanged = Signal()
|
||||
waferDetectedChanged = Signal()
|
||||
selectedPortChanged = Signal()
|
||||
detectResult = Signal(object) # WaferInfo dict or None
|
||||
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
|
||||
eraseResult = Signal(dict) # {"success": bool}
|
||||
@@ -51,11 +54,14 @@ class DeviceController(QObject):
|
||||
# ---- private signals (marshal worker-thread results back to main thread) ----
|
||||
_detectFinished = Signal(object, object) # (port_or_None, WaferInfo_or_None)
|
||||
_readFinished = Signal(str, str, object) # (port, family_code, bytes_or_None)
|
||||
_eraseFinished = Signal(bool) # (success)
|
||||
_debugFinished = Signal(dict) # (result_dict)
|
||||
|
||||
def __init__(self, settings: LocalSettings, data_dir: str, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._settings = settings
|
||||
self._data_dir = data_dir
|
||||
from pygui.serialcomm.device_service import DeviceService
|
||||
self._service = DeviceService(settings)
|
||||
|
||||
# Always start fresh — never restore connection status or logs from a prior session
|
||||
@@ -63,9 +69,18 @@ class DeviceController(QObject):
|
||||
self._operation_in_progress = False
|
||||
self._activity_log: list[str] = []
|
||||
self._raw_bytes: Optional[bytes] = None
|
||||
from pathlib import Path
|
||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
||||
# 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._wafer_detected: bool = False
|
||||
self._last_csv_path: str = ""
|
||||
self._selected_port: str = ""
|
||||
self._data_row_count: int = 0
|
||||
@@ -79,6 +94,8 @@ class DeviceController(QObject):
|
||||
# Marshal worker-thread results onto the Qt main thread.
|
||||
self._detectFinished.connect(self._handle_detect_finished, Qt.ConnectionType.QueuedConnection)
|
||||
self._readFinished.connect(self._handle_read_finished, Qt.ConnectionType.QueuedConnection)
|
||||
self._eraseFinished.connect(self._handle_erase_finished, Qt.ConnectionType.QueuedConnection)
|
||||
self._debugFinished.connect(self._handle_debug_finished, Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(QmlActivityLogHandler(self))
|
||||
@@ -94,18 +111,23 @@ class DeviceController(QObject):
|
||||
"""Update connection status and notify QML."""
|
||||
if self._connection_status != status:
|
||||
self._connection_status = status
|
||||
self.portsUpdated.emit(self.availablePorts)
|
||||
self.connectionStatusChanged.emit()
|
||||
|
||||
@Property(str, notify=portsUpdated)
|
||||
@Property(str, notify=connectionStatusChanged)
|
||||
def connectionStatus(self) -> str:
|
||||
"""Current connection status string for display."""
|
||||
return self._connection_status
|
||||
|
||||
@Property(bool, notify=portsUpdated)
|
||||
@Property(bool, notify=operationInProgressChanged)
|
||||
def operationInProgress(self) -> bool:
|
||||
"""Whether a hardware operation is currently running."""
|
||||
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)
|
||||
def saveDataDir(self) -> str:
|
||||
"""Directory where parsed CSV data files are saved."""
|
||||
@@ -119,7 +141,43 @@ class DeviceController(QObject):
|
||||
self._append_log(f"Save data dir set to: {path}")
|
||||
self._save_status()
|
||||
|
||||
@Property(str, notify=portsUpdated)
|
||||
@Slot(result=str)
|
||||
@slot_error_boundary
|
||||
def exportDir(self) -> str:
|
||||
"""Ensure and return the Export subfolder under saveDataDir."""
|
||||
path = Path(self._save_data_dir) / "Export"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return str(path)
|
||||
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def openCsvFile(self, file_path: str) -> None:
|
||||
"""Open a CSV file in the system default spreadsheet application."""
|
||||
|
||||
if not file_path:
|
||||
self._append_log("No file path provided to openCsvFile")
|
||||
return
|
||||
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
self._append_log(f"File not found: {file_path}")
|
||||
return
|
||||
|
||||
# Platform-appropriate file opener
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(["open", str(path)])
|
||||
elif sys.platform == "linux":
|
||||
subprocess.Popen(["xdg-open", str(path)])
|
||||
else: # Windows
|
||||
os.startfile(str(path))
|
||||
|
||||
self._append_log(f"Opened file: {file_path}")
|
||||
except Exception as exc:
|
||||
log.warning("Failed to open file %s: %s", file_path, exc)
|
||||
self._append_log(f"Error opening file: {exc}")
|
||||
|
||||
@Property(str, notify=selectedPortChanged)
|
||||
def selectedPort(self) -> str:
|
||||
"""Currently selected serial port shared across the UI."""
|
||||
return self._selected_port
|
||||
@@ -128,8 +186,10 @@ class DeviceController(QObject):
|
||||
@slot_error_boundary
|
||||
def setSelectedPort(self, port: str) -> None:
|
||||
"""Set the active serial port from the port selector."""
|
||||
self._selected_port = port
|
||||
self._save_status()
|
||||
if self._selected_port != port:
|
||||
self._selected_port = port
|
||||
self.selectedPortChanged.emit()
|
||||
self._save_status()
|
||||
|
||||
@Property(int, notify=parsedDataReady)
|
||||
def dataRowCount(self) -> int:
|
||||
@@ -164,6 +224,17 @@ class DeviceController(QObject):
|
||||
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)
|
||||
def dataModel(self) -> TemperatureTableModel:
|
||||
"""QAbstractTableModel for the parsed temperature data table."""
|
||||
@@ -228,6 +299,7 @@ class DeviceController(QObject):
|
||||
self._set_connection_status("Disconnected")
|
||||
self._selected_port = ""
|
||||
self._last_wafer_info = {}
|
||||
self._set_wafer_detected(False)
|
||||
self._data_row_count = 0
|
||||
self._data_col_count = 0
|
||||
self._raw_bytes = None
|
||||
@@ -243,14 +315,10 @@ class DeviceController(QObject):
|
||||
|
||||
|
||||
def _set_operation_progress(self, in_progress: bool) -> None:
|
||||
"""Set operation progress state and notify QML.
|
||||
|
||||
portsUpdated is the shared notify signal for availablePorts,
|
||||
connectionStatus, and operationInProgress — emitting it forces
|
||||
QML to re-evaluate all three bindings.
|
||||
"""
|
||||
self._operation_in_progress = in_progress
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
"""Set operation progress state and notify QML."""
|
||||
if self._operation_in_progress != in_progress:
|
||||
self._operation_in_progress = in_progress
|
||||
self.operationInProgressChanged.emit()
|
||||
|
||||
# ---- Public Slots ----
|
||||
|
||||
@@ -258,6 +326,7 @@ class DeviceController(QObject):
|
||||
@slot_error_boundary
|
||||
def refreshPorts(self) -> None:
|
||||
"""Scan for available serial ports and emit updated list."""
|
||||
self._service.clear_port_scan_cache()
|
||||
ports = self._service.enumerate_ports()
|
||||
self._set_connection_status("Disconnected")
|
||||
self.portsUpdated.emit(ports)
|
||||
@@ -287,6 +356,7 @@ class DeviceController(QObject):
|
||||
self._graph_view.resetChart()
|
||||
self._activity_log.clear()
|
||||
self.detectResult.emit(None)
|
||||
self.selectedPortChanged.emit()
|
||||
self.parsedDataReady.emit({"success": False})
|
||||
|
||||
self._set_operation_progress(True)
|
||||
@@ -312,20 +382,29 @@ class DeviceController(QObject):
|
||||
self, port: Optional[str], info: Optional[WaferInfo]
|
||||
) -> None:
|
||||
"""Main-thread completion handler for detectWafer."""
|
||||
if info is not None:
|
||||
self._selected_port = port or ""
|
||||
self._set_connection_status("Connected")
|
||||
wafer_dict = self._wafer_info_to_dict(info)
|
||||
self._last_wafer_info = wafer_dict
|
||||
self.detectResult.emit(wafer_dict)
|
||||
self._save_status()
|
||||
else:
|
||||
self._selected_port = ""
|
||||
self._set_connection_status("Disconnected")
|
||||
self._append_log("No wafer detected on any port")
|
||||
self._last_wafer_info = {}
|
||||
self.detectResult.emit(None)
|
||||
self._set_operation_progress(False)
|
||||
try:
|
||||
if info is not None:
|
||||
# Build the dict (can raise) before declaring success, so a
|
||||
# bad payload can't leave status="Connected" while
|
||||
# waferDetected stays false and Read/Erase stay greyed.
|
||||
wafer_dict = self._wafer_info_to_dict(info)
|
||||
self._selected_port = port or ""
|
||||
self._last_wafer_info = wafer_dict
|
||||
self._set_wafer_detected(True)
|
||||
self._set_connection_status("Connected")
|
||||
self.detectResult.emit(wafer_dict)
|
||||
self.selectedPortChanged.emit()
|
||||
self._save_status()
|
||||
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_error_boundary
|
||||
@@ -340,6 +419,16 @@ class DeviceController(QObject):
|
||||
self._append_log("Already busy — ignoring read request")
|
||||
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:
|
||||
self._append_log("No wafer detected — run Detect Wafer first")
|
||||
self.readResult.emit({"error": "No port — detect wafer first"})
|
||||
@@ -396,12 +485,36 @@ class DeviceController(QObject):
|
||||
@slot_error_boundary
|
||||
def eraseMemory(self, port: str) -> None:
|
||||
"""Send p1 erase command."""
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring erase request")
|
||||
return
|
||||
|
||||
# TODO P2.1: same license guard as readMemoryAsync, but emit
|
||||
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Erasing...")
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log(f"Erase command sent to {port} (wafer takes ~15s) ...")
|
||||
|
||||
ok = self._service.erase_wafer(port)
|
||||
threading.Thread(
|
||||
target=self._erase_worker,
|
||||
args=(port,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _erase_worker(self, port: str) -> None:
|
||||
"""Background thread worker for eraseMemory."""
|
||||
try:
|
||||
ok = self._service.erase_wafer(port)
|
||||
except Exception as exc:
|
||||
log.exception("Erase worker crashed: %s", exc)
|
||||
ok = False
|
||||
self._eraseFinished.emit(ok)
|
||||
|
||||
@Slot(bool)
|
||||
@slot_error_boundary
|
||||
def _handle_erase_finished(self, ok: bool) -> None:
|
||||
"""Main thread completion handler for eraseMemory."""
|
||||
if ok:
|
||||
self._set_connection_status("Connected")
|
||||
self._append_log("Erase command accepted — wait ~15 seconds")
|
||||
@@ -414,45 +527,83 @@ class DeviceController(QObject):
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def readDebug(self, port: str) -> None:
|
||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
||||
|
||||
Emits debugResult with counts on success.
|
||||
"""
|
||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data."""
|
||||
if self._operation_in_progress:
|
||||
self._append_log("Already busy — ignoring debug read request")
|
||||
return
|
||||
self._set_operation_progress(True)
|
||||
self._set_connection_status("Reading debug...")
|
||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||
self._append_log(f"Reading debug data on {port} ...")
|
||||
|
||||
# Step 1: D1 sensor data
|
||||
sensor_data = self._service.read_wafer_data(port, family_code="")
|
||||
if sensor_data is None:
|
||||
threading.Thread(
|
||||
target=self._debug_worker,
|
||||
args=(port,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _debug_worker(self, port: str) -> None:
|
||||
"""Background thread worker for readDebug."""
|
||||
try:
|
||||
# Step 1: D1 sensor data
|
||||
sensor_data = self._service.read_wafer_data(port, family_code="")
|
||||
if sensor_data is None:
|
||||
self._debugFinished.emit({"error": "D1 read failed"})
|
||||
return
|
||||
|
||||
# Step 2: F1 debug data
|
||||
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
||||
if debug_data is None:
|
||||
self._debugFinished.emit({"error": "F1 read failed"})
|
||||
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({
|
||||
"success": True,
|
||||
"sensor_bytes": len(sensor_data),
|
||||
"debug_bytes": len(debug_data),
|
||||
"csv_path": csv_path,
|
||||
})
|
||||
except Exception as exc:
|
||||
log.exception("Debug worker crashed: %s", exc)
|
||||
self._debugFinished.emit({"error": str(exc)})
|
||||
|
||||
@Slot(dict)
|
||||
@slot_error_boundary
|
||||
def _handle_debug_finished(self, result: dict) -> None:
|
||||
"""Main thread completion handler for readDebug."""
|
||||
if result.get("success"):
|
||||
self._set_connection_status("Connected")
|
||||
sensor_bytes = result.get("sensor_bytes", 0)
|
||||
debug_bytes = result.get("debug_bytes", 0)
|
||||
csv_path = result.get("csv_path", "")
|
||||
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
|
||||
if csv_path:
|
||||
msg += f", CSV written to {csv_path}"
|
||||
self._append_log(msg)
|
||||
else:
|
||||
self._set_connection_status("Disconnected")
|
||||
self._append_log("D1 read failed — aborting debug read")
|
||||
self.debugResult.emit({"error": "D1 read failed"})
|
||||
self._set_operation_progress(False)
|
||||
return
|
||||
err_msg = result.get("error", "Debug read failed")
|
||||
self._append_log(err_msg)
|
||||
|
||||
self._append_log(f"D1 read: {len(sensor_data)} bytes")
|
||||
|
||||
# Step 2: F1 debug data
|
||||
debug_data = self._service.read_wafer_data(port, family_code="", cmd="F1")
|
||||
if debug_data is None:
|
||||
self._set_connection_status("Disconnected")
|
||||
self._append_log("F1 read failed")
|
||||
self.debugResult.emit({"error": "F1 read failed"})
|
||||
self._set_operation_progress(False)
|
||||
return
|
||||
|
||||
self._set_connection_status("Connected")
|
||||
self._append_log(
|
||||
f"Debug read complete: {len(sensor_data)} sensor bytes, "
|
||||
f"{len(debug_data)} debug bytes"
|
||||
)
|
||||
self.debugResult.emit({
|
||||
"success": True,
|
||||
"sensor_bytes": len(sensor_data),
|
||||
"debug_bytes": len(debug_data),
|
||||
})
|
||||
self.debugResult.emit(result)
|
||||
self._set_operation_progress(False)
|
||||
|
||||
@Slot(str, str)
|
||||
@@ -472,7 +623,6 @@ class DeviceController(QObject):
|
||||
return
|
||||
|
||||
if not self._save_data_dir:
|
||||
from pathlib import Path
|
||||
self._save_data_dir = str(Path(self._data_dir) / "csv")
|
||||
self._append_log(f"Auto-set save directory to: {self._save_data_dir}")
|
||||
self._save_status()
|
||||
|
||||
@@ -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.data.csv_recorder import CsvRecorder
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
||||
from pygui.backend.models.replay_stats import ReplayStatsTracker
|
||||
from pygui.backend.models.frame_player import (
|
||||
FramePlayer,
|
||||
all_sensor_series,
|
||||
frames_from_wafer_data,
|
||||
)
|
||||
from pygui.backend.models.frame_stats import compute_edge_center
|
||||
from pygui.backend.models.sensor_editor import SensorEditor
|
||||
from pygui.backend.models.session_model import SessionModel
|
||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||
from pygui.backend.utils import slot_error_boundary
|
||||
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
from pygui.serialcomm.stream_reader import StreamReader
|
||||
@@ -35,17 +40,22 @@ class SessionController(QObject):
|
||||
recordingChanged = Signal()
|
||||
sensorsChanged = Signal()
|
||||
loadedFileChanged = Signal()
|
||||
loadFileError = Signal(str)
|
||||
clusterAveragingEnabledChanged = Signal()
|
||||
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||
# 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
|
||||
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
||||
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||
splitResult = Signal(dict) # dict with success, segments
|
||||
segmentExported = Signal(dict) # dict with success, path | error
|
||||
# private: marshal a worker-thread frame onto the main thread
|
||||
_liveFrame = Signal(object) # Frame
|
||||
_liveError = Signal() # worker-thread error ping
|
||||
|
||||
def __init__(self, parent: QObject | None = None,
|
||||
settings: dict | None = None) -> None:
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._mode = MODE_REVIEW
|
||||
self._model = SessionModel()
|
||||
@@ -53,15 +63,9 @@ class SessionController(QObject):
|
||||
self._reader: Optional[StreamReader] = None
|
||||
self._recorder = CsvRecorder()
|
||||
self._sensors: list[Sensor] = []
|
||||
self._last = None # last SessionUpdate
|
||||
self._last: Optional[SessionUpdate] = None
|
||||
self._elapsed = 0.0
|
||||
self._trend_buffer: list[float] = [] # rolling window
|
||||
self._trend_timestamps: list[float] = [] # monotonic timestamps
|
||||
|
||||
# Trend refresh timer - update every 1s
|
||||
self._trend_timer = QTimer(self)
|
||||
self._trend_timer.setInterval(1000)
|
||||
self._trend_timer.timeout.connect(self._flush_trend)
|
||||
self._stream_start_time: float = 0.0
|
||||
|
||||
self._play_timer = QTimer(self)
|
||||
self._play_timer.timeout.connect(self._advance)
|
||||
@@ -77,28 +81,18 @@ class SessionController(QObject):
|
||||
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
self._sensor_editor = SensorEditor()
|
||||
self._ec_pairs: tuple[tuple[int, int], ...] = ()
|
||||
self._last_raw_frame: Frame | None = None
|
||||
self._loaded_file: str = ""
|
||||
self._cluster_averaging_enabled = False
|
||||
self._active_clusters: list[list[int]] = []
|
||||
self._stats_tracker = ReplayStatsTracker()
|
||||
self._received_count: int = 0
|
||||
self._error_count: int = 0
|
||||
|
||||
# Restore persisted state if available
|
||||
self._load_settings(settings or {})
|
||||
|
||||
# ---- persisted settings ----
|
||||
|
||||
def _load_settings(self, settings: dict) -> None:
|
||||
"""Restore session state from persisted settings dict."""
|
||||
# Do NOT auto-restore the last session file — always start fresh.
|
||||
|
||||
def collect_settings(self) -> dict:
|
||||
"""Return a dict of session state to persist."""
|
||||
return {
|
||||
"session_last_file": self._loaded_file,
|
||||
}
|
||||
# Comparison cache for dynamic overlap mapping
|
||||
self._compare_recs_a: list = []
|
||||
self._compare_recs_b: list = []
|
||||
self._compare_alignment_map: dict[int, int] = {}
|
||||
|
||||
# ---- properties QML binds to ----
|
||||
@Property(str, notify=modeChanged)
|
||||
@@ -139,7 +133,7 @@ class SessionController(QObject):
|
||||
"value": round(v, 2), "band": band, "index": i})
|
||||
return out
|
||||
|
||||
@Property("QVariantList", notify=sensorsChanged)
|
||||
@Property("QVariantList", notify=sensorsChanged) # type: ignore[arg-type]
|
||||
def sensorLayout(self) -> list:
|
||||
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
|
||||
return [
|
||||
@@ -164,14 +158,14 @@ class SessionController(QObject):
|
||||
"""Wafer size in mm."""
|
||||
return getattr(self._sensors, "size", 300.0)
|
||||
|
||||
@Property("QVariantList", notify=frameUpdated)
|
||||
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||
def sensorValues(self) -> list:
|
||||
"""[float] in sensor order."""
|
||||
if not self._last:
|
||||
return []
|
||||
return [round(v, 3) for v in self._last.values]
|
||||
|
||||
@Property("QVariantList", notify=frameUpdated)
|
||||
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
|
||||
def sensorBands(self) -> list:
|
||||
"""['in_range'|'high'|'low'] in sensor order."""
|
||||
if not self._last:
|
||||
@@ -193,15 +187,43 @@ class SessionController(QObject):
|
||||
if not self._last:
|
||||
return {}
|
||||
s = self._last.stats
|
||||
return {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
||||
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
||||
out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
|
||||
"max": round(s.max, 2), "maxIndex": s.max_index + 1,
|
||||
"diff": round(s.diff, 2), "avg": round(s.avg, 2),
|
||||
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
|
||||
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)
|
||||
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]:
|
||||
"""Indices of sensors that currently have a replacement or offset."""
|
||||
return self._sensor_editor.active_indices()
|
||||
@@ -210,7 +232,7 @@ class SessionController(QObject):
|
||||
def clusterAveragingEnabled(self) -> bool:
|
||||
return self._cluster_averaging_enabled
|
||||
|
||||
@clusterAveragingEnabled.setter
|
||||
@clusterAveragingEnabled.setter # type: ignore[no-redef]
|
||||
def clusterAveragingEnabled(self, value: bool) -> None:
|
||||
if self._cluster_averaging_enabled == value:
|
||||
return
|
||||
@@ -226,13 +248,24 @@ class SessionController(QObject):
|
||||
def errorCount(self) -> int:
|
||||
return self._error_count
|
||||
|
||||
@Property(int, notify=liveStatsChanged)
|
||||
def resyncCount(self) -> int:
|
||||
return self._reader.resync_count if self._reader else 0
|
||||
|
||||
# ---- mode + thresholds ----
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def setMode(self, mode: str) -> None:
|
||||
if mode == self._mode:
|
||||
return
|
||||
self.stopStream()
|
||||
if mode != "live":
|
||||
self.stopStream()
|
||||
if self._mode == "live":
|
||||
# Leaving a live session: blank the wafer map instead of
|
||||
# freezing the last streamed frame on screen.
|
||||
self._last = None
|
||||
self._last_raw_frame = None
|
||||
self.frameUpdated.emit()
|
||||
self._play_timer.stop()
|
||||
self._mode = mode
|
||||
self.modeChanged.emit()
|
||||
@@ -259,24 +292,25 @@ class SessionController(QObject):
|
||||
|
||||
frames = []
|
||||
|
||||
if is_official_csv(file_path):
|
||||
sensors, records = read_official_csv(file_path)
|
||||
frames = frames_from_wafer_data(None, records)
|
||||
else:
|
||||
try:
|
||||
try:
|
||||
if is_official_csv(file_path):
|
||||
sensors, records = read_official_csv(file_path)
|
||||
frames = frames_from_wafer_data(None, records)
|
||||
else:
|
||||
data, _ = ZWaferParser().parse(file_path)
|
||||
except (ValueError, KeyError) as exc:
|
||||
log.warning("Could not parse %s: %s", file_path, exc)
|
||||
return
|
||||
if data is None or not data.sensors:
|
||||
log.warning("Could not parse %s", file_path)
|
||||
return
|
||||
records = read_data_records(file_path)
|
||||
sensors = data.sensors
|
||||
frames = frames_from_wafer_data(data, records)
|
||||
if data is None or not data.sensors:
|
||||
self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
|
||||
return
|
||||
records = read_data_records(file_path)
|
||||
sensors = data.sensors
|
||||
frames = frames_from_wafer_data(data, records)
|
||||
|
||||
if not sensors or not frames:
|
||||
log.warning("No sensors or data in %s", file_path)
|
||||
if not sensors or not frames:
|
||||
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
|
||||
|
||||
wafer_id = ""
|
||||
@@ -287,22 +321,270 @@ class SessionController(QObject):
|
||||
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||
|
||||
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._active_clusters = getattr(self._sensors, 'clusters', [])
|
||||
if not self._active_clusters:
|
||||
self._active_clusters = group_sensors_by_radius(self._sensors)
|
||||
self._player.load(frames)
|
||||
self._model.reset()
|
||||
self._stats_tracker.reset()
|
||||
self._loaded_file = file_path
|
||||
self.loadedFileChanged.emit()
|
||||
self.sensorsChanged.emit()
|
||||
self._emit_current()
|
||||
# Populate stats tracker from all loaded frames for trend chart (P3.1)
|
||||
for frame in self._player._frames:
|
||||
self._stats_tracker.track(frame.seq, frame.values)
|
||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||
self.settingsChanged.emit()
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def unloadFile(self) -> None:
|
||||
"""Clear the loaded file, resetting the player and frame states."""
|
||||
self._player.load([])
|
||||
self._model.reset()
|
||||
self._ec_pairs = ()
|
||||
self._loaded_file = ""
|
||||
self.loadedFileChanged.emit()
|
||||
self.sensorsChanged.emit()
|
||||
|
||||
# ---- comparison: DTW between two CSV files ----
|
||||
@Slot(str, str)
|
||||
@slot_error_boundary
|
||||
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||
"""Run DTW comparison between two CSV files and emit result."""
|
||||
from pathlib import Path
|
||||
|
||||
from pygui.backend.comparison import compare_runs
|
||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||
|
||||
if Path(file_a).resolve() == Path(file_b).resolve():
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": "Cannot compare a file to itself. Choose two different runs."
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
if is_official_csv(file_a):
|
||||
_, recs_a = read_official_csv(file_a)
|
||||
else:
|
||||
recs_a = read_data_records(file_a)
|
||||
|
||||
if is_official_csv(file_b):
|
||||
_, recs_b = read_official_csv(file_b)
|
||||
else:
|
||||
recs_b = read_data_records(file_b)
|
||||
if not recs_a or not recs_b:
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": "No data in one or both files"
|
||||
})
|
||||
return
|
||||
|
||||
self._compare_recs_a = recs_a
|
||||
self._compare_recs_b = recs_b
|
||||
|
||||
len_a, len_b = len(recs_a), len(recs_b)
|
||||
# DTW can only align frames both runs actually have; the longer
|
||||
# run's extra tail is still returned for display, just unaligned.
|
||||
overlap_frames = min(len_a, len_b)
|
||||
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
|
||||
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)]
|
||||
time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
|
||||
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
|
||||
|
||||
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
|
||||
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
|
||||
|
||||
# Mean temporal shift along the DTW path: positive means run B
|
||||
# reaches the same profile features later than run A.
|
||||
path = result["warping_path"]
|
||||
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
|
||||
frame_offset_seconds = round(
|
||||
sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2
|
||||
) if path else 0.0
|
||||
|
||||
# Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
|
||||
# the overlap both runs share) and take the largest per-sensor abs
|
||||
# difference across all sensors, not just the sensor[0] series used
|
||||
# for alignment.
|
||||
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
|
||||
max_sensor_deviation = 0.0
|
||||
for i, j in zip(result["index_a"], result["index_b"]):
|
||||
values_a = recs_a[i].values
|
||||
values_b = recs_b[j].values
|
||||
for s in range(min(num_sensors, len(values_a), len(values_b))):
|
||||
diff = abs(values_a[s] - values_b[s])
|
||||
if diff > max_sensor_deviation:
|
||||
max_sensor_deviation = diff
|
||||
|
||||
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||
# same sensor-parsing branch as loadFile() (file_a is assumed representative
|
||||
# of both files' wafer type).
|
||||
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
sensor_layout: list[dict] = []
|
||||
sensor_diff: list[float] = []
|
||||
wafer_shape = "round"
|
||||
wafer_size = 300.0
|
||||
try:
|
||||
if is_official_csv(file_a):
|
||||
sensors, _ = read_official_csv(file_a)
|
||||
stem = Path(file_a).stem
|
||||
wafer_id = stem.split("-")[0] if "-" in stem else stem
|
||||
else:
|
||||
parsed, _ = ZWaferParser().parse(file_a)
|
||||
sensors = parsed.sensors if parsed else []
|
||||
wafer_id = parsed.serial if (parsed and parsed.serial) else ""
|
||||
|
||||
if sensors:
|
||||
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
||||
last_a = recs_a[overlap_frames - 1].values
|
||||
last_b = recs_b[overlap_frames - 1].values
|
||||
n = min(len(sensors), len(last_a), len(last_b))
|
||||
sensor_layout = [
|
||||
{
|
||||
"label": s.label,
|
||||
"x": s.x,
|
||||
"y": s.y,
|
||||
"side": getattr(s, "side", "right"),
|
||||
"offset_x": getattr(s, "offset_x", 0.0),
|
||||
"offset_y": getattr(s, "offset_y", 0.0),
|
||||
}
|
||||
for s in sensors[:n]
|
||||
]
|
||||
sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
|
||||
except Exception as layout_exc:
|
||||
log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)
|
||||
|
||||
self.comparisonResult.emit({
|
||||
"success": True,
|
||||
"distance": result["distance"],
|
||||
# Lists, not tuples — tuples don't survive QVariant conversion
|
||||
"warping_path": [list(p) for p in result["warping_path"][:50]],
|
||||
"frame_offset": frame_offset,
|
||||
"frame_offset_seconds": frame_offset_seconds,
|
||||
"max_sensor_deviation": max_sensor_deviation,
|
||||
"series_a": series_a,
|
||||
"series_b": series_b,
|
||||
"time_a": time_a,
|
||||
"time_b": time_b,
|
||||
"frame_count_a": len_a,
|
||||
"frame_count_b": len_b,
|
||||
"sensor_layout": sensor_layout,
|
||||
"sensor_diff": sensor_diff,
|
||||
"wafer_shape": wafer_shape,
|
||||
"wafer_size": wafer_size,
|
||||
})
|
||||
except Exception as exc:
|
||||
log.warning("Comparison failed: %s", exc)
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
@Slot(int, result="QVariantList")
|
||||
def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
|
||||
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
|
||||
if not self._compare_recs_a or not self._compare_recs_b:
|
||||
return []
|
||||
|
||||
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
|
||||
if idx_a not in self._compare_alignment_map:
|
||||
# DTW path only covers indices up to the shorter run's length —
|
||||
# past that, Run B has no data at this frame, not a clampable one.
|
||||
return []
|
||||
idx_b = self._compare_alignment_map[idx_a]
|
||||
|
||||
values_a = self._compare_recs_a[idx_a].values
|
||||
values_b = self._compare_recs_b[idx_b].values
|
||||
|
||||
n = min(len(values_a), len(values_b))
|
||||
return [round(values_b[i] - values_a[i], 3) for i in range(n)]
|
||||
|
||||
# ---- splitting: threshold-based segmentation ----
|
||||
@Slot(str, float)
|
||||
@slot_error_boundary
|
||||
def splitData(self, file_path: str, threshold: float) -> None:
|
||||
"""Segment temperature profile into Ramp/Soak/Cool phases."""
|
||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||
from pygui.backend.splitter import segment_profile
|
||||
|
||||
try:
|
||||
if is_official_csv(file_path):
|
||||
_, records = read_official_csv(file_path)
|
||||
else:
|
||||
records = read_data_records(file_path)
|
||||
if not records:
|
||||
self.splitResult.emit({
|
||||
"success": False,
|
||||
"error": "No data in file"
|
||||
})
|
||||
return
|
||||
|
||||
# Extract average temperatures (use first sensor as proxy)
|
||||
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
|
||||
segments = segment_profile(avg_temps, threshold)
|
||||
|
||||
# cache for exportSegment (per-segment Export in SplitDialog)
|
||||
self._last_split_file = file_path
|
||||
self._last_split_segments = segments
|
||||
|
||||
self.splitResult.emit({
|
||||
"success": True,
|
||||
"segments": [{
|
||||
"label": seg.label,
|
||||
"start_frame": seg.start_frame,
|
||||
"end_frame": seg.end_frame,
|
||||
"avg_temp": seg.avg_temp
|
||||
} for seg in segments]
|
||||
})
|
||||
except Exception as exc:
|
||||
log.warning("Split failed: %s", exc)
|
||||
self.splitResult.emit({
|
||||
"success": False,
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
@Slot(int)
|
||||
@slot_error_boundary
|
||||
def exportSegment(self, index: int) -> None:
|
||||
"""Write one segment from the last split as a standalone CSV.
|
||||
|
||||
Output lands next to the source file as
|
||||
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
|
||||
so read_official_csv still resolves the layout on re-import.
|
||||
Emits segmentExported with success + path (or error).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||
|
||||
source = getattr(self, "_last_split_file", "")
|
||||
segments = getattr(self, "_last_split_segments", [])
|
||||
if not source or not (0 <= index < len(segments)):
|
||||
self.segmentExported.emit({
|
||||
"success": False,
|
||||
"error": "No split result to export"
|
||||
})
|
||||
return
|
||||
|
||||
seg = segments[index]
|
||||
src = Path(source)
|
||||
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
|
||||
try:
|
||||
ok = CsvRecorder.write_segment(
|
||||
str(dest), str(src), seg.start_frame, seg.end_frame)
|
||||
except Exception as exc:
|
||||
log.warning("Segment export failed: %s", exc)
|
||||
self.segmentExported.emit({"success": False, "error": str(exc)})
|
||||
return
|
||||
|
||||
if ok:
|
||||
self.segmentExported.emit({"success": True, "path": str(dest)})
|
||||
else:
|
||||
self.segmentExported.emit({
|
||||
"success": False,
|
||||
"error": "No frames in range"
|
||||
})
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
@@ -394,6 +676,9 @@ class SessionController(QObject):
|
||||
@Slot(str, str)
|
||||
@slot_error_boundary
|
||||
def startStream(self, port: str, family_code: str = "") -> None:
|
||||
if self._reader is not None:
|
||||
log.warning("startStream: StreamReader is already running.")
|
||||
return
|
||||
|
||||
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||
from pygui.serialcomm.serial_port import SerialPort # transport open
|
||||
@@ -404,6 +689,7 @@ class SessionController(QObject):
|
||||
self.sensorsChanged.emit()
|
||||
except Exception as 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', [])
|
||||
if not self._active_clusters:
|
||||
@@ -434,10 +720,7 @@ class SessionController(QObject):
|
||||
|
||||
# Clear out any old data from the prev sessions
|
||||
self._model.reset()
|
||||
self._trend_buffer.clear()
|
||||
self._trend_timestamps.clear()
|
||||
self._trend_timer.start()
|
||||
self._stats_tracker.reset()
|
||||
self._reset_live_trend()
|
||||
self._last = None
|
||||
self._last_raw_frame = None
|
||||
self._received_count = 0
|
||||
@@ -449,13 +732,24 @@ class SessionController(QObject):
|
||||
cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
|
||||
transport.write(cmd)
|
||||
|
||||
import weakref
|
||||
weak_self = weakref.ref(self)
|
||||
|
||||
def on_error(exc: Exception):
|
||||
log.error("Live stream error: %s", exc)
|
||||
self._liveError.emit()
|
||||
ref = weak_self()
|
||||
if ref is not None:
|
||||
ref._liveError.emit()
|
||||
ref.stopStream()
|
||||
|
||||
def on_frame(frame: Frame):
|
||||
ref = weak_self()
|
||||
if ref is not None:
|
||||
ref._liveFrame.emit(frame)
|
||||
|
||||
self._reader = StreamReader(
|
||||
transport, parse_binary_frame,
|
||||
on_frame=lambda f: self._liveFrame.emit(f),
|
||||
on_frame=on_frame,
|
||||
on_error=on_error,
|
||||
family_code=family_code or "A")
|
||||
self._reader.start()
|
||||
@@ -468,16 +762,14 @@ class SessionController(QObject):
|
||||
@slot_error_boundary
|
||||
def stopStream(self) -> None:
|
||||
self._repaint_timer.stop()
|
||||
self._trend_timer.stop()
|
||||
self._received_count = 0
|
||||
self._error_count = 0
|
||||
self.liveStatsChanged.emit()
|
||||
if self._reader:
|
||||
transport = self._reader._transport
|
||||
self._reader.stop()
|
||||
self._reader = None
|
||||
|
||||
# Send 'D2S' command padded to 512 bytes to stop the stream
|
||||
# Send BEFORE stopping the reader which will close the port on thread exit
|
||||
if transport and transport.is_open:
|
||||
try:
|
||||
cmd = b"D2S" + (b"F" * 509)
|
||||
@@ -486,16 +778,17 @@ class SessionController(QObject):
|
||||
except Exception as exc:
|
||||
log.error("Error sending stop command: %s", exc)
|
||||
|
||||
self._reader.stop()
|
||||
self._reader = None
|
||||
|
||||
self.stateChanged.emit()
|
||||
self.stopRecording()
|
||||
|
||||
@Slot(object)
|
||||
@slot_error_boundary
|
||||
def _on_live_frame(self, frame: Frame) -> None:
|
||||
import json
|
||||
self._received_count += 1
|
||||
self.liveStatsChanged.emit()
|
||||
self._stats_tracker.track(frame.seq, frame.values)
|
||||
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||
self._last_raw_frame = frame
|
||||
edited = Frame(seq=frame.seq, time=frame.time,
|
||||
@@ -504,22 +797,11 @@ class SessionController(QObject):
|
||||
if self._recorder.is_recording:
|
||||
self._recorder.write(frame) # always record raw values, never edited
|
||||
self._dirty = True
|
||||
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
|
||||
|
||||
if self._last and self._last.stats:
|
||||
avg = self._last.stats.avg
|
||||
now = time.monotonic()
|
||||
self._trend_buffer.append(avg)
|
||||
self._trend_timestamps.append(now)
|
||||
|
||||
# drop entries older than 60 secs
|
||||
cutoff = now -60
|
||||
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
|
||||
self._trend_timestamps.pop(0)
|
||||
self._trend_buffer.pop(0)
|
||||
|
||||
|
||||
|
||||
elapsed = time.monotonic() - self._stream_start_time
|
||||
self.trendDelta.emit(json.dumps([[elapsed, avg]]))
|
||||
|
||||
def _flush_repaint(self) -> None:
|
||||
if not self._dirty:
|
||||
@@ -575,8 +857,8 @@ class SessionController(QObject):
|
||||
self._sensor_editor.clear()
|
||||
self._reprocess_current()
|
||||
|
||||
def _flush_trend(self) -> None:
|
||||
import json
|
||||
self.trendData.emit(json.dumps(self._trend_buffer))
|
||||
|
||||
def _reset_live_trend(self) -> None:
|
||||
"""Mark a fresh stream start for elapsed-time trend deltas."""
|
||||
self._stream_start_time = time.monotonic()
|
||||
self.trendReset.emit()
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# ===== Crypto Sub-package =====
|
||||
from pygui.backend.crypto.crypto_helper import * # noqa: F403
|
||||
|
||||
@@ -1,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 =====
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QStandardPaths
|
||||
|
||||
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 self.wafer[0] if self.wafer else ""
|
||||
|
||||
def get_date(self) -> datetime:
|
||||
"""Parsifes the date string. Returns current time if parsing fails"""
|
||||
def get_date(self) -> datetime | None:
|
||||
"""Parsifes the date string. Returns None if parsing fails"""
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
try:
|
||||
return datetime.strptime(self.date, date_format)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Append live frames"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -12,17 +13,15 @@ from pygui.backend.wafer.zwafer_models import Sensor
|
||||
class CsvRecorder:
|
||||
def __init__(self) -> None:
|
||||
self._file_handle: Optional[TextIO] = None
|
||||
self._oath: Optional[str] = None
|
||||
|
||||
self._path: Optional[str] = None
|
||||
|
||||
@property
|
||||
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)
|
||||
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"Acquisition Date={datetime.now():%m/%d/%Y}\n")
|
||||
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
|
||||
@@ -32,7 +31,7 @@ class CsvRecorder:
|
||||
file_handle.flush()
|
||||
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:
|
||||
return
|
||||
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
|
||||
@@ -47,9 +46,15 @@ class CsvRecorder:
|
||||
return path
|
||||
|
||||
@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.
|
||||
|
||||
Preserves the source's header structure so the segment re-imports
|
||||
through FileBrowser unchanged: official CSVs keep their sensor-name
|
||||
row, Z-wafer CSVs keep every metadata line through the "data"
|
||||
sentinel. Frame indices count only valid data rows, matching how
|
||||
the readers in data_records.py number frames.
|
||||
|
||||
Args:
|
||||
file_path: Output CSV path.
|
||||
source_path: Source CSV path with full wafer data
|
||||
@@ -57,11 +62,42 @@ class CsvRecorder:
|
||||
end_frame: Last frame index (inclusive)
|
||||
|
||||
Returns:
|
||||
True on success.
|
||||
True on success (at least one frame written).
|
||||
"""
|
||||
import pandas as pd
|
||||
df = pd.read_csv(source_path)
|
||||
segment = df.iloc[start_frame:end_frame + 1]
|
||||
segment.to_csv(file_path, index=False)
|
||||
return True
|
||||
from pygui.backend.data.data_records import is_data_row, is_official_csv
|
||||
|
||||
if start_frame < 0 or end_frame < start_frame:
|
||||
return False
|
||||
|
||||
with open(source_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
out: list[str] = []
|
||||
written = 0
|
||||
frame_idx = -1
|
||||
in_data = is_official_csv(source_path)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.rstrip().rstrip(",").strip()
|
||||
if not in_data:
|
||||
out.append(line)
|
||||
if stripped and stripped.split(",")[0].lower() == "data":
|
||||
in_data = True
|
||||
continue
|
||||
if i == 0:
|
||||
# official format: row 0 is the sensor-name header
|
||||
out.append(line)
|
||||
continue
|
||||
if not stripped or not is_data_row(stripped):
|
||||
continue
|
||||
frame_idx += 1
|
||||
if start_frame <= frame_idx <= end_frame:
|
||||
out.append(line if line.endswith("\n") else line + "\n")
|
||||
written += 1
|
||||
|
||||
if written == 0:
|
||||
return False
|
||||
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8", newline="") as f:
|
||||
f.writelines(out)
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
@@ -6,6 +7,20 @@ from pathlib import Path
|
||||
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]:
|
||||
records: list[DataRecord] = []
|
||||
in_data = False
|
||||
@@ -18,13 +33,10 @@ def read_data_records(file_path: str) -> list[DataRecord]:
|
||||
if line.split(",")[0].lower() == "data":
|
||||
in_data = True
|
||||
continue
|
||||
if not is_data_row(line):
|
||||
continue
|
||||
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
|
||||
try:
|
||||
nums = [float(c) for c in cols]
|
||||
except ValueError:
|
||||
continue
|
||||
if not nums:
|
||||
continue
|
||||
nums = [float(c) for c in cols]
|
||||
records.append(DataRecord(time=nums[0], values=nums[1:]))
|
||||
return records
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import re
|
||||
from datetime import datetime
|
||||
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 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.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
@@ -28,7 +28,7 @@ class FileBrowser(QObject):
|
||||
self._refresh_files(show_empty_message=False)
|
||||
|
||||
# ===== Exposed Properties =====
|
||||
@Property("QVariantList", notify=filesChanged)
|
||||
@Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
|
||||
def files(self) -> list[dict[str, object]]:
|
||||
return [dict(row) for row in self._files]
|
||||
|
||||
@@ -37,12 +37,22 @@ class FileBrowser(QObject):
|
||||
return str(self._current_directory)
|
||||
|
||||
# ===== 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()
|
||||
def chooseDirectory(self) -> None:
|
||||
selected_dir = QFileDialog.getExistingDirectory(
|
||||
None,
|
||||
"Select CSV Folder",
|
||||
self.currentDirectory,
|
||||
str(self._current_directory),
|
||||
)
|
||||
if not selected_dir:
|
||||
return
|
||||
@@ -71,17 +81,16 @@ class FileBrowser(QObject):
|
||||
self._show_error(f"CSV file was not found:\n{file_path}")
|
||||
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)
|
||||
if row is None:
|
||||
self._show_error("The selected CSV row is no longer available.")
|
||||
return
|
||||
|
||||
row["wafer"] = wafer
|
||||
row["date"] = date
|
||||
row["chamber"] = chamber
|
||||
row["notes"] = notes
|
||||
row["selected"] = selected
|
||||
row["masterType"] = master_type
|
||||
if row is not None:
|
||||
row["wafer"] = wafer
|
||||
row["date"] = date
|
||||
row["chamber"] = chamber
|
||||
row["notes"] = notes
|
||||
row["selected"] = selected
|
||||
row["masterType"] = master_type
|
||||
|
||||
payload = {
|
||||
"wafer": wafer,
|
||||
@@ -119,6 +128,11 @@ class FileBrowser(QObject):
|
||||
return
|
||||
|
||||
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:
|
||||
metadata = self._load_metadata(csv_path)
|
||||
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,
|
||||
"fileName": str(csv_path),
|
||||
"highlight": wafer_type in {"A", "B", "C"},
|
||||
"isRecording": is_recording,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
@@ -169,6 +184,7 @@ class FileBrowser(QObject):
|
||||
"masterType": master_state.get(str(csv_path), ""),
|
||||
"fileName": str(csv_path),
|
||||
"highlight": False,
|
||||
"isRecording": is_recording,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -229,11 +245,7 @@ class FileBrowser(QObject):
|
||||
|
||||
# ===== Internal Helpers =====
|
||||
def _resolve_default_directory(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
|
||||
return default_data_dir()
|
||||
|
||||
def _set_current_directory(self, directory: Path) -> None:
|
||||
normalized = Path(directory)
|
||||
|
||||
@@ -13,8 +13,8 @@ class LocalSettings:
|
||||
# Configuration settings
|
||||
self.chamber_id = ""
|
||||
self.reverse_z_wafer = False
|
||||
self.master = {} # Dict[str, str]
|
||||
self.wafer_data_size = {} # Dict[str, int]
|
||||
self.master: dict[str, str] = {}
|
||||
self.wafer_data_size: dict[str, int] = {}
|
||||
self.debug = False
|
||||
self.wafer_read_retries = 8
|
||||
# Timeout in msec for reading from the wafer (default 2 min)
|
||||
@@ -32,9 +32,6 @@ class LocalSettings:
|
||||
self.data_col_count = 0
|
||||
self.last_csv_path = ""
|
||||
|
||||
# Session persistence
|
||||
self.session_last_file = ""
|
||||
|
||||
# ===== File Path Helpers =====
|
||||
@classmethod
|
||||
def _settings_path(cls, directory: str) -> Path:
|
||||
|
||||
@@ -2,17 +2,26 @@ from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
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
|
||||
|
||||
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
|
||||
|
||||
|
||||
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()
|
||||
reverseZWaferChanged = Signal()
|
||||
debugModeChanged = Signal()
|
||||
@@ -26,18 +35,35 @@ class LocalSettingsModel(QObject):
|
||||
saveStatusChanged = 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)
|
||||
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._chamber_id = self._defaults["chamberId"]
|
||||
self._reverse_z_wafer = self._defaults["reverseZWafer"]
|
||||
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"])
|
||||
for draft_attr, default_value in self._defaults.items():
|
||||
setattr(self, draft_attr, deepcopy(default_value))
|
||||
|
||||
self._is_dirty = False
|
||||
self._is_valid = True
|
||||
@@ -47,34 +73,21 @@ class LocalSettingsModel(QObject):
|
||||
self._saved_snapshot = self._snapshot()
|
||||
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]:
|
||||
return {
|
||||
"chamberId": "2",
|
||||
"reverseZWafer": False,
|
||||
"debugMode": False,
|
||||
"waferReadTimeout": 120000,
|
||||
"waferDetectTimeout": 5000,
|
||||
"waferRetries": 8,
|
||||
"masters": {family: "" for family in MASTER_FAMILIES},
|
||||
"_chamber_id": "2",
|
||||
"_reverse_z_wafer": False,
|
||||
"_debug_mode": False,
|
||||
"_wafer_read_timeout": 120000,
|
||||
"_wafer_detect_timeout": 5000,
|
||||
"_wafer_retries": 8,
|
||||
"_masters": {family: "" for family in MASTER_FAMILIES},
|
||||
}
|
||||
|
||||
def _snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
"chamberId": self._chamber_id,
|
||||
"reverseZWafer": self._reverse_z_wafer,
|
||||
"debugMode": self._debug_mode,
|
||||
"waferReadTimeout": self._wafer_read_timeout,
|
||||
"waferDetectTimeout": self._wafer_detect_timeout,
|
||||
"waferRetries": self._wafer_retries,
|
||||
"masters": deepcopy(self._masters),
|
||||
}
|
||||
snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
|
||||
snap["_masters"] = deepcopy(self._masters)
|
||||
return snap
|
||||
|
||||
def _set_is_dirty(self, value: bool) -> None:
|
||||
if self._is_dirty != value:
|
||||
@@ -137,22 +150,19 @@ class LocalSettingsModel(QObject):
|
||||
self.waferRetriesChanged.emit()
|
||||
self.mastersChanged.emit()
|
||||
|
||||
def _to_local_settings(self) -> LocalSettings:
|
||||
settings = LocalSettings()
|
||||
settings.chamber_id = self._chamber_id
|
||||
settings.reverse_z_wafer = self._reverse_z_wafer
|
||||
settings.debug = self._debug_mode
|
||||
settings.wafer_read_timeout = self._wafer_read_timeout
|
||||
settings.wafer_detect_timeout = self._wafer_detect_timeout
|
||||
settings.wafer_read_retries = self._wafer_retries
|
||||
settings.master = deepcopy(self._masters)
|
||||
return settings
|
||||
def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
|
||||
masters = {family: "" for family in MASTER_FAMILIES}
|
||||
for family, value in source.items():
|
||||
normalized = str(family).strip().upper()
|
||||
if normalized in masters:
|
||||
masters[normalized] = str(value).strip()
|
||||
return masters
|
||||
|
||||
@Property(str, notify=chamberIdChanged)
|
||||
def chamberId(self) -> str:
|
||||
return self._chamber_id
|
||||
|
||||
@chamberId.setter
|
||||
@chamberId.setter # type: ignore[no-redef]
|
||||
def chamberId(self, value: str) -> None:
|
||||
next_value = str(value).strip()
|
||||
if self._chamber_id == next_value:
|
||||
@@ -165,7 +175,7 @@ class LocalSettingsModel(QObject):
|
||||
def reverseZWafer(self) -> bool:
|
||||
return self._reverse_z_wafer
|
||||
|
||||
@reverseZWafer.setter
|
||||
@reverseZWafer.setter # type: ignore[no-redef]
|
||||
def reverseZWafer(self, value: bool) -> None:
|
||||
if self._reverse_z_wafer == value:
|
||||
return
|
||||
@@ -177,7 +187,7 @@ class LocalSettingsModel(QObject):
|
||||
def debugMode(self) -> bool:
|
||||
return self._debug_mode
|
||||
|
||||
@debugMode.setter
|
||||
@debugMode.setter # type: ignore[no-redef]
|
||||
def debugMode(self, value: bool) -> None:
|
||||
if self._debug_mode == value:
|
||||
return
|
||||
@@ -189,7 +199,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferReadTimeout(self) -> int:
|
||||
return self._wafer_read_timeout
|
||||
|
||||
@waferReadTimeout.setter
|
||||
@waferReadTimeout.setter # type: ignore[no-redef]
|
||||
def waferReadTimeout(self, value: int) -> None:
|
||||
if self._wafer_read_timeout == value:
|
||||
return
|
||||
@@ -201,7 +211,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferDetectTimeout(self) -> int:
|
||||
return self._wafer_detect_timeout
|
||||
|
||||
@waferDetectTimeout.setter
|
||||
@waferDetectTimeout.setter # type: ignore[no-redef]
|
||||
def waferDetectTimeout(self, value: int) -> None:
|
||||
if self._wafer_detect_timeout == value:
|
||||
return
|
||||
@@ -213,7 +223,7 @@ class LocalSettingsModel(QObject):
|
||||
def waferRetries(self) -> int:
|
||||
return self._wafer_retries
|
||||
|
||||
@waferRetries.setter
|
||||
@waferRetries.setter # type: ignore[no-redef]
|
||||
def waferRetries(self, value: int) -> None:
|
||||
if self._wafer_retries == value:
|
||||
return
|
||||
@@ -251,22 +261,11 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def loadSettings(self) -> None:
|
||||
loaded = LocalSettings.read_settings(str(self._data_dir))
|
||||
self._chamber_id = str(loaded.chamber_id).strip() or str(
|
||||
self._defaults["chamberId"]
|
||||
)
|
||||
self._reverse_z_wafer = bool(loaded.reverse_z_wafer)
|
||||
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
|
||||
"""Seed draft fields from the shared LocalSettings instance."""
|
||||
for draft_attr, settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
|
||||
self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
|
||||
self._masters = self._normalized_masters(self._settings.master)
|
||||
|
||||
self._saved_snapshot = self._snapshot()
|
||||
self._emit_all_changed()
|
||||
@@ -275,13 +274,18 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def saveSettings(self) -> None:
|
||||
"""Write draft fields onto the shared LocalSettings instance and persist it."""
|
||||
self._recompute_derived()
|
||||
if not self._is_valid:
|
||||
self._set_save_status("error: invalid settings")
|
||||
return
|
||||
|
||||
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._recompute_derived()
|
||||
self._set_save_status("Saved")
|
||||
@@ -293,13 +297,9 @@ class LocalSettingsModel(QObject):
|
||||
@Slot()
|
||||
def revertChanges(self) -> None:
|
||||
snap = self._saved_snapshot
|
||||
self._chamber_id = str(snap["chamberId"])
|
||||
self._reverse_z_wafer = bool(snap["reverseZWafer"])
|
||||
self._debug_mode = bool(snap["debugMode"])
|
||||
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"])
|
||||
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(snap[draft_attr]))
|
||||
self._masters = deepcopy(snap["_masters"])
|
||||
|
||||
self._emit_all_changed()
|
||||
self._recompute_derived()
|
||||
@@ -307,13 +307,9 @@ class LocalSettingsModel(QObject):
|
||||
|
||||
@Slot()
|
||||
def resetDefaults(self) -> None:
|
||||
self._chamber_id = str(self._defaults["chamberId"])
|
||||
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"])
|
||||
self._debug_mode = bool(self._defaults["debugMode"])
|
||||
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"])
|
||||
for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
|
||||
setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
|
||||
self._masters = deepcopy(self._defaults["_masters"])
|
||||
|
||||
self._emit_all_changed()
|
||||
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
|
||||
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():
|
||||
return None
|
||||
|
||||
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
||||
return None
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: int, role: int = ...
|
||||
self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
|
||||
) -> Any:
|
||||
if role != Qt.ItemDataRole.DisplayRole:
|
||||
return None
|
||||
|
||||
@@ -4,10 +4,23 @@ from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.wafer.zwafer_models import ZWaferData
|
||||
|
||||
|
||||
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]:
|
||||
def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
|
||||
"""Build Frames from parsed DataRecords (records = list[DataRecord].)"""
|
||||
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self) -> None:
|
||||
self._frames: list[Frame] =[]
|
||||
@@ -22,6 +35,10 @@ class FramePlayer:
|
||||
def total(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
@property
|
||||
def frames(self) -> tuple[Frame, ...]:
|
||||
return tuple(self._frames)
|
||||
|
||||
@property
|
||||
def index(self) -> int:
|
||||
return self._i
|
||||
|
||||
@@ -14,6 +14,44 @@ class Stats:
|
||||
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:
|
||||
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
|
||||
if not clean:
|
||||
|
||||
@@ -28,6 +28,11 @@ class SensorEditor:
|
||||
def has_overrides(self) -> bool:
|
||||
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]:
|
||||
"""Return sorted sensor indices that have any override."""
|
||||
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 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.QtQml import QmlElement
|
||||
from PySide6.QtQuick import QQuickPaintedItem
|
||||
|
||||
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
|
||||
|
||||
QML_IMPORT_NAME = "ISC.Wafer"
|
||||
QML_IMPORT_MAJOR_VERSION = 1
|
||||
|
||||
|
||||
def viewport_stats(
|
||||
series: list[list[float]],
|
||||
names: list[str],
|
||||
start: int,
|
||||
end: int,
|
||||
) -> tuple[float, float, float, str, str]:
|
||||
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
|
||||
|
||||
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
|
||||
every visible point from every series, plus which sensor achieved each
|
||||
extreme. Non-numeric values are skipped. No points in range -> zeros and
|
||||
empty sensor names.
|
||||
"""
|
||||
vmin = math.inf
|
||||
vmax = -math.inf
|
||||
total = 0.0
|
||||
count = 0
|
||||
min_sensor = ""
|
||||
max_sensor = ""
|
||||
for i, s in enumerate(series):
|
||||
name = names[i] if i < len(names) else ""
|
||||
lo = max(0, start)
|
||||
hi = min(len(s) - 1, end)
|
||||
for j in range(lo, hi + 1):
|
||||
try:
|
||||
v = float(s[j])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v < vmin:
|
||||
vmin = v
|
||||
min_sensor = name
|
||||
if v > vmax:
|
||||
vmax = v
|
||||
max_sensor = name
|
||||
total += v
|
||||
count += 1
|
||||
if count == 0:
|
||||
return (0.0, 0.0, 0.0, "", "")
|
||||
return (vmin, vmax, total / count, min_sensor, max_sensor)
|
||||
|
||||
|
||||
@QmlElement
|
||||
class GraphQuickItem(QQuickPaintedItem):
|
||||
"""Painted line chart; driven by series data passed via QML property bindings."""
|
||||
@@ -40,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
xLabelChanged = Signal()
|
||||
yLabelChanged = Signal()
|
||||
colorsChanged = Signal()
|
||||
viewportChanged = Signal()
|
||||
viewStatsChanged = Signal()
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -72,16 +119,37 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
self._max_y: float = 150.0
|
||||
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
|
||||
|
||||
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
|
||||
# so the Graph tab (which never sets these) is unaffected. See
|
||||
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
|
||||
self._view_start: int = 0
|
||||
self._view_end: int = -1
|
||||
self._show_min_max_markers: bool = False
|
||||
self._view_min: float = 0.0
|
||||
self._view_max: float = 0.0
|
||||
self._view_avg: float = 0.0
|
||||
self._view_min_sensor: str = ""
|
||||
self._view_max_sensor: str = ""
|
||||
|
||||
# ── Qt properties (QML-bindable) ──────────────────────────────────────────
|
||||
|
||||
@Property("QVariantList", notify=seriesDataChanged)
|
||||
def seriesData(self) -> list:
|
||||
return self._series_data
|
||||
|
||||
@seriesData.setter
|
||||
@seriesData.setter # type: ignore[no-redef]
|
||||
def seriesData(self, val: list) -> None:
|
||||
self._series_data = [list(s) for s in (val or [])]
|
||||
# New data resets the viewport to the full range -- matches
|
||||
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
|
||||
# before RecalculateAxesScale(). Without this, a stale viewport left
|
||||
# over from a previous (longer) series can point entirely past the
|
||||
# new data -- every per-series slice becomes empty and the chart
|
||||
# silently renders nothing instead of the new data.
|
||||
self._view_start = 0
|
||||
self._view_end = -1
|
||||
self._auto_range()
|
||||
self._recompute_view_stats()
|
||||
self.seriesDataChanged.emit()
|
||||
self.update()
|
||||
|
||||
@@ -89,17 +157,138 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def sensorNames(self) -> list:
|
||||
return self._sensor_names
|
||||
|
||||
@sensorNames.setter
|
||||
@sensorNames.setter # type: ignore[no-redef]
|
||||
def sensorNames(self, val: list) -> None:
|
||||
self._sensor_names = list(val or [])
|
||||
self._recompute_view_stats()
|
||||
self.sensorNamesChanged.emit()
|
||||
self.update()
|
||||
|
||||
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
|
||||
|
||||
@Property(int, notify=viewportChanged)
|
||||
def viewStartIndex(self) -> int:
|
||||
return self._view_start
|
||||
|
||||
@viewStartIndex.setter # type: ignore[no-redef]
|
||||
def viewStartIndex(self, val: int) -> None:
|
||||
v = max(0, int(val))
|
||||
if v == self._view_start:
|
||||
return
|
||||
self._view_start = v
|
||||
self._on_viewport_changed()
|
||||
|
||||
@Property(int, notify=viewportChanged)
|
||||
def viewEndIndex(self) -> int:
|
||||
return self._view_end
|
||||
|
||||
@viewEndIndex.setter # type: ignore[no-redef]
|
||||
def viewEndIndex(self, val: int) -> None:
|
||||
v = int(val)
|
||||
if v == self._view_end:
|
||||
return
|
||||
self._view_end = v
|
||||
self._on_viewport_changed()
|
||||
|
||||
@Property(bool, notify=viewportChanged)
|
||||
def showMinMaxMarkers(self) -> bool:
|
||||
return self._show_min_max_markers
|
||||
|
||||
@showMinMaxMarkers.setter # type: ignore[no-redef]
|
||||
def showMinMaxMarkers(self, val: bool) -> None:
|
||||
v = bool(val)
|
||||
if v == self._show_min_max_markers:
|
||||
return
|
||||
self._show_min_max_markers = v
|
||||
self.viewportChanged.emit()
|
||||
self.update()
|
||||
|
||||
@Property(float, notify=viewStatsChanged)
|
||||
def viewMin(self) -> float:
|
||||
return self._view_min
|
||||
|
||||
@Property(float, notify=viewStatsChanged)
|
||||
def viewMax(self) -> float:
|
||||
return self._view_max
|
||||
|
||||
@Property(float, notify=viewStatsChanged)
|
||||
def viewAvg(self) -> float:
|
||||
return self._view_avg
|
||||
|
||||
@Property(str, notify=viewStatsChanged)
|
||||
def viewMinSensor(self) -> str:
|
||||
return self._view_min_sensor
|
||||
|
||||
@Property(str, notify=viewStatsChanged)
|
||||
def viewMaxSensor(self) -> str:
|
||||
return self._view_max_sensor
|
||||
|
||||
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
|
||||
# with fractions derived from wheel/drag pixel deltas) ───────────────
|
||||
|
||||
@Slot(float, float)
|
||||
def zoomAtFraction(self, frac: float, factor: float) -> None:
|
||||
"""Zoom the viewport around a fractional X position within it.
|
||||
|
||||
`frac` in [0, 1] is where within the current viewport to zoom around;
|
||||
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
|
||||
(zoom out). Clamped to data bounds and a minimum 1-index window.
|
||||
"""
|
||||
max_len = max((len(s) for s in self._series_data), default=0)
|
||||
if max_len < 2:
|
||||
return
|
||||
start = float(self._view_start)
|
||||
end = float(self._resolved_view_end())
|
||||
width = max(1.0, end - start)
|
||||
f = max(0.0, min(1.0, frac))
|
||||
center = start + f * width
|
||||
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
|
||||
new_start = center - f * new_width
|
||||
new_end = new_start + new_width
|
||||
self._set_viewport_clamped(new_start, new_end, max_len)
|
||||
|
||||
@Slot(float)
|
||||
def panByFraction(self, delta_frac: float) -> None:
|
||||
"""Shift the viewport by `delta_frac * window_width` indices."""
|
||||
max_len = max((len(s) for s in self._series_data), default=0)
|
||||
if max_len < 2:
|
||||
return
|
||||
start = float(self._view_start)
|
||||
end = float(self._resolved_view_end())
|
||||
width = max(1.0, end - start)
|
||||
shift = delta_frac * width
|
||||
self._set_viewport_clamped(start + shift, end + shift, max_len)
|
||||
|
||||
@Slot()
|
||||
def resetZoom(self) -> None:
|
||||
"""Restore the full-range viewport sentinel (0, -1)."""
|
||||
self._set_viewport(0, -1)
|
||||
|
||||
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
|
||||
if new_start < 0.0:
|
||||
new_end -= new_start
|
||||
new_start = 0.0
|
||||
if new_end > max_len - 1:
|
||||
new_start -= new_end - (max_len - 1)
|
||||
new_end = float(max_len - 1)
|
||||
new_start = max(0.0, new_start)
|
||||
self._set_viewport(int(round(new_start)), int(round(new_end)))
|
||||
|
||||
def _set_viewport(self, start: int, end: int) -> None:
|
||||
"""Set the viewport bounds directly (not through the QML-facing
|
||||
property setters, so this stays a plain attribute write for mypy)."""
|
||||
new_start = max(0, start)
|
||||
if new_start == self._view_start and end == self._view_end:
|
||||
return
|
||||
self._view_start = new_start
|
||||
self._view_end = end
|
||||
self._on_viewport_changed()
|
||||
|
||||
@Property(str, notify=titleChanged)
|
||||
def title(self) -> str:
|
||||
return self._title
|
||||
|
||||
@title.setter
|
||||
@title.setter # type: ignore[no-redef]
|
||||
def title(self, val: str) -> None:
|
||||
self._title = str(val or "")
|
||||
self.titleChanged.emit()
|
||||
@@ -109,7 +298,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def xLabel(self) -> str:
|
||||
return self._x_label
|
||||
|
||||
@xLabel.setter
|
||||
@xLabel.setter # type: ignore[no-redef]
|
||||
def xLabel(self, val: str) -> None:
|
||||
self._x_label = str(val or "")
|
||||
self.xLabelChanged.emit()
|
||||
@@ -119,7 +308,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def yLabel(self) -> str:
|
||||
return self._y_label
|
||||
|
||||
@yLabel.setter
|
||||
@yLabel.setter # type: ignore[no-redef]
|
||||
def yLabel(self, val: str) -> None:
|
||||
self._y_label = str(val or "")
|
||||
self.yLabelChanged.emit()
|
||||
@@ -129,7 +318,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def showLegend(self) -> bool:
|
||||
return self._show_legend
|
||||
|
||||
@showLegend.setter
|
||||
@showLegend.setter # type: ignore[no-redef]
|
||||
def showLegend(self, val: bool) -> None:
|
||||
self._show_legend = bool(val)
|
||||
self.colorsChanged.emit()
|
||||
@@ -141,7 +330,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def backgroundColor(self) -> QColor:
|
||||
return self._bg_color
|
||||
|
||||
@backgroundColor.setter
|
||||
@backgroundColor.setter # type: ignore[no-redef]
|
||||
def backgroundColor(self, c: QColor) -> None:
|
||||
self._bg_color = c
|
||||
self.update()
|
||||
@@ -150,7 +339,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
def gridColor(self) -> QColor:
|
||||
return self._grid_color
|
||||
|
||||
@gridColor.setter
|
||||
@gridColor.setter # type: ignore[no-redef]
|
||||
def gridColor(self, c: QColor) -> None:
|
||||
self._grid_color = c
|
||||
self.update()
|
||||
@@ -159,7 +348,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
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()
|
||||
@@ -168,7 +357,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
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()
|
||||
@@ -178,7 +367,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
"""Return hex strings of the current series color palette."""
|
||||
return [c.name() for c in self._series_colors]
|
||||
|
||||
@seriesColors.setter
|
||||
@seriesColors.setter # type: ignore[no-redef]
|
||||
def seriesColors(self, hex_list: list) -> None:
|
||||
colors = []
|
||||
for h in (hex_list or []):
|
||||
@@ -192,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
|
||||
# ── internal ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolved_view_end(self) -> int:
|
||||
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
|
||||
max_len = max((len(s) for s in self._series_data), default=0)
|
||||
if self._view_end < 0 or self._view_end > max_len - 1:
|
||||
return max_len - 1
|
||||
return self._view_end
|
||||
|
||||
def _on_viewport_changed(self) -> None:
|
||||
self._auto_range()
|
||||
self._recompute_view_stats()
|
||||
self.viewportChanged.emit()
|
||||
self.update()
|
||||
|
||||
def _recompute_view_stats(self) -> None:
|
||||
end = self._resolved_view_end()
|
||||
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
|
||||
self._series_data, self._sensor_names, self._view_start, end
|
||||
)
|
||||
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
|
||||
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
|
||||
self.viewStatsChanged.emit()
|
||||
|
||||
def _auto_range(self) -> None:
|
||||
"""Set Y range to cover 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] = []
|
||||
for series in self._series_data:
|
||||
for v in series:
|
||||
hi = min(len(series) - 1, end)
|
||||
for i in range(start, hi + 1):
|
||||
try:
|
||||
all_vals.append(float(v))
|
||||
all_vals.append(float(series[i]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if not all_vals:
|
||||
@@ -275,8 +489,7 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
|
||||
for i in range(n_y_ticks):
|
||||
y_val = self._min_y + i * y_step
|
||||
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5
|
||||
y_px = plot_top + y_frac * plot_h
|
||||
y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
|
||||
|
||||
# Grid line
|
||||
painter.setPen(grid_pen)
|
||||
@@ -318,19 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
self._x_label,
|
||||
)
|
||||
|
||||
# --- X-axis ticks ---
|
||||
num_points = max(len(s) for s in self._series_data) if self._series_data else 0
|
||||
if num_points > 1:
|
||||
n_x_ticks = min(num_points, max(2, int(plot_w // 80)))
|
||||
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1)
|
||||
# --- Viewport slice (replay chart zoom/pan; full range by default,
|
||||
# so unset viewport reproduces the pre-viewport rendering) ---
|
||||
view_start = max(0, self._view_start)
|
||||
view_end = self._resolved_view_end()
|
||||
view_len = max(1, view_end - view_start + 1)
|
||||
|
||||
# --- X-axis ticks (labelled in original, non-sliced index space) ---
|
||||
if view_len > 1:
|
||||
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
|
||||
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
|
||||
for i in range(n_x_ticks):
|
||||
idx = int(round(i * x_tick_step))
|
||||
x_frac = idx / (num_points - 1)
|
||||
x_px = plot_left + x_frac * plot_w
|
||||
rel_idx = int(round(i * x_tick_step))
|
||||
x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
|
||||
painter.setPen(axis_pen)
|
||||
painter.drawText(
|
||||
int(x_px - 12), int(plot_top + plot_h + 14),
|
||||
str(idx + 1),
|
||||
str(view_start + rel_idx + 1),
|
||||
)
|
||||
|
||||
# --- 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 + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
|
||||
|
||||
# --- Figure out max series length ---
|
||||
max_len = max(len(s) for s in self._series_data) if self._series_data else 0
|
||||
if max_len < 1:
|
||||
return
|
||||
|
||||
# --- Draw each series ---
|
||||
# --- Draw each series (sliced to the current viewport) ---
|
||||
min_pt: tuple[float, float] | None = None
|
||||
max_pt: tuple[float, float] | None = None
|
||||
min_seen = math.inf
|
||||
max_seen = -math.inf
|
||||
for si, series in enumerate(self._series_data):
|
||||
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
|
||||
color = self._series_colors[si % len(self._series_colors)]
|
||||
pen = QPen(color, 2)
|
||||
painter.setPen(pen)
|
||||
|
||||
pts: list[tuple[float, float]] = []
|
||||
for i, val in enumerate(series):
|
||||
for i, val in enumerate(sliced):
|
||||
try:
|
||||
v = float(val)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
x_frac = i / (max_len - 1) if max_len > 1 else 0.5
|
||||
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5
|
||||
x_px = plot_left + x_frac * plot_w
|
||||
y_px = plot_top + y_frac * plot_h
|
||||
x_px = index_to_pixel(i, view_len, plot_left, plot_w)
|
||||
y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
|
||||
pts.append((x_px, y_px))
|
||||
if self._show_min_max_markers:
|
||||
if v < min_seen:
|
||||
min_seen = v
|
||||
min_pt = (x_px, y_px)
|
||||
if v > max_seen:
|
||||
max_seen = v
|
||||
max_pt = (x_px, y_px)
|
||||
|
||||
if len(pts) < 2:
|
||||
if len(pts) == 1:
|
||||
@@ -375,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
|
||||
int(pts[i + 1][0]), int(pts[i + 1][1]),
|
||||
)
|
||||
|
||||
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
|
||||
if self._show_min_max_markers:
|
||||
marker_font = QFont()
|
||||
marker_font.setPixelSize(14)
|
||||
marker_font.setBold(True)
|
||||
painter.setFont(marker_font)
|
||||
if max_pt is not None:
|
||||
painter.setPen(QPen(QColor("#FF5757")))
|
||||
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "▼")
|
||||
if min_pt is not None:
|
||||
painter.setPen(QPen(QColor("#42A5F5")))
|
||||
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "▲")
|
||||
|
||||
# --- Legend ---
|
||||
if self._show_legend and self._sensor_names:
|
||||
legend_font = QFont()
|
||||
|
||||
@@ -23,6 +23,9 @@ class GraphView(QObject):
|
||||
each sensor as a separate line series.
|
||||
"""
|
||||
|
||||
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
|
||||
_trend_line: Any
|
||||
|
||||
# ---- signals ----
|
||||
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
|
||||
|
||||
@@ -227,7 +230,7 @@ class GraphView(QObject):
|
||||
index_a, index_b = path[i]
|
||||
if index_a < len(a) and index_b < len(b):
|
||||
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
|
||||
style=Qt.DashLine))
|
||||
style=Qt.PenStyle.DashLine))
|
||||
self._plot_widget.addItem(line)
|
||||
|
||||
self._plot_widget.setTitle("DTW Comparison")
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
from scipy.ndimage import zoom as _ndi_zoom
|
||||
|
||||
try:
|
||||
import cupy as _cupy # type: ignore
|
||||
@@ -17,6 +18,17 @@ except Exception:
|
||||
_KERNEL = "thin_plate_spline"
|
||||
_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(
|
||||
xs: np.ndarray,
|
||||
@@ -28,26 +40,30 @@ def interpolate_field(
|
||||
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
|
||||
round_clip: bool = False,
|
||||
) -> np.ndarray:
|
||||
"""Return a (height, width) float64 array of interpolated values.
|
||||
"""Return a float64 array of interpolated values, capped at GRID_RES per side.
|
||||
|
||||
Args:
|
||||
xs, ys: sensor positions in mm (1-D arrays, 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
|
||||
round_clip: if True, pixels outside the inscribed ellipse become NaN
|
||||
"""
|
||||
coords = np.column_stack([xs, ys])
|
||||
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
|
||||
|
||||
grid_w = min(width, GRID_RES)
|
||||
grid_h = min(height, GRID_RES)
|
||||
|
||||
xmin, xmax, ymin, ymax = extent
|
||||
gx = np.linspace(xmin, xmax, width)
|
||||
gy = np.linspace(ymin, ymax, height)
|
||||
gx = np.linspace(xmin, xmax, grid_w)
|
||||
gy = np.linspace(ymin, ymax, grid_h)
|
||||
grid_x, grid_y = np.meshgrid(gx, gy)
|
||||
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
|
||||
|
||||
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
|
||||
field = rbf(flat).reshape(height, width)
|
||||
field = rbf(flat).reshape(grid_h, grid_w)
|
||||
|
||||
if round_clip:
|
||||
cx = (xmin + xmax) / 2
|
||||
@@ -57,5 +73,33 @@ def interpolate_field(
|
||||
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
|
||||
field = np.where(dist <= 1.0, field, np.nan)
|
||||
|
||||
return field.astype(np.float64)
|
||||
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.
|
||||
|
||||
Renders a single polyline series (per-frame average temperatures) using QPainter.
|
||||
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can
|
||||
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`.
|
||||
Renders a single polyline series (per-frame average temperatures) over a fixed
|
||||
0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
|
||||
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
|
||||
emits a JSON-encoded list of floats from both review-mode replay and live-mode
|
||||
streaming.
|
||||
The series is driven by `streamController.trendDelta` (one new
|
||||
`[elapsed_s, value]` point per live frame) and cleared on
|
||||
`streamController.trendReset` (new stream started). See
|
||||
docs/adr/0001-trend-chart-axes-standardization.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,17 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
|
||||
from PySide6.QtQml import QmlElement
|
||||
from PySide6.QtQuick import QQuickPaintedItem
|
||||
|
||||
from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
QML_IMPORT_NAME = "ISC.Wafer"
|
||||
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
|
||||
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()
|
||||
lineColorChanged = Signal()
|
||||
gridColorChanged = Signal()
|
||||
@@ -42,41 +51,35 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
self.setAntialiasing(True)
|
||||
self.setFillColor(QColor("transparent"))
|
||||
|
||||
self._data: list[float] = []
|
||||
self._points: list[tuple[float, float]] = []
|
||||
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._grid_color = QColor("#2A3441")
|
||||
self._text_color = QColor("#CBD5E1")
|
||||
|
||||
# ── Qt properties ─────────────────────────────────────────────────────
|
||||
|
||||
@Property("QVariantList", notify=dataChanged)
|
||||
def data(self) -> list[float]:
|
||||
return self._data
|
||||
|
||||
@data.setter
|
||||
def data(self, val) -> None:
|
||||
coerced = self._coerce_floats(val)
|
||||
if coerced == self._data:
|
||||
return
|
||||
self._data = coerced
|
||||
self.dataChanged.emit()
|
||||
self.hasDataChanged.emit()
|
||||
self.update()
|
||||
|
||||
@Property(bool, notify=hasDataChanged)
|
||||
def hasData(self) -> bool:
|
||||
"""True when the data list has at least one point.
|
||||
"""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)
|
||||
def lineColor(self) -> QColor:
|
||||
return self._line_color
|
||||
|
||||
@lineColor.setter
|
||||
@lineColor.setter # type: ignore[no-redef]
|
||||
def lineColor(self, c: QColor) -> None:
|
||||
if c == self._line_color:
|
||||
return
|
||||
@@ -88,7 +91,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def gridColor(self) -> QColor:
|
||||
return self._grid_color
|
||||
|
||||
@gridColor.setter
|
||||
@gridColor.setter # type: ignore[no-redef]
|
||||
def gridColor(self, c: QColor) -> None:
|
||||
if c == self._grid_color:
|
||||
return
|
||||
@@ -100,7 +103,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def textColor(self) -> QColor:
|
||||
return self._text_color
|
||||
|
||||
@textColor.setter
|
||||
@textColor.setter # type: ignore[no-redef]
|
||||
def textColor(self, c: QColor) -> None:
|
||||
if c == self._text_color:
|
||||
return
|
||||
@@ -112,7 +115,7 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
def padding(self) -> int:
|
||||
return self._padding
|
||||
|
||||
@padding.setter
|
||||
@padding.setter # type: ignore[no-redef]
|
||||
def padding(self, val: int) -> None:
|
||||
v = max(0, int(val))
|
||||
if v == self._padding:
|
||||
@@ -121,24 +124,48 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
self.paddingChanged.emit()
|
||||
self.update()
|
||||
|
||||
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ──
|
||||
# ── slots for QML: streamController.trendDelta / trendReset ────────────
|
||||
|
||||
@Slot(str)
|
||||
def setDataFromJson(self, avgs_json: str) -> None:
|
||||
"""Slot for QML Connections handler: parse JSON array and update data.
|
||||
def appendDelta(self, delta_json: str) -> None:
|
||||
"""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:
|
||||
parsed = json.loads(avgs_json) if avgs_json else []
|
||||
parsed = json.loads(delta_json) if delta_json else []
|
||||
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
|
||||
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
|
||||
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.update()
|
||||
|
||||
@@ -153,57 +180,39 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
if w <= 0 or h <= 0:
|
||||
return
|
||||
|
||||
plot_rect = QRectF(self._padding, self._padding,
|
||||
max(1, w - 2 * self._padding),
|
||||
max(1, h - 2 * self._padding))
|
||||
left = self._padding + self._margin_left
|
||||
top = self._padding + self._margin_top
|
||||
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)
|
||||
|
||||
if len(self._data) < 2:
|
||||
if len(self._points) < 2:
|
||||
return
|
||||
|
||||
y_min, y_max = self._y_range()
|
||||
self._draw_axes_labels(painter, plot_rect, y_min, y_max)
|
||||
self._draw_line(painter, plot_rect, y_min, y_max)
|
||||
window_end = self._points[-1][0]
|
||||
window_start = max(0.0, window_end - WINDOW_SECONDS)
|
||||
self._draw_axes_labels(painter, plot_rect, window_start, window_end)
|
||||
self._draw_line(painter, plot_rect, window_start, window_end)
|
||||
|
||||
# ── internals ─────────────────────────────────────────────────────────
|
||||
|
||||
def _coerce_floats(self, val) -> list[float]:
|
||||
if not val:
|
||||
return []
|
||||
out: list[float] = []
|
||||
for v in val:
|
||||
try:
|
||||
out.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
|
||||
return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
|
||||
|
||||
def _y_range(self) -> tuple[float, float]:
|
||||
lo = min(self._data)
|
||||
hi = max(self._data)
|
||||
if hi - lo < 1e-9:
|
||||
return lo - 5.0, hi + 5.0
|
||||
buf = (hi - lo) * 0.1
|
||||
return lo - buf, hi + buf
|
||||
|
||||
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
|
||||
if n <= 1:
|
||||
return plot_rect.left()
|
||||
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
|
||||
|
||||
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
|
||||
if y_max - y_min < 1e-9:
|
||||
return plot_rect.center().y()
|
||||
t = (v - y_min) / (y_max - y_min)
|
||||
return plot_rect.bottom() - t * plot_rect.height()
|
||||
def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
|
||||
px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
|
||||
# Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
|
||||
return max(plot_rect.top(), min(plot_rect.bottom(), px))
|
||||
|
||||
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
|
||||
pen = QPen(self._grid_color)
|
||||
pen.setWidthF(1.0)
|
||||
pen.setCosmetic(True)
|
||||
painter.setPen(pen)
|
||||
rows = 4
|
||||
rows = len(Y_TICKS) - 1
|
||||
for i in range(rows + 1):
|
||||
y = plot_rect.top() + (i / rows) * plot_rect.height()
|
||||
painter.drawLine(int(plot_rect.left()), int(y),
|
||||
@@ -213,31 +222,69 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
self,
|
||||
painter: QPainter,
|
||||
plot_rect: QRectF,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
window_start: float,
|
||||
window_end: float,
|
||||
) -> None:
|
||||
painter.setPen(self._text_color)
|
||||
font = QFont(painter.font())
|
||||
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||
painter.setFont(font)
|
||||
rows = 4
|
||||
for i in range(rows + 1):
|
||||
t = i / rows
|
||||
y_val = y_max - t * (y_max - y_min)
|
||||
y_px = plot_rect.top() + t * plot_rect.height()
|
||||
label = f"{y_val:.1f}"
|
||||
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
|
||||
|
||||
title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
|
||||
label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
|
||||
for tick in reversed(Y_TICKS):
|
||||
y_px = self._y_to_px(tick, plot_rect)
|
||||
painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
|
||||
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(
|
||||
self,
|
||||
painter: QPainter,
|
||||
plot_rect: QRectF,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
window_start: float,
|
||||
window_end: float,
|
||||
) -> None:
|
||||
n = len(self._data)
|
||||
pen = QPen(self._line_color)
|
||||
pen.setWidthF(2.0)
|
||||
pen.setCosmetic(True)
|
||||
@@ -246,15 +293,16 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
painter.setPen(pen)
|
||||
|
||||
poly = QPolygon()
|
||||
for i, v in enumerate(self._data):
|
||||
px = self._x_to_px(i, n, plot_rect)
|
||||
py = self._y_to_px(v, y_min, y_max, plot_rect)
|
||||
for elapsed, value in self._points:
|
||||
px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
|
||||
py = self._y_to_px(value, plot_rect)
|
||||
poly.append(QPoint(int(px), int(py)))
|
||||
painter.drawPolyline(poly)
|
||||
|
||||
# Trailing dot at the last data point
|
||||
last_x = int(self._x_to_px(n - 1, n, plot_rect))
|
||||
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect))
|
||||
last_elapsed, last_value = self._points[-1]
|
||||
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.setPen(Qt.PenStyle.NoPen)
|
||||
radius = 3
|
||||
|
||||
@@ -14,7 +14,7 @@ import logging
|
||||
import math
|
||||
|
||||
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 (
|
||||
QBrush,
|
||||
QColor,
|
||||
@@ -27,11 +27,69 @@ from PySide6.QtGui import (
|
||||
from PySide6.QtQml import QmlElement
|
||||
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
|
||||
|
||||
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_MAJOR_VERSION = 1
|
||||
|
||||
@@ -47,11 +105,14 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
marginChanged = Signal()
|
||||
blendChanged = Signal()
|
||||
showLabelsChanged = Signal()
|
||||
showExtremesChanged = Signal()
|
||||
colorsChanged = Signal()
|
||||
shapeChanged = Signal()
|
||||
sizeChanged = Signal()
|
||||
thicknessChanged = Signal()
|
||||
showThicknessChanged = Signal()
|
||||
hoveredIndexChanged = Signal()
|
||||
hoverScaleChanged = Signal()
|
||||
|
||||
|
||||
def __init__(self, parent=None):
|
||||
@@ -63,11 +124,16 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._margin: float = 1.0
|
||||
self._blend: float = 0.0
|
||||
self._show_labels: bool = True
|
||||
self._show_extremes: bool = True
|
||||
self._shape: str = "round"
|
||||
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._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)
|
||||
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._marker_r: int = 4
|
||||
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.heightChanged.connect(self._on_resize)
|
||||
@@ -91,7 +163,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def sensors(self) -> list:
|
||||
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:
|
||||
self._sensors = [
|
||||
Sensor(
|
||||
@@ -111,7 +183,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def values(self) -> list:
|
||||
return self._values
|
||||
|
||||
@values.setter
|
||||
@values.setter # type: ignore[no-redef]
|
||||
def values(self, val: list) -> None:
|
||||
self._values = list(val or [])
|
||||
self._rebuild_heatmap()
|
||||
@@ -122,7 +194,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def bands(self) -> list:
|
||||
return self._bands
|
||||
|
||||
@bands.setter
|
||||
@bands.setter # type: ignore[no-redef]
|
||||
def bands(self, val: list) -> None:
|
||||
self._bands = list(val or [])
|
||||
self.bandsChanged.emit()
|
||||
@@ -132,7 +204,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def target(self) -> float:
|
||||
return self._target
|
||||
|
||||
@target.setter
|
||||
@target.setter # type: ignore[no-redef]
|
||||
def target(self, val: float) -> None:
|
||||
self._target = float(val)
|
||||
self._rebuild_heatmap()
|
||||
@@ -143,7 +215,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def margin(self) -> float:
|
||||
return self._margin
|
||||
|
||||
@margin.setter
|
||||
@margin.setter # type: ignore[no-redef]
|
||||
def margin(self, val: float) -> None:
|
||||
self._margin = float(val)
|
||||
self._rebuild_heatmap()
|
||||
@@ -154,7 +226,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def blend(self) -> float:
|
||||
return self._blend
|
||||
|
||||
@blend.setter
|
||||
@blend.setter # type: ignore[no-redef]
|
||||
def blend(self, val: float) -> None:
|
||||
self._blend = max(0.0, min(1.0, float(val)))
|
||||
if self._blend > 0 and self._heatmap is None:
|
||||
@@ -166,17 +238,27 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def showLabels(self) -> bool:
|
||||
return self._show_labels
|
||||
|
||||
@showLabels.setter
|
||||
@showLabels.setter # type: ignore[no-redef]
|
||||
def showLabels(self, val: bool) -> None:
|
||||
self._show_labels = bool(val)
|
||||
self.showLabelsChanged.emit()
|
||||
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)
|
||||
def shape(self) -> str:
|
||||
return self._shape
|
||||
|
||||
@shape.setter
|
||||
@shape.setter # type: ignore[no-redef]
|
||||
def shape(self, val: str) -> None:
|
||||
self._shape = str(val).lower()
|
||||
self._rebuild()
|
||||
@@ -186,7 +268,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def size(self) -> float:
|
||||
return self._size
|
||||
|
||||
@size.setter
|
||||
@size.setter # type: ignore[no-redef]
|
||||
def size(self, val: float) -> None:
|
||||
self._size = float(val)
|
||||
self._rebuild()
|
||||
@@ -194,54 +276,95 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
|
||||
@Property("QVariantList", notify=thicknessChanged)
|
||||
def thicknessData(self) -> list:
|
||||
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
|
||||
return self._thickness_data
|
||||
|
||||
@thicknessData.setter
|
||||
@thicknessData.setter # type: ignore[no-redef]
|
||||
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.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
|
||||
|
||||
@Property(bool, notify=showThicknessChanged)
|
||||
def showThickness(self) -> bool:
|
||||
return self._show_thickness
|
||||
|
||||
@showThickness.setter
|
||||
@showThickness.setter # type: ignore[no-redef]
|
||||
def showThickness(self, val: bool) -> None:
|
||||
self._show_thickness = bool(val)
|
||||
self.showThicknessChanged.emit()
|
||||
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
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
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()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
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()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
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()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
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()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
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()
|
||||
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
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()
|
||||
|
||||
# ── slots ─────────────────────────────────────────────────────────────
|
||||
@@ -255,70 +378,125 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
return idx
|
||||
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)
|
||||
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
|
||||
|
||||
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:
|
||||
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
|
||||
try:
|
||||
result = self.grabToImage()
|
||||
img = result.image()
|
||||
img.save(file_path, "PNG")
|
||||
return True
|
||||
# Render synchronously via paint() into our own image —
|
||||
# grabToImage() resolves on a later render frame, so its result
|
||||
# is null when read immediately (the old silent failure).
|
||||
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:
|
||||
log.error("Export failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# ── 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:
|
||||
"""Interpolate thickness data into a gray/orange heatmap QImage."""
|
||||
if not self._sensors or not self._thickness_data:
|
||||
"""Interpolate thickness points into a gray/orange heatmap QImage."""
|
||||
if not self._thickness_data:
|
||||
self._thickness_heatmap = None
|
||||
return
|
||||
ds = self._draw_size()
|
||||
r_mm = self._wafer_radius_mm()
|
||||
xs = np.array([s.x for s in self._sensors])
|
||||
ys = np.array([s.y for s in self._sensors])
|
||||
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
|
||||
if len(vs) < len(self._sensors):
|
||||
self._thickness_heatmap = None
|
||||
return
|
||||
pts = np.array(self._thickness_data, dtype=float)
|
||||
xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
|
||||
try:
|
||||
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
|
||||
field = interpolate_field(
|
||||
xs, ys, vs,
|
||||
width=ds, height=ds,
|
||||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||
round_clip=(self._shape == "round"),
|
||||
)
|
||||
except Exception:
|
||||
self._thickness_heatmap = None
|
||||
return
|
||||
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||||
vmin, vmax = np.nanmin(field), np.nanmax(field)
|
||||
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)
|
||||
# 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
|
||||
# Gray/orange colormap: map field range 0→1 to gray→orange
|
||||
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)
|
||||
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[:, :, 1] = 0.5 + 0.15 * t
|
||||
rgb[:, :, 2] = 0.5 - 0.5 * t
|
||||
|
||||
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
||||
rgba = np.zeros((gh, gw, 4), dtype=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 = (
|
||||
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy()
|
||||
grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
|
||||
self._thickness_heatmap = grid_img.scaled(
|
||||
ds, ds,
|
||||
Qt.AspectRatioMode.IgnoreAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
|
||||
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)
|
||||
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]:
|
||||
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
|
||||
if not self._sensors:
|
||||
@@ -391,19 +576,34 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._heatmap = None
|
||||
return
|
||||
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(
|
||||
xs, ys, vs,
|
||||
width=ds, height=ds,
|
||||
extent=(-r_mm, r_mm, -r_mm, r_mm),
|
||||
round_clip=(self._shape == "round"),
|
||||
)
|
||||
except Exception:
|
||||
self._heatmap = None
|
||||
return
|
||||
self._heatmap = self._field_to_qimage(field, ds)
|
||||
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:
|
||||
"""Apply a band-aware tri-color gradient → RGBA QImage."""
|
||||
def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
|
||||
"""Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
|
||||
lo_b = self._target - self._margin
|
||||
hi_b = self._target + self._margin
|
||||
span = hi_b - lo_b or 1.0
|
||||
@@ -428,19 +628,28 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
lo_c * (1 - t_lo) + mid_c * t_lo,
|
||||
mid_c * (1 - t_hi) + hi_c * t_hi,
|
||||
)
|
||||
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
|
||||
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
|
||||
rgb = np.nan_to_num(rgb, nan=0.0)
|
||||
rgba = np.zeros((ds, ds, 4), dtype=np.uint8)
|
||||
gh, gw = field.shape
|
||||
rgba = np.zeros((gh, gw, 4), dtype=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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
cx, cy = self._center()
|
||||
|
||||
@@ -461,6 +670,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
painter.setOpacity(1.0)
|
||||
|
||||
self._paint_markers(painter)
|
||||
self._paint_extremes(painter)
|
||||
|
||||
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
|
||||
ds = self._draw_size()
|
||||
@@ -524,11 +734,11 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
font_scale = 0.85
|
||||
|
||||
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)
|
||||
|
||||
temp_font = QFont()
|
||||
temp_font.setPointSize(max(3, int((r - 1) * font_scale)))
|
||||
temp_font.setPointSize(max(9, int(r * font_scale)))
|
||||
|
||||
band_color = {
|
||||
"in_range": self._in_range_color,
|
||||
@@ -536,6 +746,16 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
"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):
|
||||
if i not in self._markers:
|
||||
continue
|
||||
@@ -544,10 +764,22 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._bands[i] if i < len(self._bands) else "in_range",
|
||||
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
|
||||
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
|
||||
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:
|
||||
has_temp = i < len(self._values)
|
||||
@@ -557,17 +789,6 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
ox = getattr(s, "offset_x", 0.0) * r
|
||||
oy = getattr(s, "offset_y", 0.0) * r
|
||||
|
||||
# Pre-compute metrics using current scaled fonts
|
||||
painter.setFont(id_font)
|
||||
id_fm = painter.fontMetrics()
|
||||
id_line_h = id_fm.height()
|
||||
id_ascent = id_fm.ascent()
|
||||
|
||||
painter.setFont(temp_font)
|
||||
temp_fm = painter.fontMetrics()
|
||||
temp_line_h = temp_fm.height()
|
||||
temp_ascent = temp_fm.ascent()
|
||||
|
||||
id_text = s.label
|
||||
temp_text = f"{self._values[i]:.2f}" if has_temp else ""
|
||||
|
||||
@@ -598,11 +819,11 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
painter.setPen(QPen(self._text_color))
|
||||
y1 = ly + id_ascent
|
||||
if side in ("top", "bottom"):
|
||||
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)
|
||||
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) # type: ignore[arg-type]
|
||||
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:
|
||||
painter.drawText(lx, y1, id_text)
|
||||
painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
|
||||
|
||||
# Draw Temperature (second line)
|
||||
if has_temp:
|
||||
@@ -610,8 +831,8 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
painter.setPen(QPen(color))
|
||||
y2 = ly + id_line_h + temp_ascent
|
||||
if side in ("top", "bottom"):
|
||||
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)
|
||||
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) # type: ignore[arg-type]
|
||||
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:
|
||||
painter.drawText(lx, y2, temp_text)
|
||||
painter.drawText(lx, y2, temp_text) # type: ignore[arg-type]
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# ===== Wafer Sub-package =====
|
||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
__all__ = [
|
||||
"ZWaferData", "Sensor", "DataRecord",
|
||||
"ZWaferParser",
|
||||
"load_layout", "available_families",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Single source of truth for the wafer sensor-count partition."""
|
||||
|
||||
|
||||
def sensor_count_for(family_code: str) -> int:
|
||||
"""Return the number of valid sensor readings for a wafer family code.
|
||||
|
||||
Returns 80 for the "X" family. Returns 244 for every other family
|
||||
code."""
|
||||
return 80 if family_code == "X" else 244
|
||||
@@ -66,9 +66,39 @@ def _family_name(raw_name: str) -> str:
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
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]:
|
||||
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
|
||||
|
||||
|
||||
@@ -104,9 +104,6 @@ class ZWaferParser:
|
||||
elif key.lower() == "wafer id":
|
||||
wafer_data.serial = value
|
||||
|
||||
if found_kv and wafer_data.date == datetime.min:
|
||||
wafer_data.date = datetime.min
|
||||
|
||||
return found_kv
|
||||
|
||||
# ===== Sensor Layout Parsing =====
|
||||
@@ -118,7 +115,7 @@ class ZWaferParser:
|
||||
y_coords: Optional[list],
|
||||
) -> None:
|
||||
"""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.")
|
||||
|
||||
if len(labels) != len(x_coords) or len(labels) != len(y_coords):
|
||||
|
||||
@@ -10,15 +10,12 @@ Mirrors the C# Form1.cs binary parsing pipeline:
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
|
||||
# Max DUTs (sensors) per row before overhead bytes are stripped
|
||||
# P wafer: 244 valid readings per 256-block (12 overhead bytes)
|
||||
# X wafer: 80 valid readings per 256-block (14 overhead bytes)
|
||||
MAXDUT_P = 244
|
||||
MAXDUT_X = 80
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def csv_column_count(family_code: str) -> int:
|
||||
@@ -110,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float:
|
||||
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:
|
||||
"""Convert a singi hale 4-char hex string to a float temperature."""
|
||||
bits = _hex_to_binary(hex_str)
|
||||
@@ -156,26 +170,27 @@ def _parse_p_binary(data_bytes: bytes) -> list[list[str]]:
|
||||
"""Parse P-family (and A/B/C/D/E/F) binary data.
|
||||
|
||||
Each block of 256 readings has 244 valid + 12 overhead.
|
||||
Valid readings are chunked into rows of MAXDUT_P (244).
|
||||
Valid readings are chunked into rows of the family's sensor count (244).
|
||||
"""
|
||||
sensor_count = sensor_count_for("P")
|
||||
readings: list[str] = []
|
||||
|
||||
# Read 2 bytes at a time (UInt16 little-endian)
|
||||
# Each 256-word block has MAXDUT_P valid readings (first N words), rest are overhead
|
||||
# Each 256-word block has `sensor_count` valid readings (first N words), rest are overhead
|
||||
num_words = len(data_bytes) // 2
|
||||
for i in range(num_words):
|
||||
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
||||
if i % 256 < MAXDUT_P:
|
||||
if i % 256 < sensor_count:
|
||||
readings.append(f"{value:04X}")
|
||||
|
||||
# Chunk into rows of MAXDUT_P
|
||||
# Chunk into rows of sensor_count
|
||||
result: list[list[str]] = []
|
||||
idx = 0
|
||||
while idx + MAXDUT_P <= len(readings):
|
||||
result.append(readings[idx : idx + MAXDUT_P])
|
||||
idx += MAXDUT_P
|
||||
while idx + sensor_count <= len(readings):
|
||||
result.append(readings[idx : idx + sensor_count])
|
||||
idx += sensor_count
|
||||
|
||||
log.info("Parsed P-family: %d rows × %d cols", len(result), MAXDUT_P)
|
||||
log.info("Parsed P-family: %d rows × %d cols", len(result), sensor_count)
|
||||
return result
|
||||
|
||||
|
||||
@@ -183,24 +198,25 @@ def _parse_x_binary(data_bytes: bytes) -> list[list[str]]:
|
||||
"""Parse X-family binary data.
|
||||
|
||||
Each block of 256 readings has 80 valid + 14 overhead.
|
||||
Valid readings are chunked into rows of MAXDUT_X (80).
|
||||
Valid readings are chunked into rows of the family's sensor count (80).
|
||||
"""
|
||||
sensor_count = sensor_count_for("X")
|
||||
readings: list[str] = []
|
||||
|
||||
num_words = len(data_bytes) // 2
|
||||
for i in range(num_words):
|
||||
value = int.from_bytes(data_bytes[i * 2 : i * 2 + 2], byteorder="little")
|
||||
if i % 256 < MAXDUT_X:
|
||||
if i % 256 < sensor_count:
|
||||
readings.append(f"{value:04X}")
|
||||
|
||||
# Chunk into rows of MAXDUT_X
|
||||
# Chunk into rows of sensor_count
|
||||
result: list[list[str]] = []
|
||||
idx = 0
|
||||
while idx + MAXDUT_X <= len(readings):
|
||||
result.append(readings[idx : idx + MAXDUT_X])
|
||||
idx += MAXDUT_X
|
||||
while idx + sensor_count <= len(readings):
|
||||
result.append(readings[idx : idx + sensor_count])
|
||||
idx += sensor_count
|
||||
|
||||
log.info("Parsed X-family: %d rows × %d cols", len(result), MAXDUT_X)
|
||||
log.info("Parsed X-family: %d rows × %d cols", len(result), sensor_count)
|
||||
return result
|
||||
|
||||
|
||||
@@ -226,6 +242,15 @@ def convert_to_temperatures(
|
||||
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:
|
||||
"""Remove rows of all-zero values from the end of data (in-place).
|
||||
|
||||
@@ -239,7 +264,7 @@ def remove_trailing_zeros(data: list[list[str]]) -> None:
|
||||
fval = float(val)
|
||||
except ValueError:
|
||||
fval = 99.9
|
||||
if fval >= 0.01:
|
||||
if abs(fval) >= 0.01:
|
||||
is_all_zero = False
|
||||
break
|
||||
if is_all_zero:
|
||||
@@ -268,13 +293,18 @@ def save_to_csv(
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# Build filename: P00001-20260505_133045.csv
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{serial_number}-{timestamp}.csv"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
|
||||
num_cols = csv_column_count(family_code)
|
||||
# Header count = max of display column count and actual data width.
|
||||
# csv_column_count gives the display sensor count (e.g. 48 for P),
|
||||
# but the binary parser may yield more readings (244 for P). Use the
|
||||
# actual data width so no sensor values are silently dropped.
|
||||
display_cols = csv_column_count(family_code)
|
||||
data_width = max((len(r) for r in data), default=0)
|
||||
num_cols = max(display_cols, data_width)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
# Header row: Sensor1,Sensor2,...
|
||||
headers = [f"Sensor{j + 1}" for j in range(num_cols)]
|
||||
@@ -290,3 +320,46 @@ def save_to_csv(
|
||||
except Exception as exc:
|
||||
log.error("CSV save failed: %s", exc)
|
||||
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
|
||||
|
||||
@@ -19,16 +19,14 @@ log = logging.getLogger(__name__)
|
||||
class DeviceService:
|
||||
"""High-level wafer device communication."""
|
||||
|
||||
# Expected hex string lengths for known wafer families
|
||||
# P wafer: 393216 hex chars (196608 bytes)
|
||||
# X wafer: 1310720 hex chars (655360 bytes)
|
||||
EXPECTED_HEX_LENGTHS = {
|
||||
"P": 393216,
|
||||
"X": 1310720,
|
||||
}
|
||||
|
||||
def __init__(self, settings: LocalSettings) -> None:
|
||||
self._settings = settings
|
||||
self._cached_ports: list[str] = []
|
||||
self._last_port_scan_time = 0.0
|
||||
|
||||
def clear_port_scan_cache(self) -> None:
|
||||
"""Force the next scan to bypass the cache."""
|
||||
self._last_port_scan_time = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -36,6 +34,11 @@ class DeviceService:
|
||||
|
||||
def enumerate_ports(self) -> list[str]:
|
||||
"""Return list of available serial port names."""
|
||||
import time
|
||||
now = time.monotonic()
|
||||
if now - self._last_port_scan_time < 2.0:
|
||||
return self._cached_ports
|
||||
|
||||
hardware_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||
virtual_ports = []
|
||||
|
||||
@@ -86,6 +89,8 @@ class DeviceService:
|
||||
if p not in ordered_ports:
|
||||
ordered_ports.append(p)
|
||||
|
||||
self._cached_ports = ordered_ports
|
||||
self._last_port_scan_time = time.monotonic()
|
||||
return ordered_ports
|
||||
|
||||
|
||||
@@ -196,7 +201,3 @@ class DeviceService:
|
||||
except Exception as exc:
|
||||
log.error("Erase failed on %s: %s", port, exc)
|
||||
return False
|
||||
|
||||
def get_expected_hex_length(self, family_code: str) -> int:
|
||||
"""Return expected hex string length for a wafer family."""
|
||||
return self._settings.get_wafer_data_size(family_code)
|
||||
|
||||
@@ -65,6 +65,10 @@ class SerialPort:
|
||||
)
|
||||
if timeout is not None:
|
||||
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:
|
||||
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
||||
|
||||
@@ -7,12 +7,16 @@ import time
|
||||
from typing import Callable
|
||||
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
from pygui.serialcomm.data_parser import _convert_hex_to_temp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ParseFrame = Callable[[bytes, int], Frame]
|
||||
|
||||
# Max consecutive resync attempts before giving up on a corrupt stream.
|
||||
_MAX_RESYNC_ATTEMPTS = 50
|
||||
|
||||
class StreamReader:
|
||||
|
||||
def __init__(self, transport, parse_frame: ParseFrame,
|
||||
@@ -27,6 +31,8 @@ class StreamReader:
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self.error_count = 0
|
||||
# Cumulative count of resync events (lost sync marker) for stream stats.
|
||||
self.resync_count = 0
|
||||
|
||||
def start(self) -> None:
|
||||
self._stop.clear()
|
||||
@@ -36,17 +42,13 @@ class StreamReader:
|
||||
def _read_chunk(self, min_bytes: int = 1) -> bytes:
|
||||
"""Read a chunk of available bytes from the transport.
|
||||
|
||||
Reads up to 256 bytes in one shot instead of byte-at-a-time,
|
||||
falling back to single-byte reads with a yield when nothing is
|
||||
immediately available."""
|
||||
chunk = self._transport.read(max(min_bytes, 256))
|
||||
if chunk:
|
||||
return chunk
|
||||
# Nothing available right now – yield briefly to avoid busy-spin.
|
||||
b = self._transport.read(1)
|
||||
if not b:
|
||||
Reads up to 256 bytes in one shot. If nothing is immediately
|
||||
available, yields briefly to avoid a busy spin.
|
||||
"""
|
||||
chunk: bytes = self._transport.read(max(min_bytes, 256))
|
||||
if not chunk:
|
||||
time.sleep(0.005)
|
||||
return b
|
||||
return chunk
|
||||
|
||||
def _accumulate(self, needed: int) -> bytearray:
|
||||
"""Accumulate *needed* bytes using buffered reads."""
|
||||
@@ -92,15 +94,22 @@ class StreamReader:
|
||||
|
||||
# Default: ASCII hex dump stream.
|
||||
self._run_ascii(raw)
|
||||
except Exception as exc:
|
||||
log.exception("StreamReader thread encountered error: %s", exc)
|
||||
try:
|
||||
self._on_error(exc)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
self._transport.close()
|
||||
except Exception:
|
||||
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")
|
||||
buf = bytearray(initial_bytes)
|
||||
resync_attempts = 0
|
||||
|
||||
while not self._stop.is_set():
|
||||
# Find sync marker in buffer.
|
||||
@@ -113,6 +122,7 @@ class StreamReader:
|
||||
idx += 1
|
||||
|
||||
if synced:
|
||||
resync_attempts = 0
|
||||
buf = buf[idx:]
|
||||
# Accumulate header (5 bytes after sync).
|
||||
while len(buf) < 7 and not self._stop.is_set():
|
||||
@@ -139,7 +149,7 @@ class StreamReader:
|
||||
payload = buf[7:7 + payload_len]
|
||||
|
||||
try:
|
||||
frame = self._parse(payload, seq)
|
||||
frame = self._parse(payload, seq) # type: ignore[arg-type]
|
||||
self._on_frame(frame)
|
||||
except Exception as exc:
|
||||
self.error_count += 1
|
||||
@@ -150,6 +160,18 @@ class StreamReader:
|
||||
buf = buf[total_packet_len:]
|
||||
else:
|
||||
# Resync: keep only trailing 0xAA if present, then read more.
|
||||
resync_attempts += 1
|
||||
self.resync_count += 1
|
||||
if resync_attempts > _MAX_RESYNC_ATTEMPTS:
|
||||
log.error(
|
||||
"StreamReader: giving up after %d resync attempts "
|
||||
"(no 0xAA 0x88 sync marker found in stream)",
|
||||
resync_attempts,
|
||||
)
|
||||
self._on_error(
|
||||
TimeoutError("Binary stream resync failed: no sync marker")
|
||||
)
|
||||
return
|
||||
if len(buf) > 0 and buf[-1] == 0xAA:
|
||||
buf = bytearray([0xAA])
|
||||
else:
|
||||
@@ -161,7 +183,7 @@ class StreamReader:
|
||||
def _run_ascii(self, initial_bytes: bytes) -> None:
|
||||
log.info("StreamReader: starting ASCII hex dump stream parsing")
|
||||
|
||||
valid_words = 80 if self._family_code == "X" else 244
|
||||
valid_words = sensor_count_for(self._family_code)
|
||||
block_words = 256
|
||||
word_char_len = 4
|
||||
chars_needed = block_words * word_char_len # total hex chars per block
|
||||
@@ -216,7 +238,7 @@ class StreamReader:
|
||||
for hex_val in valid_hex_words:
|
||||
try:
|
||||
swapped = hex_val[2:4] + hex_val[0:2]
|
||||
t = _convert_hex_to_temp(swapped, self._family_code)
|
||||
t = _convert_hex_to_temp(swapped, self._family_code) # type: ignore[arg-type]
|
||||
values.append(t)
|
||||
except Exception:
|
||||
values.append(0.0)
|
||||
@@ -240,7 +262,7 @@ class StreamReader:
|
||||
line = line_bytes.decode('utf-8', errors='ignore').strip()
|
||||
if line:
|
||||
try:
|
||||
frame = self._parse(line, seq)
|
||||
frame = self._parse(line, seq) # type: ignore[arg-type]
|
||||
if frame:
|
||||
self._on_frame(frame)
|
||||
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."""
|
||||
import math
|
||||
import pytest
|
||||
|
||||
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for src/pygui/backend/comparison.py."""
|
||||
|
||||
import numpy as np
|
||||
from pygui.backend.comparison import compare_runs
|
||||
|
||||
|
||||
|
||||
@@ -37,4 +37,71 @@ def test_recorded_data_rows_are_readable(tmp_path):
|
||||
assert records[0].time == 0.0
|
||||
assert records[0].values == [149.0, 148.0]
|
||||
assert records[1].time == 0.5
|
||||
assert records[1].values == [149.5, 148.5]
|
||||
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,15 +1,17 @@
|
||||
"""Tests for serialcomm/data_parser.py binary parsing pipeline."""
|
||||
|
||||
import pytest
|
||||
|
||||
from pygui.serialcomm.data_parser import (
|
||||
_convert_debug_temp,
|
||||
_convert_hex_to_temp,
|
||||
convert_to_debug_temperatures,
|
||||
convert_to_temperatures,
|
||||
csv_column_count,
|
||||
parse_binary_data,
|
||||
convert_to_temperatures,
|
||||
remove_trailing_zeros,
|
||||
save_debug_csv,
|
||||
save_to_csv,
|
||||
_convert_hex_to_temp,
|
||||
MAXDUT_P,
|
||||
MAXDUT_X,
|
||||
)
|
||||
|
||||
# ── csv_column_count ──────────────────────────────────────────────────────────
|
||||
@@ -106,7 +108,7 @@ def _make_p_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
|
||||
data = bytearray()
|
||||
for _ in range(num_blocks):
|
||||
for i in range(256):
|
||||
word = value if i < MAXDUT_P else 0
|
||||
word = value if i < 244 else 0
|
||||
data += word.to_bytes(2, byteorder="little")
|
||||
return bytes(data)
|
||||
|
||||
@@ -120,7 +122,7 @@ def _make_x_block(num_blocks: int = 1, value: int = 0x0100) -> bytes:
|
||||
data = bytearray()
|
||||
for _ in range(num_blocks):
|
||||
for i in range(256):
|
||||
word = value if i < MAXDUT_X else 0
|
||||
word = value if i < 80 else 0
|
||||
data += word.to_bytes(2, byteorder="little")
|
||||
return bytes(data)
|
||||
|
||||
@@ -136,7 +138,7 @@ class TestParseBinaryData:
|
||||
data = _make_p_block(1)
|
||||
result = parse_binary_data(data, "P")
|
||||
assert result is not None
|
||||
assert len(result[0]) == MAXDUT_P # 244
|
||||
assert len(result[0]) == 244
|
||||
|
||||
def test_p_family_two_blocks_returns_two_rows(self):
|
||||
data = _make_p_block(2)
|
||||
@@ -161,14 +163,14 @@ class TestParseBinaryData:
|
||||
data = _make_x_block(1)
|
||||
result = parse_binary_data(data, "X")
|
||||
assert result is not None
|
||||
assert len(result[0]) == MAXDUT_X # 80
|
||||
assert len(result[0]) == 80
|
||||
|
||||
def test_a_family_uses_p_parser(self):
|
||||
# A/B/C/D/E/F all route to _parse_p_binary
|
||||
data = _make_p_block(1)
|
||||
result = parse_binary_data(data, "A")
|
||||
assert result is not None
|
||||
assert len(result[0]) == MAXDUT_P
|
||||
assert len(result[0]) == 244
|
||||
|
||||
def test_empty_bytes_returns_empty_list(self):
|
||||
result = parse_binary_data(b"", "P")
|
||||
@@ -242,6 +244,11 @@ class TestRemoveTrailingZeros:
|
||||
remove_trailing_zeros(data)
|
||||
assert len(data) == 2
|
||||
|
||||
def test_preserves_negative_temperatures(self):
|
||||
data = [["25.0", "24.5"], ["-5.5", "-10.0"]]
|
||||
remove_trailing_zeros(data)
|
||||
assert len(data) == 2
|
||||
|
||||
|
||||
# ── save_to_csv ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -261,20 +268,25 @@ class TestSaveToCsv:
|
||||
assert result is not None
|
||||
assert "P12345" in result
|
||||
|
||||
def test_csv_has_sensor_headers(self, tmp_path):
|
||||
# Header count is driven by csv_column_count(family), not data width.
|
||||
# P family → 48 sensors; F family → 22 sensors; X family → 80 sensors.
|
||||
cases = [("P", 48), ("F", 22), ("X", 80), ("B", 29)]
|
||||
for family, expected_cols in cases:
|
||||
data = [["25.0", "24.5"]]
|
||||
result = save_to_csv(data, family, f"{family}00001", str(tmp_path))
|
||||
assert result is not None, f"save_to_csv returned None for {family}"
|
||||
headers = open(result).readline().strip().split(",")
|
||||
assert (
|
||||
len(headers) == expected_cols
|
||||
), f"{family}: expected {expected_cols} headers, got {len(headers)}"
|
||||
assert headers[0] == "Sensor1"
|
||||
assert headers[-1] == f"Sensor{expected_cols}"
|
||||
def test_csv_header_wins_when_data_wider_than_display(self, tmp_path):
|
||||
# Header count = max(csv_column_count, data width).
|
||||
# When data has 60 columns and P display is 48, header is 60 — no truncation.
|
||||
data = [[f"{i}.0" for i in range(60)]]
|
||||
result = save_to_csv(data, "P", "P00001", str(tmp_path))
|
||||
assert result is not None
|
||||
headers = open(result).readline().strip().split(",")
|
||||
assert len(headers) == 60, "data width 60 should win over display 48"
|
||||
assert headers[0] == "Sensor1"
|
||||
assert headers[-1] == "Sensor60"
|
||||
|
||||
def test_csv_uses_display_count_when_data_is_narrower(self, tmp_path):
|
||||
# When data has fewer columns than the display count, pad to display.
|
||||
data = [["25.0"]]
|
||||
result = save_to_csv(data, "X", "X00001", str(tmp_path))
|
||||
assert result is not None
|
||||
headers = open(result).readline().strip().split(",")
|
||||
# X display = 80, data = 1 → headers = 80
|
||||
assert len(headers) == 80
|
||||
|
||||
def test_csv_row_count_matches_data(self, tmp_path):
|
||||
data = [["25.0"], ["24.5"], ["23.0"]]
|
||||
@@ -297,3 +309,112 @@ class TestSaveToCsv:
|
||||
data = [["25.0"]]
|
||||
result = save_to_csv(data, "P", "P00001", "/\x00invalid\x00path")
|
||||
assert result is None
|
||||
|
||||
def test_preserves_all_columns_when_data_wider_than_display(self, tmp_path):
|
||||
# Bug fix: when data has more columns than csv_column_count(family),
|
||||
# all data columns must be preserved (no silent truncation).
|
||||
data = [[f"{i}.0" for i in range(244)]]
|
||||
result = save_to_csv(data, "P", "P00001", str(tmp_path))
|
||||
assert result is not None
|
||||
with open(result) as f:
|
||||
lines = f.readlines()
|
||||
headers = lines[0].strip().split(",")
|
||||
data_row = lines[1].strip().split(",")
|
||||
# All 244 columns should be preserved, not truncated to 48
|
||||
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 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()
|
||||
|
||||
@@ -16,4 +16,4 @@ def test_reads_rows_after_data_marker(tmp_path):
|
||||
assert len(recs) == 2
|
||||
assert recs[0].time == 0.0
|
||||
assert recs[0].values == [149.0, 148.5]
|
||||
assert recs[1].values == [149.2, 148.4]
|
||||
assert recs[1].values == [149.2, 148.4]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import pytest
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pygui.backend.controllers.device_controller import DeviceController
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.serialcomm.serial_port import WaferInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
s = LocalSettings()
|
||||
@@ -16,8 +19,8 @@ def mock_settings():
|
||||
|
||||
@pytest.fixture
|
||||
def controller(mock_settings):
|
||||
import tempfile
|
||||
import shutil
|
||||
import tempfile
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
yield DeviceController(mock_settings, temp_dir)
|
||||
shutil.rmtree(temp_dir)
|
||||
@@ -81,37 +84,112 @@ def test_clear_session(controller):
|
||||
controller._connection_status = "Connected"
|
||||
controller._selected_port = "COM1"
|
||||
controller._last_wafer_info = {"serialNumber": "P00001"}
|
||||
|
||||
|
||||
controller.clearSession()
|
||||
assert controller.connectionStatus == "Disconnected"
|
||||
assert controller.selectedPort == ""
|
||||
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
|
||||
|
||||
def test_erase_memory(controller):
|
||||
controller._service.erase_wafer = MagicMock(return_value=True)
|
||||
def test_erase_memory_spawns_thread(controller):
|
||||
with patch("threading.Thread") as mock_thread:
|
||||
controller.eraseMemory("COM1")
|
||||
mock_thread.assert_called_once()
|
||||
assert controller.operationInProgress
|
||||
|
||||
def test_erase_memory_handler(controller):
|
||||
emitted_result = None
|
||||
def on_erase_result(result):
|
||||
nonlocal emitted_result
|
||||
emitted_result = result
|
||||
|
||||
controller.eraseResult.connect(on_erase_result)
|
||||
controller.eraseMemory("COM1")
|
||||
controller._handle_erase_finished(True)
|
||||
assert emitted_result == {"success": True}
|
||||
assert controller.connectionStatus == "Connected"
|
||||
assert not controller.operationInProgress
|
||||
|
||||
def test_read_debug(controller):
|
||||
controller._service.read_wafer_data = MagicMock(return_value=bytes([12] * 512))
|
||||
def test_read_debug_spawns_thread(controller):
|
||||
with patch("threading.Thread") as mock_thread:
|
||||
controller.readDebug("COM1")
|
||||
mock_thread.assert_called_once()
|
||||
assert controller.operationInProgress
|
||||
|
||||
def test_read_debug_handler(controller):
|
||||
emitted_result = None
|
||||
def on_debug_result(result):
|
||||
nonlocal emitted_result
|
||||
emitted_result = result
|
||||
|
||||
controller.debugResult.connect(on_debug_result)
|
||||
controller.readDebug("COM1")
|
||||
controller._handle_debug_finished({
|
||||
"success": True,
|
||||
"sensor_bytes": 512,
|
||||
"debug_bytes": 512
|
||||
})
|
||||
assert emitted_result is not None
|
||||
assert emitted_result.get("success") is True
|
||||
assert emitted_result.get("sensor_bytes") == 512
|
||||
assert emitted_result.get("debug_bytes") == 512
|
||||
assert controller.connectionStatus == "Connected"
|
||||
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):
|
||||
res = controller.getChartData()
|
||||
@@ -125,7 +203,7 @@ def test_parse_and_save_data_and_get_chart_data(controller):
|
||||
|
||||
controller.parsedDataReady.connect(on_parsed)
|
||||
controller.parseAndSaveData("P", "COM1")
|
||||
|
||||
|
||||
assert emitted_result is not None
|
||||
assert emitted_result.get("success") is True
|
||||
assert controller.dataRowCount == 1
|
||||
@@ -142,16 +220,16 @@ def test_logging_handler(controller):
|
||||
assert any("Hello QML Activity Log" in line for line in controller._activity_log)
|
||||
|
||||
def test_fresh_session_on_startup(mock_settings):
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
import tempfile
|
||||
|
||||
# Configure mock settings with some data to simulate previous session
|
||||
mock_settings.activity_log = ["[12:00:00] Old message"]
|
||||
mock_settings.selected_port = "COM9"
|
||||
mock_settings.last_wafer_info = {"familyCode": "P", "serialNumber": "P00002"}
|
||||
mock_settings.data_row_count = 50
|
||||
mock_settings.data_col_count = 244
|
||||
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
new_controller = DeviceController(mock_settings, temp_dir)
|
||||
@@ -170,20 +248,55 @@ def test_detect_wafer_clears_old_session(controller):
|
||||
controller._data_row_count = 10
|
||||
controller._data_col_count = 244
|
||||
controller._activity_log.append("[12:00:00] Previous message")
|
||||
|
||||
|
||||
# We mock detect_all_ports to not run long or fail
|
||||
controller._service.detect_all_ports = MagicMock(return_value=(None, None))
|
||||
|
||||
with patch("threading.Thread") as mock_thread:
|
||||
|
||||
with patch("threading.Thread"):
|
||||
controller.detectWafer()
|
||||
|
||||
|
||||
# Verify it cleared all session variables and logs immediately at start of detect
|
||||
assert controller.selectedPort == ""
|
||||
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
|
||||
assert controller.dataRowCount == 0
|
||||
assert controller.dataColCount == 0
|
||||
|
||||
|
||||
# Log should only contain "Scanning all ports for wafer ..."
|
||||
assert len(controller._activity_log) == 1
|
||||
assert "Scanning all ports" in controller._activity_log[0]
|
||||
|
||||
def test_device_service_port_caching(controller):
|
||||
service = controller._service
|
||||
# Mock comports list
|
||||
with patch("serial.tools.list_ports.comports", return_value=[]), \
|
||||
patch("subprocess.run") as mock_run:
|
||||
# Mock subprocess to return empty
|
||||
mock_run.return_value.returncode = 0
|
||||
mock_run.return_value.stdout = ""
|
||||
|
||||
# Clear cache first
|
||||
service.clear_port_scan_cache()
|
||||
|
||||
# First scan should invoke list_ports and subprocess
|
||||
ports1 = service.enumerate_ports()
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
# Second scan within 2 seconds should return cached list without invoking subprocess
|
||||
ports2 = service.enumerate_ports()
|
||||
assert mock_run.call_count == 1
|
||||
assert ports1 == ports2
|
||||
|
||||
# Clear cache and scan again should invoke subprocess
|
||||
service.clear_port_scan_cache()
|
||||
service.enumerate_ports()
|
||||
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,23 @@
|
||||
"""Tests for the shared wafer family sensor-count lookup."""
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
|
||||
|
||||
def test_x_family_returns_80():
|
||||
assert sensor_count_for("X") == 80
|
||||
|
||||
|
||||
def test_p_family_returns_244():
|
||||
assert sensor_count_for("P") == 244
|
||||
|
||||
|
||||
def test_other_known_families_return_244():
|
||||
for code in ("A", "B", "C", "D", "E", "F"):
|
||||
assert sensor_count_for(code) == 244
|
||||
|
||||
|
||||
def test_unknown_family_returns_244():
|
||||
assert sensor_count_for("Z") == 244
|
||||
|
||||
|
||||
def test_empty_string_returns_244():
|
||||
assert sensor_count_for("") == 244
|
||||
@@ -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"
|
||||