Compare commits
25 Commits
d63332d619
...
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 |
+18
@@ -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]
|
||||
|
||||
+113
-47
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+316
-273
@@ -23,20 +23,38 @@ Rectangle {
|
||||
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
|
||||
}
|
||||
@@ -63,6 +81,7 @@ Rectangle {
|
||||
|
||||
function _doDetect() {
|
||||
root.memoryRead = false
|
||||
root.waferDetected = false
|
||||
streamController.setMode("review")
|
||||
streamController.stopStream()
|
||||
deviceController.detectWafer()
|
||||
@@ -72,7 +91,9 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -181,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
|
||||
@@ -201,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 {
|
||||
@@ -284,307 +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 === 0 ? 0 : 1
|
||||
spacing: 8
|
||||
|
||||
// ── Status tab: Hardware Actions ────────────────
|
||||
ColumnLayout {
|
||||
spacing: 6
|
||||
Label {
|
||||
text: "RUN COMPARISON (DTW)"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Button {
|
||||
id: resetRunBtn
|
||||
flat: true
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
visible: true
|
||||
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "Tabs/icons/rotate-ccw.svg"
|
||||
width: 18; height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
AppToolTip {
|
||||
visible: resetRunBtn.hovered
|
||||
text: "Reset comparison"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: useMasterCheck
|
||||
Layout.fillWidth: true
|
||||
text: "Compare vs Master File"
|
||||
checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
onToggled: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.useMasterFile = checked
|
||||
if (checked) runComparisonBox.dataTab.activeBox = 2
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
|
||||
title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
|
||||
accent: Theme.primaryAccent
|
||||
locked: useMaster
|
||||
placeholderText: useMaster
|
||||
? (runComparisonBox.dataTab.runBWaferType !== ""
|
||||
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
|
||||
: "Select Run B to resolve master")
|
||||
: "Click to select"
|
||||
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
|
||||
filePath: runComparisonBox.dataTab
|
||||
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
|
||||
: ""
|
||||
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileA = ""
|
||||
runComparisonBox.dataTab.activeBox = 1
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
RunSlot {
|
||||
title: "SESSION (RUN B)"
|
||||
accent: Theme.themeSkill
|
||||
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
|
||||
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
|
||||
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
|
||||
onCleared: {
|
||||
if (!runComparisonBox.dataTab) return
|
||||
runComparisonBox.dataTab.compareFileB = ""
|
||||
runComparisonBox.dataTab.activeBox = 2
|
||||
runComparisonBox.dataTab.clearResults()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: compareRunBtn
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 32
|
||||
readonly property var dt: runComparisonBox.dataTab
|
||||
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
||||
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
||||
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
||||
readonly property string gateError: !!dt ? dt.familyGateError : ""
|
||||
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
|
||||
AppToolTip {
|
||||
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
|
||||
text: compareRunBtn.masterMissing
|
||||
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
|
||||
: "Choose two different runs to compare"
|
||||
}
|
||||
onClicked: {
|
||||
dt.comparing = true
|
||||
dt.clearResults()
|
||||
streamController.compareFiles(fileA, dt.compareFileB)
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: compareRunBtn.enabled
|
||||
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
|
||||
: Theme.buttonNeutralBackground
|
||||
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 8
|
||||
anchors.centerIn: parent
|
||||
Label {
|
||||
text: "HARDWARE ACTIONS"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
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
|
||||
topPadding: 6
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "DETECT WAFER"
|
||||
iconSource: "../icons/detect.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: !deviceController.operationInProgress
|
||||
onClicked: root._doDetect()
|
||||
BusyIndicator {
|
||||
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
|
||||
width: 14; height: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "READ MEMORY"
|
||||
iconSource: "../icons/read.svg"
|
||||
Layout.fillWidth: true
|
||||
enabled: root.waferDetected
|
||||
onClicked: deviceController.readMemoryAsync()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "ERASE MEMORY"
|
||||
iconSource: "../icons/erase.svg"
|
||||
Layout.fillWidth: true
|
||||
destructive: true
|
||||
enabled: root.waferDetected
|
||||
onClicked: deviceController.eraseMemory(
|
||||
deviceController.selectedPort)
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Map tab: Source file browser ──────────────
|
||||
SourcePanel {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
// ── 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: Theme.statusBadgeText
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -594,8 +633,6 @@ Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.workspaceBackground
|
||||
border.color: Theme.workspaceBorder
|
||||
border.width: 1
|
||||
|
||||
StackLayout {
|
||||
anchors.fill: parent
|
||||
@@ -613,6 +650,12 @@ Rectangle {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+197
-732
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
|
||||
// ===== Graph Tab =====
|
||||
// Whole-run multi-sensor line chart (C# parity: tabGraph/chartData). Static
|
||||
// snapshot recomputed whenever a file is loaded — not live-updating during
|
||||
// a stream. RunChart adds wheel zoom, drag pan, and a visible-range
|
||||
// min/max/avg strip. See specs/plans/2026-07-10-graph-tab.md and
|
||||
// docs/adr/0003-pyqtgraph-for-multi-sensor-charts.md.
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
|
||||
function refresh() {
|
||||
if (!streamController.loadedFile) {
|
||||
chart.seriesData = []
|
||||
chart.sensorNames = []
|
||||
return
|
||||
}
|
||||
chart.sensorNames = streamController.graphSensorNames
|
||||
? streamController.graphSensorNames.split(",") : []
|
||||
chart.seriesData = JSON.parse(streamController.graphSeriesJson || "[]")
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: streamController
|
||||
function onLoadedFileChanged() { root.refresh() }
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusMd
|
||||
|
||||
RunChart {
|
||||
id: chart
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
title: "Sensor Temperature Over Time"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
+305
-309
@@ -30,36 +30,45 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component GridCell : RowLayout {
|
||||
// Stacked stat block: small label over big value, centered in its grid
|
||||
// quadrant so the card's height is used instead of leaving dead space.
|
||||
component GridCell : ColumnLayout {
|
||||
property alias label: labelText.text
|
||||
property alias value: valueText.text
|
||||
property bool active: root.waferDetected
|
||||
|
||||
spacing: 8
|
||||
spacing: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
Text {
|
||||
id: labelText
|
||||
color: Theme.sideMutedText
|
||||
@@ -67,17 +76,15 @@ ColumnLayout {
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
id: valueText
|
||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.pixelSize: Theme.fontXl
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.alignment: Qt.AlignRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
// ── Aliases for external wiring ──
|
||||
@@ -115,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
|
||||
@@ -138,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
|
||||
@@ -239,221 +281,204 @@ ColumnLayout {
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 1
|
||||
Text {
|
||||
text: "COM PORT"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.alignment: Qt.AlignRight
|
||||
}
|
||||
Text {
|
||||
text: root.portName
|
||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
Layout.alignment: Qt.AlignRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 90
|
||||
Layout.maximumHeight: 90
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.preferredWidth: 65
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 6
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: "DIRECTORY"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Medium
|
||||
font.letterSpacing: 1.2
|
||||
font.family: Theme.uiFontFamily
|
||||
}
|
||||
Text {
|
||||
text: deviceController.saveDataDir || "Not configured"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.family: Theme.uiFontFamily
|
||||
font.italic: true
|
||||
opacity: 0.7
|
||||
elide: Text.ElideMiddle
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
visible: root.csvPath !== ""
|
||||
width: sessionSavedLabel.implicitWidth + 12
|
||||
height: 18
|
||||
radius: 4
|
||||
color: Theme.fieldBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
id: sessionSavedLabel
|
||||
anchors.centerIn: parent
|
||||
text: "SESSION SAVED"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
id: browseBtn
|
||||
text: "Save As"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
font.family: Theme.uiFontFamily
|
||||
implicitHeight: 24
|
||||
implicitWidth: 70
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
contentItem: Text {
|
||||
text: browseBtn.text
|
||||
color: Theme.bodyColor
|
||||
font: browseBtn.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
onClicked: saveDirDialog.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: "WAFER & SENSOR DATA"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontXs
|
||||
text: root.portName
|
||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
font.letterSpacing: 0.6
|
||||
Layout.bottomMargin: 2
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wafer & Sensor Data: far right, spans both rows ──
|
||||
Rectangle {
|
||||
Layout.rowSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.panelPadding
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: "WAFER & SENSOR DATA"
|
||||
color: Theme.sideMutedText
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
font.letterSpacing: 0.6
|
||||
Layout.bottomMargin: 2
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
columns: 2
|
||||
rowSpacing: 6
|
||||
columnSpacing: 16
|
||||
|
||||
GridCell {
|
||||
id: waferInfoFamilyCell
|
||||
label: "Family Code"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
||||
}
|
||||
}
|
||||
GridCell {
|
||||
id: waferSerialCell
|
||||
label: "Serial"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||
}
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Rectangle {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
columns: 2
|
||||
rowSpacing: 4
|
||||
columnSpacing: 16
|
||||
Layout.preferredHeight: 1
|
||||
color: Theme.cardBorder
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
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]) : "—";
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.columnSpan: 2
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
opacity: 0.5
|
||||
}
|
||||
|
||||
GridCell {
|
||||
id: waferSerialCell
|
||||
label: "Serial"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||
}
|
||||
}
|
||||
GridCell {
|
||||
id: waferSensorsCell
|
||||
label: "Sensors"
|
||||
value: {
|
||||
var info = deviceController.lastWaferInfo;
|
||||
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,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
|
||||
@@ -489,64 +524,22 @@ ColumnLayout {
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
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 {
|
||||
@@ -567,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
|
||||
@@ -578,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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -635,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,7 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
@@ -11,39 +10,23 @@ 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();
|
||||
}
|
||||
// A file was picked from the rail (any tab) — loadFile() switches the
|
||||
// backend to review mode; mirror that in the toolbar toggle so a
|
||||
// successful load is never hidden behind a stale "Live" selection.
|
||||
function onLoadedFileChanged() {
|
||||
root.loadErrorMessage = "";
|
||||
if (streamController.mode === "review")
|
||||
modeBar.currentIndex = 0;
|
||||
}
|
||||
function onLoadFileError(message) {
|
||||
root.loadErrorMessage = message;
|
||||
@@ -60,111 +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
|
||||
// Live status dot (matches the connection-flow indicator dot elsewhere)
|
||||
Rectangle {
|
||||
id: liveIndicator
|
||||
width: 8; height: 8
|
||||
radius: 4
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
|
||||
|
||||
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
|
||||
}
|
||||
@@ -219,49 +97,6 @@ Item {
|
||||
font.letterSpacing: 1.2
|
||||
}
|
||||
}
|
||||
|
||||
// Record start/stop toggle (Live mode)
|
||||
Button {
|
||||
visible: streamController.mode === "live"
|
||||
enabled: streamController.state !== "idle" || streamController.recording
|
||||
text: streamController.recording ? "Stop REC" : "Record"
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream statistics popup (Live mode)
|
||||
Button {
|
||||
visible: streamController.mode === "live"
|
||||
text: "Stats"
|
||||
onClicked: streamStatsDialog.open()
|
||||
}
|
||||
|
||||
StreamStatsDialog {
|
||||
id: streamStatsDialog
|
||||
elapsedSecs: root._liveSecs
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Export PNG"
|
||||
onClicked: exportDialog.open()
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: exportDialog
|
||||
title: "Export Wafer Map"
|
||||
fileMode: FileDialog.SaveFile
|
||||
nameFilters: ["PNG files (*.png)"]
|
||||
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load error banner ───────────────────────────────────────────
|
||||
@@ -309,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.Controls.impl
|
||||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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.diffAccent
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -186,7 +186,7 @@ Dialog {
|
||||
implicitHeight: 32
|
||||
onClicked: root.close()
|
||||
contentItem: IconImage {
|
||||
source: "icons/x.svg"
|
||||
source: "../icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,18 +160,47 @@ ColumnLayout {
|
||||
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
|
||||
}
|
||||
|
||||
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab).
|
||||
readonly property bool isActive:{
|
||||
readonly property var dataTab: {
|
||||
try {
|
||||
if (root.selectedTabIndex === 1 && dataTabLoader.item) {
|
||||
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB;
|
||||
}
|
||||
} catch (e) {}
|
||||
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
||||
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)
|
||||
|
||||
@@ -225,6 +224,8 @@ 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 Theme.familyBlueAccent;
|
||||
@@ -282,6 +283,16 @@ ColumnLayout {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Typography hierarchy
|
||||
@@ -321,6 +332,25 @@ ColumnLayout {
|
||||
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
|
||||
|
||||
@@ -212,6 +212,7 @@ Popup {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +268,31 @@ Popup {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +321,16 @@ Popup {
|
||||
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 ────────────────────────────────────────
|
||||
@@ -302,6 +338,7 @@ Popup {
|
||||
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");
|
||||
@@ -310,6 +347,13 @@ Popup {
|
||||
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,130 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import ISC
|
||||
|
||||
// ===== Stream Stats Dialog =====
|
||||
// Popup showing live stream statistics: received frames, errors,
|
||||
// resync count, and elapsed time. Opened from the Live toolbar.
|
||||
|
||||
Popup {
|
||||
id: root
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: 320
|
||||
implicitHeight: content.implicitHeight + 40
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
// Elapsed seconds supplied by the Live timer in WaferMapTab.
|
||||
property int elapsedSecs: 0
|
||||
|
||||
function fmtTime(s) {
|
||||
var m = Math.floor(s / 60);
|
||||
var ss = s % 60;
|
||||
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusMd
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
component StatRow: RowLayout {
|
||||
property string label
|
||||
property string value
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: label
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label {
|
||||
text: value
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
anchors.fill: parent
|
||||
anchors.margins: 20
|
||||
spacing: 16
|
||||
|
||||
// ── Header ────────────────────────────────────────────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 10
|
||||
|
||||
Label {
|
||||
text: "STREAM DATA"
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
color: Theme.headingColor
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Button {
|
||||
flat: true
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
onClicked: root.close()
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusXs
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||
}
|
||||
contentItem: IconImage {
|
||||
source: "../icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats box ─────────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: statRows.implicitHeight + 20
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.subtleSectionBackground
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: statRows
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 8
|
||||
|
||||
StatRow {
|
||||
label: "Received frames"
|
||||
value: streamController.receivedCount
|
||||
}
|
||||
StatRow {
|
||||
label: "Errors"
|
||||
value: streamController.errorCount
|
||||
}
|
||||
StatRow {
|
||||
label: "Resyncs"
|
||||
value: streamController.resyncCount
|
||||
}
|
||||
StatRow {
|
||||
label: "Elapsed"
|
||||
value: root.fmtTime(root.elapsedSecs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ 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
|
||||
@@ -16,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 {
|
||||
@@ -147,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
|
||||
@@ -229,7 +178,7 @@ Item {
|
||||
Button {
|
||||
id: playPauseBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: 64
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
background: Rectangle {
|
||||
|
||||
@@ -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
|
||||
@@ -39,7 +41,9 @@ Item {
|
||||
HoverHandler {
|
||||
id: hoverHandler
|
||||
onPointChanged: {
|
||||
map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
||||
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
|
||||
@@ -47,6 +51,8 @@ Item {
|
||||
|
||||
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);
|
||||
@@ -58,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,5 +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
|
||||
StreamStatsDialog 1.0 StreamStatsDialog.qml
|
||||
StreamControlPanel 1.0 StreamControlPanel.qml
|
||||
StatusActionsPanel 1.0 StatusActionsPanel.qml
|
||||
ConnectionFooter 1.0 ConnectionFooter.qml
|
||||
UtilityFooter 1.0 UtilityFooter.qml
|
||||
PanelCheckBox 1.0 PanelCheckBox.qml
|
||||
ReadoutStat 1.0 ReadoutStat.qml
|
||||
MetricRow 1.0 MetricRow.qml
|
||||
AppToolTip 1.0 AppToolTip.qml
|
||||
SectionTitle 1.0 SectionTitle.qml
|
||||
PanelBox 1.0 PanelBox.qml
|
||||
PanelSlider 1.0 PanelSlider.qml
|
||||
EditableScrubStat 1.0 EditableScrubStat.qml
|
||||
ComparisonChartCard 1.0 ComparisonChartCard.qml
|
||||
OverlapMapCard 1.0 OverlapMapCard.qml
|
||||
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
|
||||
RunChart 1.0 RunChart.qml
|
||||
@@ -5,4 +5,3 @@ 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
|
||||
|
||||
@@ -205,7 +205,8 @@ 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
|
||||
|
||||
+43
-16
@@ -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"]
|
||||
|
||||
@@ -13,24 +13,20 @@ 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.serial_port import WaferInfo
|
||||
|
||||
# import pygui.backend.wafer_map_item
|
||||
|
||||
stream_controller = SessionController()
|
||||
# engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -44,6 +40,7 @@ class DeviceController(QObject):
|
||||
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}
|
||||
@@ -73,7 +70,17 @@ class DeviceController(QObject):
|
||||
self._activity_log: list[str] = []
|
||||
self._raw_bytes: Optional[bytes] = None
|
||||
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
|
||||
@@ -116,6 +123,11 @@ class DeviceController(QObject):
|
||||
"""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."""
|
||||
@@ -129,6 +141,14 @@ class DeviceController(QObject):
|
||||
self._append_log(f"Save data dir set to: {path}")
|
||||
self._save_status()
|
||||
|
||||
@Slot(result=str)
|
||||
@slot_error_boundary
|
||||
def exportDir(self) -> str:
|
||||
"""Ensure and return the Export subfolder under saveDataDir."""
|
||||
path = Path(self._save_data_dir) / "Export"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return str(path)
|
||||
|
||||
@Slot(str)
|
||||
@slot_error_boundary
|
||||
def openCsvFile(self, file_path: str) -> None:
|
||||
@@ -204,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."""
|
||||
@@ -268,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
|
||||
@@ -324,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)
|
||||
@@ -349,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
|
||||
@@ -377,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"})
|
||||
@@ -436,6 +488,9 @@ class DeviceController(QObject):
|
||||
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())
|
||||
@@ -502,10 +557,29 @@ class DeviceController(QObject):
|
||||
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)
|
||||
@@ -519,10 +593,11 @@ class DeviceController(QObject):
|
||||
self._set_connection_status("Connected")
|
||||
sensor_bytes = result.get("sensor_bytes", 0)
|
||||
debug_bytes = result.get("debug_bytes", 0)
|
||||
self._append_log(
|
||||
f"Debug read complete: {sensor_bytes} sensor bytes, "
|
||||
f"{debug_bytes} debug bytes"
|
||||
)
|
||||
csv_path = result.get("csv_path", "")
|
||||
msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
|
||||
if csv_path:
|
||||
msg += f", CSV written to {csv_path}"
|
||||
self._append_log(msg)
|
||||
else:
|
||||
self._set_connection_status("Disconnected")
|
||||
err_msg = result.get("error", "Debug read failed")
|
||||
|
||||
@@ -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,22 +40,22 @@ class SessionController(QObject):
|
||||
recordingChanged = Signal()
|
||||
sensorsChanged = Signal()
|
||||
loadedFileChanged = Signal()
|
||||
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
|
||||
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()
|
||||
@@ -58,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)
|
||||
@@ -82,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)
|
||||
@@ -144,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 [
|
||||
@@ -169,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:
|
||||
@@ -198,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()
|
||||
@@ -215,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
|
||||
@@ -243,6 +260,12 @@ class SessionController(QObject):
|
||||
return
|
||||
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()
|
||||
@@ -269,37 +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:
|
||||
msg = f"Could not parse {Path(file_path).name}: {exc}"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
return
|
||||
if data is None or not data.sensors:
|
||||
msg = f"Could not parse {Path(file_path).name}"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
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:
|
||||
msg = f"No sensor layout found in {Path(file_path).name}"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
return
|
||||
if not frames:
|
||||
# A well-formed header with zero data rows below it — most commonly
|
||||
# a live recording that was stopped before any frames arrived.
|
||||
msg = f"{Path(file_path).name} has no recorded frames"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
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 = ""
|
||||
@@ -310,26 +321,17 @@ 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
|
||||
# A file was explicitly picked for review — show it regardless of
|
||||
# whichever mode (e.g. Live) was previously active, otherwise a
|
||||
# successful load can be invisible to the user.
|
||||
self.setMode(MODE_REVIEW)
|
||||
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
|
||||
@@ -337,11 +339,10 @@ class SessionController(QObject):
|
||||
"""Clear the loaded file, resetting the player and frame states."""
|
||||
self._player.load([])
|
||||
self._model.reset()
|
||||
self._stats_tracker.reset()
|
||||
self._ec_pairs = ()
|
||||
self._loaded_file = ""
|
||||
self.loadedFileChanged.emit()
|
||||
self.sensorsChanged.emit()
|
||||
self.settingsChanged.emit()
|
||||
|
||||
# ---- comparison: DTW between two CSV files ----
|
||||
@Slot(str, str)
|
||||
@@ -353,43 +354,14 @@ class SessionController(QObject):
|
||||
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:
|
||||
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
||||
# startRecording (see FileBrowser.isRecording), distinct from the
|
||||
# "<serial>-<timestamp>.csv" convention used for read-memory dumps.
|
||||
# Comparing across those categories doesn't make sense (a live
|
||||
# capture vs. a full memory readout aren't the same kind of run),
|
||||
# so gate on that before even looking at wafer type.
|
||||
stem_a, stem_b = Path(file_a).stem, Path(file_b).stem
|
||||
is_recording_a = stem_a.lower().startswith("live_")
|
||||
is_recording_b = stem_b.lower().startswith("live_")
|
||||
if is_recording_a != is_recording_b:
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": "Cannot compare a recording file with a read-memory file"
|
||||
})
|
||||
return
|
||||
|
||||
# Gate on wafer type (family): comparing a P-wafer against an X-wafer
|
||||
# (different sensor counts/layouts) would silently truncate the
|
||||
# smaller sensor set and pair up unrelated physical sensors in the
|
||||
# overlap view. Same convention as FileBrowser's waferType badge —
|
||||
# first character of the filename stem, minus the "live_" prefix
|
||||
# so the wafer letter is read from the serial, not the "L".
|
||||
def _wafer_type(stem: str, is_recording: bool) -> str:
|
||||
if is_recording:
|
||||
stem = stem[len("live_"):]
|
||||
return stem[0].upper() if stem else ""
|
||||
|
||||
type_a = _wafer_type(stem_a, is_recording_a)
|
||||
type_b = _wafer_type(stem_b, is_recording_b)
|
||||
if type_a and type_b and type_a != type_b:
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": f"Cannot compare different wafer types: {type_a} vs {type_b}"
|
||||
})
|
||||
return
|
||||
|
||||
if is_official_csv(file_a):
|
||||
_, recs_a = read_official_csv(file_a)
|
||||
else:
|
||||
@@ -406,30 +378,36 @@ class SessionController(QObject):
|
||||
})
|
||||
return
|
||||
|
||||
# Compare the full overlapping length of both runs. DTW's O(n*m) cost
|
||||
# matrix is cheap even at a few thousand frames (~26MB at 1800x1800),
|
||||
# so only cap against pathologically long files, not typical runs.
|
||||
max_frames = min(5000, len(recs_a), len(recs_b))
|
||||
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
|
||||
for i in range(max_frames)]
|
||||
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
|
||||
for i in range(max_frames)]
|
||||
self._compare_recs_a = recs_a
|
||||
self._compare_recs_b = recs_b
|
||||
|
||||
result = compare_runs(series_a, series_b)
|
||||
len_a, len_b = len(recs_a), len(recs_b)
|
||||
# DTW can only align frames both runs actually have; the longer
|
||||
# run's extra tail is still returned for display, just unaligned.
|
||||
overlap_frames = min(len_a, len_b)
|
||||
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
|
||||
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)]
|
||||
time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
|
||||
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
|
||||
|
||||
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
|
||||
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
|
||||
|
||||
# Mean temporal shift along the DTW path: positive means run B
|
||||
# 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 and take
|
||||
# the largest per-sensor abs difference across all sensors, not
|
||||
# just the sensor[0] series used for alignment.
|
||||
# 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"]):
|
||||
if i >= max_frames or j >= max_frames:
|
||||
continue
|
||||
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))):
|
||||
@@ -438,9 +416,8 @@ class SessionController(QObject):
|
||||
max_sensor_deviation = diff
|
||||
|
||||
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||
# same sensor-parsing branch as loadFile(); file_a and file_b are now
|
||||
# guaranteed to share a wafer type by the gate above.
|
||||
from pygui.backend.data.data_records import is_official_csv, read_official_csv
|
||||
# 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
|
||||
|
||||
@@ -460,8 +437,8 @@ class SessionController(QObject):
|
||||
|
||||
if sensors:
|
||||
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
|
||||
last_a = recs_a[max_frames - 1].values
|
||||
last_b = recs_b[max_frames - 1].values
|
||||
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 = [
|
||||
{
|
||||
@@ -484,9 +461,14 @@ class SessionController(QObject):
|
||||
# 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,
|
||||
@@ -499,6 +481,25 @@ class SessionController(QObject):
|
||||
"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
|
||||
@@ -523,6 +524,10 @@ class SessionController(QObject):
|
||||
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": [{
|
||||
@@ -539,6 +544,48 @@ class SessionController(QObject):
|
||||
"error": str(exc)
|
||||
})
|
||||
|
||||
@Slot(int)
|
||||
@slot_error_boundary
|
||||
def exportSegment(self, index: int) -> None:
|
||||
"""Write one segment from the last split as a standalone CSV.
|
||||
|
||||
Output lands next to the source file as
|
||||
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
|
||||
so read_official_csv still resolves the layout on re-import.
|
||||
Emits segmentExported with success + path (or error).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
||||
|
||||
source = getattr(self, "_last_split_file", "")
|
||||
segments = getattr(self, "_last_split_segments", [])
|
||||
if not source or not (0 <= index < len(segments)):
|
||||
self.segmentExported.emit({
|
||||
"success": False,
|
||||
"error": "No split result to export"
|
||||
})
|
||||
return
|
||||
|
||||
seg = segments[index]
|
||||
src = Path(source)
|
||||
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
|
||||
try:
|
||||
ok = CsvRecorder.write_segment(
|
||||
str(dest), str(src), seg.start_frame, seg.end_frame)
|
||||
except Exception as exc:
|
||||
log.warning("Segment export failed: %s", exc)
|
||||
self.segmentExported.emit({"success": False, "error": str(exc)})
|
||||
return
|
||||
|
||||
if ok:
|
||||
self.segmentExported.emit({"success": True, "path": str(dest)})
|
||||
else:
|
||||
self.segmentExported.emit({
|
||||
"success": False,
|
||||
"error": "No frames in range"
|
||||
})
|
||||
|
||||
@Slot()
|
||||
@slot_error_boundary
|
||||
def play(self) -> None:
|
||||
@@ -642,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:
|
||||
@@ -672,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
|
||||
@@ -717,7 +762,6 @@ 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()
|
||||
@@ -743,10 +787,8 @@ class SessionController(QObject):
|
||||
@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,
|
||||
@@ -755,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:
|
||||
@@ -826,6 +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,
|
||||
@@ -236,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,6 +105,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
marginChanged = Signal()
|
||||
blendChanged = Signal()
|
||||
showLabelsChanged = Signal()
|
||||
showExtremesChanged = Signal()
|
||||
colorsChanged = Signal()
|
||||
shapeChanged = Signal()
|
||||
sizeChanged = Signal()
|
||||
@@ -65,9 +124,10 @@ 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)
|
||||
@@ -103,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(
|
||||
@@ -123,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()
|
||||
@@ -134,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()
|
||||
@@ -144,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()
|
||||
@@ -155,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()
|
||||
@@ -166,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:
|
||||
@@ -178,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()
|
||||
@@ -198,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()
|
||||
@@ -206,20 +276,38 @@ 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()
|
||||
@@ -229,7 +317,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def hoveredIndex(self) -> int:
|
||||
return self._hovered_index
|
||||
|
||||
@hoveredIndex.setter
|
||||
@hoveredIndex.setter # type: ignore[no-redef]
|
||||
def hoveredIndex(self, val: int) -> None:
|
||||
val = int(val)
|
||||
if val == self._hovered_index:
|
||||
@@ -242,7 +330,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
def hoverScale(self) -> float:
|
||||
return self._hover_scale
|
||||
|
||||
@hoverScale.setter
|
||||
@hoverScale.setter # type: ignore[no-redef]
|
||||
def hoverScale(self, val: float) -> None:
|
||||
self._hover_scale = float(val)
|
||||
self.hoverScaleChanged.emit()
|
||||
@@ -251,32 +339,32 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
# 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 ─────────────────────────────────────────────────────────────
|
||||
@@ -290,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:
|
||||
@@ -377,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:
|
||||
@@ -426,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
|
||||
@@ -463,14 +628,15 @@ 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -504,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()
|
||||
@@ -567,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,
|
||||
@@ -579,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
|
||||
@@ -612,16 +789,6 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
ox = getattr(s, "offset_x", 0.0) * r
|
||||
oy = getattr(s, "offset_y", 0.0) * r
|
||||
|
||||
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 ""
|
||||
|
||||
@@ -652,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:
|
||||
@@ -664,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,12 +0,0 @@
|
||||
# ===== Wafer Sub-package =====
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
__all__ = [
|
||||
"ZWaferData", "Sensor", "DataRecord",
|
||||
"ZWaferParser",
|
||||
"load_layout", "available_families",
|
||||
"sensor_count_for",
|
||||
]
|
||||
|
||||
@@ -66,9 +66,39 @@ def _family_name(raw_name: str) -> str:
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
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")]
|
||||
|
||||
|
||||
@@ -115,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,6 +10,7 @@ Mirrors the C# Form1.cs binary parsing pipeline:
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
||||
@@ -106,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)
|
||||
@@ -224,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).
|
||||
|
||||
@@ -266,7 +293,6 @@ 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"
|
||||
@@ -294,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -45,7 +45,7 @@ class StreamReader:
|
||||
Reads up to 256 bytes in one shot. If nothing is immediately
|
||||
available, yields briefly to avoid a busy spin.
|
||||
"""
|
||||
chunk = self._transport.read(max(min_bytes, 256))
|
||||
chunk: bytes = self._transport.read(max(min_bytes, 256))
|
||||
if not chunk:
|
||||
time.sleep(0.005)
|
||||
return chunk
|
||||
@@ -106,7 +106,7 @@ class StreamReader:
|
||||
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
|
||||
@@ -149,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
|
||||
@@ -238,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)
|
||||
@@ -262,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
|
||||
|
||||
Vendored
+11
@@ -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
|
||||
|
||||
+101
-3
@@ -1,13 +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,
|
||||
)
|
||||
|
||||
# ── csv_column_count ──────────────────────────────────────────────────────────
|
||||
@@ -271,7 +275,7 @@ class TestSaveToCsv:
|
||||
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, f"data width 60 should win over display 48"
|
||||
assert len(headers) == 60, "data width 60 should win over display 48"
|
||||
assert headers[0] == "Sensor1"
|
||||
assert headers[-1] == "Sensor60"
|
||||
|
||||
@@ -320,3 +324,97 @@ class TestSaveToCsv:
|
||||
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,7 +84,7 @@ 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 == ""
|
||||
@@ -130,6 +133,64 @@ def test_read_debug_handler(controller):
|
||||
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()
|
||||
assert res == {"success": False}
|
||||
@@ -142,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
|
||||
@@ -159,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)
|
||||
@@ -187,19 +248,19 @@ 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]
|
||||
@@ -227,6 +288,15 @@ def test_device_service_port_caching(controller):
|
||||
|
||||
# Clear cache and scan again should invoke subprocess
|
||||
service.clear_port_scan_cache()
|
||||
ports3 = service.enumerate_ports()
|
||||
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.
|
||||
@@ -28,3 +28,16 @@ def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
|
||||
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
|
||||
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
|
||||
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
|
||||
|
||||
|
||||
def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path):
|
||||
"""setCurrentDirectory (no native dialog) is how the Status tab's save-dir
|
||||
picker keeps the Source panel pointed at the same folder."""
|
||||
_write_csv(tmp_path / "P00003-20260706_143500.csv")
|
||||
|
||||
browser = FileBrowser()
|
||||
browser.setCurrentDirectory(str(tmp_path))
|
||||
|
||||
assert browser.currentDirectory == str(tmp_path)
|
||||
assert len(browser.files) == 1
|
||||
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.models.frame_player import FramePlayer
|
||||
from pygui.backend.models.frame_player import FramePlayer, all_sensor_series
|
||||
|
||||
|
||||
def frames(n):
|
||||
return [Frame(seq=i, time=float(i), values=[149.0 + i])for i in range(n)]
|
||||
|
||||
def test_loads_and_reports_total():
|
||||
p = FramePlayer(); p.load(frames(3))
|
||||
assert p.total == 3 and p.index == 0
|
||||
assert p.total == 3 and p.index == 0
|
||||
current = p.current()
|
||||
assert current is not None
|
||||
assert current.values == [149.0]
|
||||
|
||||
|
||||
def test_step_forward_and_back_clamps():
|
||||
p = FramePlayer(); p.load(frames(3))
|
||||
assert p.step(1).index == 1
|
||||
assert p.step(1).index == 2
|
||||
assert p.step(1).index == 2
|
||||
assert p.step(-5).index == 0
|
||||
|
||||
|
||||
def test_seek():
|
||||
p = FramePlayer(); p.load(frames(5))
|
||||
assert p.seek(3).index == 3
|
||||
assert p.seek(99).index == 4
|
||||
|
||||
|
||||
def test_at_end():
|
||||
p = FramePlayer(); p.load(frames(2))
|
||||
assert not p.at_end
|
||||
p.seek(1)
|
||||
assert p.at_end
|
||||
assert p.at_end
|
||||
|
||||
def test_frames_property_exposes_loaded_frames():
|
||||
p = FramePlayer(); p.load(frames(3))
|
||||
assert p.frames == tuple(frames(3))
|
||||
|
||||
|
||||
# ---- all_sensor_series (Graph tab whole-run assembly) ----
|
||||
|
||||
def test_all_sensor_series_empty_frames():
|
||||
assert all_sensor_series([]) == ([], [])
|
||||
|
||||
|
||||
def test_all_sensor_series_names_and_columns():
|
||||
fs = [
|
||||
Frame(seq=0, time=0.0, values=[150.0, 148.0]),
|
||||
Frame(seq=1, time=1.0, values=[151.0, 147.5]),
|
||||
Frame(seq=2, time=2.0, values=[152.0, 147.0]),
|
||||
]
|
||||
names, series = all_sensor_series(fs)
|
||||
assert names == ["Sensor 1", "Sensor 2"]
|
||||
assert series == [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from pygui.backend.models.frame_stats import compute_stats, Stats
|
||||
|
||||
from pygui.backend.models.frame_stats import (
|
||||
EdgeCenterStats,
|
||||
Stats,
|
||||
compute_edge_center,
|
||||
compute_stats,
|
||||
)
|
||||
|
||||
|
||||
def test_basic_stat():
|
||||
@@ -20,3 +27,49 @@ def test_empty_values_returns_zeros():
|
||||
|
||||
def test_ignores_nan():
|
||||
s = compute_stats([149.0, float("nan"), 151.0])
|
||||
assert s.min == 149.0 and s.min_index == 0
|
||||
assert s.max == 151.0 and s.max_index == 2
|
||||
assert s.diff == pytest.approx(2.0)
|
||||
assert s.avg == pytest.approx(150.0)
|
||||
assert s.sigma == pytest.approx(1.0)
|
||||
assert s.three_sigma == pytest.approx(3.0)
|
||||
|
||||
|
||||
# ---- edge-center delta (ADR-0002) ----
|
||||
|
||||
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
|
||||
|
||||
|
||||
def test_edge_center_min_max_pairs():
|
||||
# e0 e1 e2 e3 c4 c5
|
||||
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||
ec = compute_edge_center(values, PAIRS, excluded=set())
|
||||
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
|
||||
assert ec == EdgeCenterStats(
|
||||
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
|
||||
min_delta=pytest.approx(0.1),
|
||||
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
|
||||
max_delta=pytest.approx(1.5),
|
||||
)
|
||||
|
||||
|
||||
def test_edge_center_skips_excluded_sensors():
|
||||
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
|
||||
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
|
||||
assert ec is not None
|
||||
assert ec.max_delta == pytest.approx(1.0)
|
||||
# excluding a center sensor kills all its pairs
|
||||
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
|
||||
|
||||
|
||||
def test_edge_center_skips_nan_and_out_of_range():
|
||||
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
|
||||
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
|
||||
assert ec is not None
|
||||
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
|
||||
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
|
||||
|
||||
|
||||
def test_edge_center_none_when_no_pairs():
|
||||
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
|
||||
assert compute_edge_center([], PAIRS, excluded=set()) is None
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Tests for src/pygui/backend/visualization/graph_quick_item.py."""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("qapp")
|
||||
|
||||
|
||||
def test_no_data_initially():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
assert item.seriesData == []
|
||||
assert item.sensorNames == []
|
||||
|
||||
|
||||
def test_series_data_setter_stores_series():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[150.0, 151.0], [148.0, 147.5]]
|
||||
|
||||
assert item.seriesData == [[150.0, 151.0], [148.0, 147.5]]
|
||||
|
||||
|
||||
def test_series_data_auto_ranges_y_with_padding():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||
|
||||
# min=100, max=200, range=100, 10% pad => [90, 210]
|
||||
assert item._min_y == pytest.approx(90.0)
|
||||
assert item._max_y == pytest.approx(210.0)
|
||||
|
||||
|
||||
def test_series_data_empty_keeps_default_range():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = []
|
||||
|
||||
assert item._min_y == 0.0
|
||||
assert item._max_y == 150.0
|
||||
|
||||
|
||||
def test_sensor_names_setter_stores_names():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||
|
||||
assert item.sensorNames == ["Sensor 1", "Sensor 2"]
|
||||
|
||||
|
||||
def test_non_numeric_values_are_skipped_not_crashing():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[150.0, "bad", 151.0]]
|
||||
|
||||
assert item._min_y == pytest.approx(149.9)
|
||||
assert item._max_y == pytest.approx(151.1)
|
||||
|
||||
|
||||
# ---- viewport_stats (replay chart min/max/avg readouts) ----
|
||||
# Independently hand-computed against PopupChartForm.cs's recalc_stats
|
||||
# semantics: aggregate across every sensor's points in [start, end], plus
|
||||
# which sensor achieved each extreme.
|
||||
|
||||
|
||||
def test_viewport_stats_full_range_aggregate():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
names = ["Sensor 1", "Sensor 2"]
|
||||
|
||||
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||
|
||||
assert vmin == pytest.approx(147.0)
|
||||
assert vmax == pytest.approx(152.0)
|
||||
assert avg == pytest.approx(149.25)
|
||||
assert min_sensor == "Sensor 2"
|
||||
assert max_sensor == "Sensor 1"
|
||||
|
||||
|
||||
def test_viewport_stats_restricted_to_subrange():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
series = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
names = ["Sensor 1", "Sensor 2"]
|
||||
|
||||
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 1)
|
||||
|
||||
assert vmin == pytest.approx(147.5)
|
||||
assert vmax == pytest.approx(151.0)
|
||||
assert avg == pytest.approx(149.125)
|
||||
assert min_sensor == "Sensor 2"
|
||||
assert max_sensor == "Sensor 1"
|
||||
|
||||
|
||||
def test_viewport_stats_empty_series_returns_zeros():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
assert viewport_stats([], [], 0, -1) == (0.0, 0.0, 0.0, "", "")
|
||||
|
||||
|
||||
def test_viewport_stats_skips_non_numeric():
|
||||
from pygui.backend.visualization.graph_quick_item import viewport_stats
|
||||
|
||||
series = [[150.0, "bad", 151.0]]
|
||||
names = ["Sensor 1"]
|
||||
|
||||
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(series, names, 0, 2)
|
||||
|
||||
assert vmin == pytest.approx(150.0)
|
||||
assert vmax == pytest.approx(151.0)
|
||||
assert avg == pytest.approx(150.5)
|
||||
assert min_sensor == "Sensor 1"
|
||||
assert max_sensor == "Sensor 1"
|
||||
|
||||
|
||||
# ---- GraphQuickItem viewport properties (replay chart) ----
|
||||
# Default viewport (0, -1) means "whole series" so the already-shipped Graph
|
||||
# tab (which never sets these) is unaffected — see docs/adr/0004.
|
||||
|
||||
|
||||
def test_viewport_defaults_to_whole_range_and_markers_off():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
assert item.showMinMaxMarkers is False
|
||||
|
||||
|
||||
def test_view_stats_reflect_default_full_range_viewport():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
|
||||
assert item.viewMin == pytest.approx(147.0)
|
||||
assert item.viewMax == pytest.approx(152.0)
|
||||
assert item.viewAvg == pytest.approx(149.25)
|
||||
assert item.viewMinSensor == "Sensor 2"
|
||||
assert item.viewMaxSensor == "Sensor 1"
|
||||
|
||||
|
||||
def test_view_stats_restrict_to_narrowed_viewport():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.sensorNames = ["Sensor 1", "Sensor 2"]
|
||||
item.seriesData = [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
|
||||
|
||||
item.viewStartIndex = 0
|
||||
item.viewEndIndex = 1
|
||||
|
||||
assert item.viewMin == pytest.approx(147.5)
|
||||
assert item.viewMax == pytest.approx(151.0)
|
||||
assert item.viewAvg == pytest.approx(149.125)
|
||||
|
||||
|
||||
def test_auto_range_y_restricted_to_narrowed_viewport():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[150.0, 100.0], [200.0, 120.0]]
|
||||
# Full-range Y (pre-existing behavior): min=100, max=200 -> [90, 210]
|
||||
assert item._min_y == pytest.approx(90.0)
|
||||
assert item._max_y == pytest.approx(210.0)
|
||||
|
||||
# Narrow the viewport to index 0 only: min=150, max=200, range=50, 10% pad
|
||||
item.viewStartIndex = 0
|
||||
item.viewEndIndex = 0
|
||||
|
||||
assert item._min_y == pytest.approx(145.0)
|
||||
assert item._max_y == pytest.approx(205.0)
|
||||
|
||||
|
||||
# ---- zoomAtFraction / panByFraction / resetZoom (replay chart interaction) ----
|
||||
# 11-point series (indices 0..10) chosen so the zoom/pan arithmetic lands on
|
||||
# whole numbers -- avoids rounding ambiguity in the independent hand trace.
|
||||
|
||||
|
||||
def _eleven_point_item():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[float(i) for i in range(11)]]
|
||||
return item
|
||||
|
||||
|
||||
def test_zoom_in_centers_on_fraction():
|
||||
item = _eleven_point_item()
|
||||
|
||||
# Full range is (0, 10), width 10. Zooming to 40% width centered at the
|
||||
# midpoint (frac=0.5): center=5, new_width=4 -> new_start=3, new_end=7.
|
||||
item.zoomAtFraction(0.5, 0.4)
|
||||
|
||||
assert item.viewStartIndex == 3
|
||||
assert item.viewEndIndex == 7
|
||||
|
||||
|
||||
def test_zoom_out_clamps_to_data_bounds():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 3
|
||||
item.viewEndIndex = 7
|
||||
|
||||
# width=4, zoom out 3x centered at midpoint (frac=0.5): requested
|
||||
# new_width=12 exceeds the data's max width of 10, so it clamps to the
|
||||
# full range (0, 10).
|
||||
item.zoomAtFraction(0.5, 3.0)
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == 10
|
||||
|
||||
|
||||
def test_pan_shifts_viewport_by_fraction_of_width():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 4
|
||||
item.viewEndIndex = 8
|
||||
|
||||
# width=4, pan by 50% of width (2 indices): (4,8) -> (6,10)
|
||||
item.panByFraction(0.5)
|
||||
|
||||
assert item.viewStartIndex == 6
|
||||
assert item.viewEndIndex == 10
|
||||
|
||||
|
||||
def test_pan_clamps_at_right_edge_preserving_width():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 4
|
||||
item.viewEndIndex = 8
|
||||
|
||||
# width=4, pan by 100% of width (4 indices) would be (8,12); index 12 is
|
||||
# out of bounds (max index 10), so the whole window shifts back by 2 to
|
||||
# stay in bounds while keeping width=4: (6,10).
|
||||
item.panByFraction(1.0)
|
||||
|
||||
assert item.viewStartIndex == 6
|
||||
assert item.viewEndIndex == 10
|
||||
|
||||
|
||||
def test_reset_zoom_restores_full_range_sentinel():
|
||||
item = _eleven_point_item()
|
||||
item.viewStartIndex = 3
|
||||
item.viewEndIndex = 7
|
||||
|
||||
item.resetZoom()
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
|
||||
|
||||
def test_zoom_noop_when_fewer_than_two_points():
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[42.0]]
|
||||
|
||||
item.zoomAtFraction(0.5, 0.5)
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
|
||||
|
||||
def test_loading_new_series_while_zoomed_resets_viewport_to_full_range():
|
||||
"""Regression: zooming near the tail of a long run, then loading a new
|
||||
(shorter) file, left the stale viewport pointing past the new data --
|
||||
every per-series slice became empty and the chart went blank with a
|
||||
stale Y-range and zeroed-out min/max/avg readouts. New data must reset
|
||||
the viewport to the full range (PopupChartForm.cs's SetData() parity).
|
||||
"""
|
||||
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
|
||||
|
||||
item = GraphQuickItem()
|
||||
item.seriesData = [[float(i) for i in range(20)]]
|
||||
item.viewStartIndex = 15
|
||||
item.viewEndIndex = 19
|
||||
|
||||
item.seriesData = [[100.0, 101.0, 102.0, 103.0, 104.0]]
|
||||
|
||||
assert item.viewStartIndex == 0
|
||||
assert item.viewEndIndex == -1
|
||||
assert item.viewMin == pytest.approx(100.0)
|
||||
assert item.viewMax == pytest.approx(104.0)
|
||||
assert item.viewAvg == pytest.approx(102.0)
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for src/pygui/backend/visualization/graph_view.py."""
|
||||
import json
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _make_plot_widget():
|
||||
@@ -68,6 +66,51 @@ class TestUpdateTrend:
|
||||
gv.updateTrend("[1, 2, 3]")
|
||||
|
||||
|
||||
class TestUpdateChart:
|
||||
def test_plots_one_series_per_sensor(self):
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
|
||||
gv = GraphView()
|
||||
gv._plot_widget = _make_plot_widget()
|
||||
|
||||
series = [[150.0, 151.0, 152.0], [148.5, 148.4, 148.6]]
|
||||
gv.updateChart("Sensor 1,Sensor 2", json.dumps(series))
|
||||
|
||||
assert gv._plot_widget.clear.called
|
||||
assert gv._plot_widget.plot.call_count == 2
|
||||
assert gv._sensor_names == ["Sensor 1", "Sensor 2"]
|
||||
|
||||
def test_rejects_invalid_json(self):
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
|
||||
gv = GraphView()
|
||||
gv._plot_widget = _make_plot_widget()
|
||||
|
||||
gv.updateChart("Sensor 1", "not json")
|
||||
|
||||
assert not gv._plot_widget.plot.called
|
||||
|
||||
def test_empty_series_clears_without_plotting(self):
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
|
||||
gv = GraphView()
|
||||
gv._plot_widget = _make_plot_widget()
|
||||
|
||||
gv.updateChart("", "[]")
|
||||
|
||||
assert gv._plot_widget.clear.called
|
||||
assert not gv._plot_widget.plot.called
|
||||
|
||||
def test_no_plot_widget(self):
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
|
||||
gv = GraphView()
|
||||
assert gv._plot_widget is None
|
||||
|
||||
# Should not crash
|
||||
gv.updateChart("Sensor 1", json.dumps([[1.0, 2.0]]))
|
||||
|
||||
|
||||
class TestUpdateComparison:
|
||||
def test_parses_valid_json(self):
|
||||
from pygui.backend.visualization.graph_view import GraphView
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for src/pygui/backend/license/ (manager + model)."""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from pygui.backend.license.license_manager import (
|
||||
LICENSE_KEY,
|
||||
parse_license_blob,
|
||||
read_license_files,
|
||||
split_data_iv,
|
||||
)
|
||||
from pygui.backend.license.license_model import LicenseModel
|
||||
|
||||
|
||||
# ===== Fixture helpers =====
|
||||
def _encrypt_aes(plaintext: bytes, key: bytes, iv: bytes) -> bytes:
|
||||
"""AES-128-CBC + PKCS7, mirroring what the C# license generator does."""
|
||||
pad_len = 16 - (len(plaintext) % 16)
|
||||
padded = plaintext + bytes([pad_len]) * pad_len
|
||||
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
||||
enc = cipher.encryptor()
|
||||
return enc.update(padded) + enc.finalize()
|
||||
|
||||
|
||||
def make_license_blob(
|
||||
serial: str = "A00001",
|
||||
mfg_hex: str = "0001E240",
|
||||
level: str = "01",
|
||||
lic_hex: str = "0001E24F",
|
||||
) -> bytes:
|
||||
"""Build a C#-format license blob: ciphertext || iv, fixed-offset text."""
|
||||
text = f"{serial} {mfg_hex} {level} {lic_hex}"
|
||||
iv = bytes(range(16))
|
||||
return _encrypt_aes(text.encode("utf-8"), LICENSE_KEY, iv) + iv
|
||||
|
||||
|
||||
def write_license(directory, serial="A00001", level="01", **kw) -> str:
|
||||
path = directory / f"{serial}-{level}.bin"
|
||||
path.write_bytes(make_license_blob(serial=serial, level=level, **kw))
|
||||
return str(path)
|
||||
|
||||
|
||||
# ===== Manager: blob parsing =====
|
||||
def test_parse_valid_blob():
|
||||
lic = parse_license_blob(make_license_blob())
|
||||
assert lic is not None
|
||||
assert lic.serial == "A00001"
|
||||
assert lic.level == "01"
|
||||
assert lic.level_int == 1
|
||||
assert lic.mfg_date_hex == "0001E240"
|
||||
assert lic.mfg_date_decimal == "123456"
|
||||
assert lic.lic_date_decimal == str(int("0001E24F", 16))
|
||||
|
||||
|
||||
def test_parse_blob_too_short():
|
||||
assert parse_license_blob(b"short") is None
|
||||
|
||||
|
||||
def test_parse_blob_garbage():
|
||||
assert parse_license_blob(os.urandom(64)) is None
|
||||
|
||||
|
||||
def test_parse_blob_short_text():
|
||||
iv = bytes(16)
|
||||
blob = _encrypt_aes(b"A00001 tiny", LICENSE_KEY, iv) + iv
|
||||
assert parse_license_blob(blob) is None
|
||||
|
||||
|
||||
def test_split_data_iv():
|
||||
data, iv = split_data_iv(bytes(48))
|
||||
assert len(data) == 32
|
||||
assert len(iv) == 16
|
||||
|
||||
|
||||
# ===== Manager: directory scan =====
|
||||
def test_read_license_files_valid(tmp_path):
|
||||
write_license(tmp_path)
|
||||
licenses = read_license_files(tmp_path)
|
||||
assert len(licenses) == 1
|
||||
assert licenses[0].serial == "A00001"
|
||||
|
||||
|
||||
def test_read_license_files_missing_dir(tmp_path):
|
||||
assert read_license_files(tmp_path / "nope") == []
|
||||
|
||||
|
||||
def test_read_license_files_bad_filename_skipped(tmp_path):
|
||||
(tmp_path / "notalicense.bin").write_bytes(make_license_blob())
|
||||
assert read_license_files(tmp_path) == []
|
||||
|
||||
|
||||
def test_read_license_files_name_payload_mismatch(tmp_path):
|
||||
# payload says A00001-01 but file claims B00002-03 → tamper, reject
|
||||
(tmp_path / "B00002-03.bin").write_bytes(make_license_blob())
|
||||
assert read_license_files(tmp_path) == []
|
||||
|
||||
|
||||
def test_read_license_files_corrupt_skipped(tmp_path):
|
||||
(tmp_path / "A00001-01.bin").write_bytes(os.urandom(64))
|
||||
assert read_license_files(tmp_path) == []
|
||||
|
||||
|
||||
# ===== Model =====
|
||||
def test_model_lists_licenses(tmp_path, qapp):
|
||||
(tmp_path / "licenses").mkdir()
|
||||
write_license(tmp_path / "licenses")
|
||||
model = LicenseModel(str(tmp_path))
|
||||
rows = model.licenses
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["serial"] == "A00001"
|
||||
assert rows[0]["level"] == "01"
|
||||
assert rows[0]["mfgDate"] == "123456"
|
||||
|
||||
|
||||
def test_model_load_license_file(tmp_path, qapp):
|
||||
src_dir = tmp_path / "downloads"
|
||||
src_dir.mkdir()
|
||||
src = write_license(src_dir, serial="B00002", level="00")
|
||||
model = LicenseModel(str(tmp_path / "appdata"))
|
||||
assert model.loadLicenseFile(src) is True
|
||||
assert len(model.licenses) == 1
|
||||
assert model.licenses[0]["serial"] == "B00002"
|
||||
# copy landed in <data_dir>/licenses
|
||||
assert (tmp_path / "appdata" / "licenses" / "B00002-00.bin").exists()
|
||||
|
||||
|
||||
def test_model_load_license_file_url(tmp_path, qapp):
|
||||
src_dir = tmp_path / "dl"
|
||||
src_dir.mkdir()
|
||||
src = write_license(src_dir)
|
||||
model = LicenseModel(str(tmp_path / "appdata"))
|
||||
assert model.loadLicenseFile("file://" + src) is True
|
||||
|
||||
|
||||
def test_model_load_rejects_invalid(tmp_path, qapp):
|
||||
src_dir = tmp_path / "dl"
|
||||
src_dir.mkdir()
|
||||
bad = src_dir / "A00001-01.bin"
|
||||
bad.write_bytes(os.urandom(64))
|
||||
model = LicenseModel(str(tmp_path / "appdata"))
|
||||
assert model.loadLicenseFile(str(bad)) is False
|
||||
assert model.licenses == []
|
||||
|
||||
|
||||
def test_model_load_rejects_missing(tmp_path, qapp):
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.loadLicenseFile(str(tmp_path / "ghost.bin")) is False
|
||||
|
||||
|
||||
def test_model_level_for_wafer(tmp_path, qapp):
|
||||
(tmp_path / "licenses").mkdir()
|
||||
write_license(tmp_path / "licenses", serial="A00001", level="02")
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.levelForWafer("A00001") == "02"
|
||||
assert model.levelForWafer("Z99999") == ""
|
||||
|
||||
|
||||
# ===== Replay trial =====
|
||||
def test_replay_state_none_then_temporary(tmp_path, qapp):
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.replayLicenseState() == "none"
|
||||
assert model.startReplayTrial() is True
|
||||
assert model.replayLicenseState() == "temporary"
|
||||
assert model.replayTrialExpired() is False
|
||||
|
||||
|
||||
def test_replay_state_permanent_with_level1(tmp_path, qapp):
|
||||
(tmp_path / "licenses").mkdir()
|
||||
write_license(tmp_path / "licenses", level="01")
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.replayLicenseState() == "permanent"
|
||||
|
||||
|
||||
def test_replay_level0_not_permanent(tmp_path, qapp):
|
||||
(tmp_path / "licenses").mkdir()
|
||||
write_license(tmp_path / "licenses", level="00")
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.replayLicenseState() == "none"
|
||||
|
||||
|
||||
def test_replay_trial_expiry(tmp_path, qapp):
|
||||
model = LicenseModel(str(tmp_path))
|
||||
model.startReplayTrial()
|
||||
marker = tmp_path / "replay.lic"
|
||||
old = time.time() - 31 * 86400
|
||||
os.utime(marker, (old, old))
|
||||
assert model.replayTrialExpired() is True
|
||||
|
||||
|
||||
def test_replay_trial_expired_without_marker(tmp_path, qapp):
|
||||
model = LicenseModel(str(tmp_path))
|
||||
assert model.replayTrialExpired() is True
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
|
||||
from pygui.backend.data.local_settings import LocalSettings
|
||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared_settings():
|
||||
return LocalSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model(qapp, tmp_path, shared_settings):
|
||||
return LocalSettingsModel(shared_settings, tmp_path)
|
||||
|
||||
|
||||
def test_save_mutates_the_shared_instance_in_place(model, shared_settings):
|
||||
# This is the bug candidate 1 fixes: DeviceController holds the same
|
||||
# LocalSettings object, so a save here must be visible to it without
|
||||
# a reload — no throwaway copy, no stale read until restart.
|
||||
model.chamberId = "7"
|
||||
model.waferReadTimeout = 45000
|
||||
model.saveSettings()
|
||||
|
||||
assert shared_settings.chamber_id == "7"
|
||||
assert shared_settings.wafer_read_timeout == 45000
|
||||
|
||||
|
||||
def test_save_persists_to_disk_and_reload_round_trips(tmp_path, qapp, shared_settings):
|
||||
model = LocalSettingsModel(shared_settings, tmp_path)
|
||||
model.chamberId = "9"
|
||||
model.waferRetries = 3
|
||||
model.saveSettings()
|
||||
|
||||
reloaded_settings = LocalSettings.read_settings(str(tmp_path))
|
||||
other_model = LocalSettingsModel(reloaded_settings, tmp_path)
|
||||
other_model.loadSettings()
|
||||
|
||||
assert other_model.chamberId == "9"
|
||||
assert other_model.waferRetries == 3
|
||||
|
||||
|
||||
def test_revert_restores_last_saved_values(model):
|
||||
model.chamberId = "2"
|
||||
model.saveSettings()
|
||||
|
||||
model.chamberId = "unsaved-edit"
|
||||
assert model.isDirty is True
|
||||
|
||||
model.revertChanges()
|
||||
assert model.chamberId == "2"
|
||||
assert model.isDirty is False
|
||||
|
||||
|
||||
def test_invalid_chamber_id_blocks_save(model, shared_settings):
|
||||
shared_settings.chamber_id = "before"
|
||||
model.chamberId = ""
|
||||
model.saveSettings()
|
||||
|
||||
assert model.saveStatus.startswith("error:")
|
||||
assert shared_settings.chamber_id == "before" # unsaved, shared instance untouched
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for src/pygui/backend/rbf_heatmap.py."""
|
||||
import numpy as np
|
||||
from pygui.backend.visualization.rbf_heatmap import interpolate_field, BACKEND
|
||||
|
||||
from pygui.backend.visualization.rbf_heatmap import BACKEND, interpolate_field
|
||||
|
||||
|
||||
def test_field_shape_and_interpolates_at_sites():
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Tests for src/pygui/backend/sensor_editor.py."""
|
||||
import pytest
|
||||
from pygui.backend.models.sensor_editor import SensorEditor
|
||||
|
||||
|
||||
|
||||
+182
-123
@@ -1,6 +1,12 @@
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pygui.backend.controllers.session_controller as session_controller_module
|
||||
from pygui.backend.controllers.session_controller import SessionController
|
||||
from pygui.backend.models.frame import Frame
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller():
|
||||
@@ -9,7 +15,7 @@ def controller():
|
||||
def test_initial_default(controller):
|
||||
assert controller.mode == "review"
|
||||
assert controller.state == "idle"
|
||||
assert controller.recording == False
|
||||
assert not controller.recording
|
||||
|
||||
def test_playback_flow(controller):
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
@@ -47,44 +53,56 @@ def test_pause_stop_timer(controller):
|
||||
controller.stop()
|
||||
assert controller.frameIndex == 0
|
||||
|
||||
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
|
||||
"""A recording with a valid header but zero data rows (e.g. Record was
|
||||
started/stopped without any live frames arriving) must surface a clear
|
||||
loadFileError instead of silently doing nothing."""
|
||||
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
|
||||
empty_recording.write_text(
|
||||
"Wafer ID=A00001\n"
|
||||
"Acquisition Date=07/06/2026\n"
|
||||
"Label,1,2\n"
|
||||
"X (mm),0.0,10.0\n"
|
||||
"Y (mm),10.0,-10.0\n"
|
||||
"data\n"
|
||||
)
|
||||
def test_graph_data_empty_before_load(controller):
|
||||
assert controller.graphSensorNames == ""
|
||||
assert controller.graphSeriesJson == "[]"
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.loadFileError.connect(mock_slot)
|
||||
controller.loadFile(str(empty_recording))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
assert "no recorded frames" in mock_slot.call_args[0][0]
|
||||
assert controller.loadedFile == ""
|
||||
|
||||
def test_load_file_switches_to_review_mode(controller):
|
||||
"""Loading a file for playback must always land in review mode, even if
|
||||
the controller was left in live mode from a previous session — otherwise
|
||||
a successful load can be invisible behind a stale Live toggle."""
|
||||
controller._mode = "live"
|
||||
def test_graph_data_after_load(controller):
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
assert controller.mode == "review"
|
||||
assert controller.graphSensorNames == "Sensor 1,Sensor 2,Sensor 3"
|
||||
series = json.loads(controller.graphSeriesJson)
|
||||
assert series == [
|
||||
[149.0, 149.2, 149.1],
|
||||
[148.5, 148.4, 148.6],
|
||||
[150.6, 150.7, 150.5],
|
||||
]
|
||||
|
||||
def test_graph_data_cleared_after_unload(controller):
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
controller.unloadFile()
|
||||
assert controller.graphSensorNames == ""
|
||||
assert controller.graphSeriesJson == "[]"
|
||||
|
||||
def test_leaving_live_mode_blanks_wafer_map(controller):
|
||||
"""Stopping a live session (tab switch → setMode) must not leave stale
|
||||
sensor values on the wafer map — it should read as blank."""
|
||||
frame_signal = MagicMock()
|
||||
controller.frameUpdated.connect(frame_signal)
|
||||
|
||||
controller.setMode("live")
|
||||
controller._on_live_frame(Frame(seq=1, time=0.0, values=[100.0] * 22))
|
||||
assert controller.sensorValues, "sanity: live frame should populate the map"
|
||||
|
||||
controller.setMode("review")
|
||||
assert controller.sensorValues == []
|
||||
assert controller.stats == {}
|
||||
assert frame_signal.called, "QML needs frameUpdated to repaint blank"
|
||||
|
||||
|
||||
def test_leaving_live_mode_stops_stream(controller):
|
||||
controller.setMode("live")
|
||||
controller.setMode("review")
|
||||
assert controller._reader is None
|
||||
assert not controller._repaint_timer.isActive()
|
||||
|
||||
|
||||
def test_compare_files_integration(controller):
|
||||
# Mock the comparisonResult signal
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
|
||||
# Use sample_stream.csv for both arguments to test identical files compare
|
||||
csv_path = "tests/fixtures/sample_stream.csv"
|
||||
controller.compareFiles(csv_path, csv_path)
|
||||
# Two runs with the same first 3 frames but run B has 2 extra trailing frames.
|
||||
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
|
||||
|
||||
# Verify that the comparison signal is emitted with a success dict
|
||||
assert mock_slot.call_count == 1
|
||||
@@ -93,97 +111,24 @@ def test_compare_files_integration(controller):
|
||||
assert "distance" in args
|
||||
assert "series_a" in args
|
||||
assert "series_b" in args
|
||||
assert args["frame_offset"] == 0
|
||||
|
||||
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path):
|
||||
"""compareFiles must score the whole run, not just an initial 100-frame window.
|
||||
|
||||
Regression test: max_frames used to be hard-capped at 100, so a >100-frame
|
||||
run's soak/cool phases were silently excluded from the DTW comparison.
|
||||
"""
|
||||
num_frames = 150
|
||||
csv_path = tmp_path / "long_run.csv"
|
||||
lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"]
|
||||
for i in range(num_frames):
|
||||
t = i * 0.5
|
||||
lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}")
|
||||
csv_path.write_text("\n".join(lines) + "\n")
|
||||
# time_a/time_b expose per-frame timestamps in seconds for the mismatched run lengths.
|
||||
assert args["time_a"] == [0.0, 0.5, 1.0]
|
||||
assert args["time_b"] == [0.0, 0.5, 1.0, 1.5, 2.0]
|
||||
assert args["frame_count_a"] == 3
|
||||
assert args["frame_count_b"] == 5
|
||||
|
||||
def test_compare_files_same_file_is_gated(controller):
|
||||
"""Comparing a run against itself should be rejected, not silently DTW'd."""
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(csv_path), str(csv_path))
|
||||
|
||||
csv_path = "tests/fixtures/sample_stream.csv"
|
||||
controller.compareFiles(csv_path, csv_path)
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
assert len(result["series_a"]) == num_frames
|
||||
assert len(result["series_b"]) == num_frames
|
||||
|
||||
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
|
||||
"""compareFiles must refuse to compare two different wafer families.
|
||||
|
||||
Otherwise the overlap view silently truncates to min(sensor_count) and
|
||||
pairs up unrelated physical sensors between the two wafer types.
|
||||
"""
|
||||
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
file_a = tmp_path / "A00055-20250326.csv"
|
||||
file_b = tmp_path / "X00108-20250326.csv"
|
||||
file_a.write_text("\n".join(header) + "\n")
|
||||
file_b.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(file_a), str(file_b))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
assert "A" in result["error"] and "X" in result["error"]
|
||||
|
||||
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
|
||||
"""A live_-prefixed recording must not be compared against a read-memory
|
||||
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
|
||||
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
|
||||
recording-vs-read-mem error.
|
||||
"""
|
||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
recording = tmp_path / "live_P0001_20260706_143022.csv"
|
||||
read_mem = tmp_path / "P0001-20260706_143500.csv"
|
||||
recording.write_text("\n".join(header) + "\n")
|
||||
read_mem.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(recording), str(read_mem))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Cannot compare a recording file with a read-memory file"
|
||||
|
||||
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
|
||||
"""Two live recordings of the same wafer family should compare fine —
|
||||
the "live_" prefix must not be mistaken for the wafer type letter."""
|
||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
file_a = tmp_path / "live_P0001_20260706_143022.csv"
|
||||
file_b = tmp_path / "live_P0002_20260706_150000.csv"
|
||||
file_a.write_text("\n".join(header) + "\n")
|
||||
file_b.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(file_a), str(file_b))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
args = mock_slot.call_args[0][0]
|
||||
assert args["success"] is False
|
||||
assert "error" in args
|
||||
|
||||
def test_comparison_result_reaches_qml(qapp, controller):
|
||||
"""The result dict must arrive in QML as a real JS object with fields.
|
||||
@@ -213,20 +158,20 @@ Item {
|
||||
assert engine.rootObjects(), "probe QML failed to load"
|
||||
root = engine.rootObjects()[0]
|
||||
|
||||
csv_path = "tests/fixtures/sample_stream.csv"
|
||||
controller.compareFiles(csv_path, csv_path)
|
||||
controller.compareFiles("tests/fixtures/sample_stream.csv", "tests/fixtures/sample_stream_b.csv")
|
||||
qapp.processEvents()
|
||||
|
||||
assert root.property("gotSuccess") is True
|
||||
assert root.property("gotDistance") >= 0
|
||||
|
||||
|
||||
def test_recording_toggle(controller, tmp_path):
|
||||
csv_path = str(tmp_path / "rec" / "live_test.csv")
|
||||
controller.startRecording(csv_path, "SN123")
|
||||
assert controller.recording == True
|
||||
assert controller.recording
|
||||
|
||||
controller.stopRecording()
|
||||
assert controller.recording == False
|
||||
assert not controller.recording
|
||||
|
||||
# Header written with wafer serial
|
||||
content = open(csv_path).read()
|
||||
@@ -252,4 +197,118 @@ def test_split_data_integration(controller):
|
||||
assert mock_slot.call_count == 1
|
||||
args = mock_slot.call_args[0][0]
|
||||
assert args["success"] is True
|
||||
assert "segments" in args
|
||||
assert "segments" in args
|
||||
|
||||
|
||||
|
||||
def test_export_segment_after_split(qapp, controller, tmp_path):
|
||||
import shutil
|
||||
|
||||
src = tmp_path / "sample.csv"
|
||||
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
|
||||
|
||||
controller.splitData(str(src), 149.0)
|
||||
|
||||
exported = MagicMock()
|
||||
controller.segmentExported.connect(exported)
|
||||
controller.exportSegment(0)
|
||||
|
||||
assert exported.call_count == 1
|
||||
result = exported.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
out = result["path"]
|
||||
assert out.startswith(str(tmp_path))
|
||||
from pygui.backend.data.data_records import read_data_records
|
||||
assert len(read_data_records(out)) > 0
|
||||
|
||||
|
||||
def test_export_segment_without_split(qapp, controller):
|
||||
exported = MagicMock()
|
||||
controller.segmentExported.connect(exported)
|
||||
controller.exportSegment(0)
|
||||
result = exported.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_export_segment_bad_index(qapp, controller, tmp_path):
|
||||
import shutil
|
||||
src = tmp_path / "sample.csv"
|
||||
shutil.copyfile("tests/fixtures/sample_stream.csv", src)
|
||||
controller.splitData(str(src), 149.0)
|
||||
|
||||
exported = MagicMock()
|
||||
controller.segmentExported.connect(exported)
|
||||
controller.exportSegment(99)
|
||||
assert exported.call_args[0][0]["success"] is False
|
||||
|
||||
|
||||
def test_reset_live_trend_sets_start_time_and_emits_reset(controller, monkeypatch):
|
||||
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 100.0)
|
||||
|
||||
reset_seen = MagicMock()
|
||||
controller.trendReset.connect(reset_seen)
|
||||
controller._reset_live_trend()
|
||||
|
||||
assert controller._stream_start_time == 100.0
|
||||
assert reset_seen.called
|
||||
|
||||
|
||||
def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkeypatch):
|
||||
controller._stream_start_time = 100.0
|
||||
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 102.5)
|
||||
|
||||
delta_seen = MagicMock()
|
||||
controller.trendDelta.connect(delta_seen)
|
||||
|
||||
frame = Frame(seq=1, time=102.5, values=[10.0, 20.0, 30.0])
|
||||
controller._on_live_frame(frame)
|
||||
|
||||
payload = json.loads(delta_seen.call_args[0][0])
|
||||
assert len(payload) == 1
|
||||
elapsed, avg = payload[0]
|
||||
assert elapsed == pytest.approx(2.5)
|
||||
assert avg == pytest.approx(20.0)
|
||||
|
||||
|
||||
# ---- edge-center stats exposure (ADR-0002) ----
|
||||
|
||||
def _write_f_family_csv(path):
|
||||
"""22-sensor F-family stream: pair (0,18) has delta 1.0, all others 0."""
|
||||
n = 22
|
||||
values = [149.0] * n
|
||||
values[0] = 150.0
|
||||
lines = [
|
||||
"Wafer ID=F12",
|
||||
"Acquisition Date=03/26/2025",
|
||||
"Label," + ",".join(str(i + 1) for i in range(n)),
|
||||
"X (mm)," + ",".join(str(i) for i in range(n)),
|
||||
"Y (mm)," + ",".join(str(i) for i in range(n)),
|
||||
"data",
|
||||
"0.0," + ",".join(f"{v:.1f}" for v in values),
|
||||
]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_stats_include_edge_center_for_known_family(controller, tmp_path):
|
||||
csv = tmp_path / "f_run.csv"
|
||||
_write_f_family_csv(csv)
|
||||
controller.loadFile(str(csv))
|
||||
s = controller.stats
|
||||
assert s["ecMaxDelta"] == pytest.approx(1.0)
|
||||
assert s["ecMaxEdgeIndex"] == 1 and s["ecMaxCenterIndex"] == 19 # 1-origin
|
||||
assert s["ecMaxEdgeValue"] == pytest.approx(150.0)
|
||||
assert s["ecMaxCenterValue"] == pytest.approx(149.0)
|
||||
assert s["ecMinDelta"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_stats_edge_center_skips_replaced_sensor(controller, tmp_path):
|
||||
csv = tmp_path / "f_run.csv"
|
||||
_write_f_family_csv(csv)
|
||||
controller.loadFile(str(csv))
|
||||
controller.replaceSensor(0, 149.0) # replaced: pair (0,18) must be skipped
|
||||
assert controller.stats["ecMaxDelta"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_stats_edge_center_absent_for_unknown_or_short_family(controller):
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv") # A12, 3 sensors
|
||||
assert "ecMaxDelta" not in controller.stats
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.backend.models.threshold_classifier import ThresholdConfig, BAND_HIGH, BAND_IN, BAND_LOW
|
||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
||||
|
||||
from pygui.backend.models.session_model import SessionModel
|
||||
from pygui.backend.models.threshold_classifier import BAND_HIGH, BAND_IN, BAND_LOW, ThresholdConfig
|
||||
|
||||
|
||||
def test_manual_banding_and_stats():
|
||||
@@ -12,17 +11,16 @@ def test_manual_banding_and_stats():
|
||||
assert update.stats.avg == 149.0
|
||||
assert (update.target, update.margin) == (149.0, 1.0)
|
||||
assert update.state in ("idle", "ramp", "set")
|
||||
|
||||
|
||||
def test_auto_banding_uses_mean_and_sigma():
|
||||
model = SessionModel(ThresholdConfig(auto=True))
|
||||
# Tight cluster around 149 -> small σ -> the 200.0 outlier reads HIGH
|
||||
update = model.process(Frame(seq=0, time=0.0, values=[149.0, 149.0, 149.0, 200.0]))
|
||||
assert update.bands[3] == BAND_HIGH
|
||||
assert update.target == update.stats.avg
|
||||
|
||||
|
||||
def test_set_thresholds_reband_on_next_frame():
|
||||
m = SessionModel(ThresholdConfig(set_point=149.0, margin=1.0, auto=False))
|
||||
m.set_thresholds(ThresholdConfig(set_point=149.0, margin=5.0, auto=False))
|
||||
upd = m.process(Frame(seq=0, time=0.0, values=[151.0]))
|
||||
assert upd.bands == [BAND_IN]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from pygui.backend.models.stability_detector import (
|
||||
StabilityDetector, STATE_IDLE, STATE_RAMP, STATE_SET,
|
||||
STATE_IDLE,
|
||||
STATE_RAMP,
|
||||
STATE_SET,
|
||||
StabilityDetector,
|
||||
)
|
||||
|
||||
|
||||
SET_POINT = 149.0
|
||||
|
||||
def make():
|
||||
@@ -11,16 +13,16 @@ def make():
|
||||
def test_idle_when_cold():
|
||||
d = make()
|
||||
assert d.update(avg=25.0, time=0.0, set_point=SET_POINT) == STATE_IDLE
|
||||
|
||||
|
||||
def test_ramp_while_far_from_setpoint():
|
||||
d = make()
|
||||
d.update(avg=100.0, time=0.0, set_point=SET_POINT)
|
||||
|
||||
|
||||
def test_ramp_until_settle_time_elapses():
|
||||
d = make()
|
||||
assert d.update(avg=149.2, time=0.0, set_point=SET_POINT) == STATE_RAMP
|
||||
assert d.update(avg=148.9, time=0.0, set_point=SET_POINT) == STATE_RAMP
|
||||
|
||||
|
||||
def test_set_after_holding_near_setpoint():
|
||||
d = make()
|
||||
d.update(avg=149.2, time=0.0, set_point=SET_POINT)
|
||||
@@ -32,4 +34,4 @@ def test_back_to_ramp_on_disturbance():
|
||||
d = make()
|
||||
for t in (0.0, 1.0, 2.5):
|
||||
d.update(149.0, t, set_point=SET_POINT)
|
||||
assert d.update(avg=160.0, time=3.0, set_point=SET_POINT) == STATE_RAMP
|
||||
assert d.update(avg=160.0, time=3.0, set_point=SET_POINT) == STATE_RAMP
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import time
|
||||
import time
|
||||
|
||||
from pygui.backend.models.frame import Frame
|
||||
from pygui.serialcomm.stream_reader import StreamReader
|
||||
|
||||
|
||||
class FakeTransport:
|
||||
"""Yield canned data then blocks (returns b'')."""
|
||||
def __init__(self, lines):
|
||||
@@ -28,7 +30,7 @@ class FakeTransport:
|
||||
self._pos = idx + 1
|
||||
return res
|
||||
def close(self): self.closed = True
|
||||
|
||||
|
||||
def parse_line(raw: str, seq: int) -> Frame:
|
||||
parts = [float(x) for x in raw.split(",")]
|
||||
return Frame(seq = seq, time = parts[0], values = parts[1:])
|
||||
@@ -38,10 +40,10 @@ def test_reads_frames_and_counts_errors():
|
||||
transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"])
|
||||
r = StreamReader(transport, parse_line,
|
||||
on_frame = got.append, on_error = lambda e: errors.append(e))
|
||||
|
||||
|
||||
r.start()
|
||||
time.sleep(0.1)
|
||||
r.stop()
|
||||
assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]]
|
||||
assert r.error_count == 1
|
||||
assert transport.closed
|
||||
assert transport.closed
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import math
|
||||
|
||||
from pygui.backend.models.threshold_classifier import (
|
||||
ThresholdConfig, classify, classify_all, resolve_bounds,
|
||||
BAND_IN, BAND_HIGH, BAND_LOW
|
||||
BAND_HIGH,
|
||||
BAND_IN,
|
||||
BAND_LOW,
|
||||
ThresholdConfig,
|
||||
classify,
|
||||
classify_all,
|
||||
resolve_bounds,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,19 +16,18 @@ def test_classify_about_target_margin():
|
||||
assert classify(149.9, target=149.0, margin=1.0) ==BAND_IN # within
|
||||
assert classify(150.5, target=149.0, margin=1.0) ==BAND_HIGH # 1.5 above
|
||||
assert classify(147.5, target=149.0, margin=1.0) ==BAND_LOW # 1.5 below
|
||||
|
||||
|
||||
|
||||
|
||||
def test_manual_bounds_use_set_point_and_margin():
|
||||
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
|
||||
assert resolve_bounds([200.0, 0.0], cfg) == (149.0, 1.0)
|
||||
|
||||
|
||||
def test_auto_bounds_use_mean_and_sigma():
|
||||
cfg = ThresholdConfig(auto=True)
|
||||
target, margin = resolve_bounds([148.0, 150.0, 149.0], cfg)
|
||||
assert target == 149.0
|
||||
assert margin == math.sqrt(2 / 3)
|
||||
|
||||
assert margin == math.sqrt(2 / 3)
|
||||
|
||||
def test_classify_all_manual():
|
||||
cfg = ThresholdConfig(set_point=149.0, margin=1.0, auto=False)
|
||||
assert classify_all([149.0, 151.0, 147.0], cfg) == [BAND_IN, BAND_HIGH, BAND_LOW]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for src/pygui/backend/visualization/trend_chart_item.py."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
|
||||
# descriptors work properly.
|
||||
pytestmark = pytest.mark.usefixtures("qapp")
|
||||
|
||||
|
||||
def test_no_data_initially():
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
|
||||
item = TrendChartItem()
|
||||
assert item.hasData is False
|
||||
|
||||
|
||||
def test_append_delta_adds_point_and_sets_has_data():
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
|
||||
item = TrendChartItem()
|
||||
item.appendDelta(json.dumps([[1.5, 42.0]]))
|
||||
|
||||
assert item.hasData is True
|
||||
assert item._points == [(1.5, 42.0)]
|
||||
|
||||
|
||||
def test_append_delta_accumulates_across_calls():
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
|
||||
item = TrendChartItem()
|
||||
item.appendDelta(json.dumps([[0.0, 10.0]]))
|
||||
item.appendDelta(json.dumps([[1.0, 20.0]]))
|
||||
|
||||
assert item._points == [(0.0, 10.0), (1.0, 20.0)]
|
||||
|
||||
|
||||
def test_append_delta_prunes_points_older_than_60s_window():
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
|
||||
item = TrendChartItem()
|
||||
item.appendDelta(json.dumps([[0.0, 10.0]]))
|
||||
item.appendDelta(json.dumps([[70.0, 20.0]]))
|
||||
|
||||
assert item._points == [(70.0, 20.0)]
|
||||
|
||||
|
||||
def test_append_delta_ignores_malformed_json():
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
|
||||
item = TrendChartItem()
|
||||
item.appendDelta("not json")
|
||||
|
||||
assert item._points == []
|
||||
assert item.hasData is False
|
||||
|
||||
|
||||
def test_append_delta_skips_repaint_when_no_points_added():
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
|
||||
item = TrendChartItem()
|
||||
item.appendDelta(json.dumps([[1.0, 10.0]]))
|
||||
item.update = MagicMock()
|
||||
|
||||
item.appendDelta("not json")
|
||||
|
||||
assert item.update.called is False
|
||||
|
||||
|
||||
def test_clear_trend_resets_points_and_has_data():
|
||||
from pygui.backend.visualization.trend_chart_item import TrendChartItem
|
||||
|
||||
item = TrendChartItem()
|
||||
item.appendDelta(json.dumps([[1.0, 10.0]]))
|
||||
assert item.hasData is True
|
||||
|
||||
item.clearTrend()
|
||||
|
||||
assert item._points == []
|
||||
assert item.hasData is False
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
||||
import pytest
|
||||
from pygui.backend.wafer.wafer_layouts import load_layout, available_families
|
||||
|
||||
from pygui.backend.wafer.wafer_layouts import (
|
||||
available_families,
|
||||
ec_pairs_for_wafer_id,
|
||||
load_layout,
|
||||
)
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
|
||||
|
||||
@@ -19,3 +24,27 @@ def test_loads_sensors_in_mm():
|
||||
def test_unknown_family_raises():
|
||||
with pytest.raises(KeyError):
|
||||
load_layout("nope")
|
||||
|
||||
|
||||
# ---- edge-center pair tables (ADR-0002) ----
|
||||
|
||||
def test_ec_pairs_aep_match_csharp_table():
|
||||
pairs = ec_pairs_for_wafer_id("A00123")
|
||||
assert pairs[0] == (0, 40)
|
||||
assert pairs[-1] == (15, 47)
|
||||
assert len(pairs) == 16
|
||||
assert ec_pairs_for_wafer_id("E1") == pairs
|
||||
assert ec_pairs_for_wafer_id("P1") == pairs
|
||||
|
||||
|
||||
def test_ec_pairs_per_family_classes():
|
||||
assert len(ec_pairs_for_wafer_id("B00108")) == 12
|
||||
assert ec_pairs_for_wafer_id("C1") == ec_pairs_for_wafer_id("D1")
|
||||
assert ec_pairs_for_wafer_id("F1")[0] == (0, 18)
|
||||
assert ec_pairs_for_wafer_id("X1")[0] == (5, 76)
|
||||
assert ec_pairs_for_wafer_id("Z1")[0] == (49, 0)
|
||||
|
||||
|
||||
def test_ec_pairs_unknown_family_empty():
|
||||
assert ec_pairs_for_wafer_id("Q999") == ()
|
||||
assert ec_pairs_for_wafer_id("") == ()
|
||||
|
||||
+239
-33
@@ -1,28 +1,13 @@
|
||||
"""Tests for src/pygui/backend/visualization/wafer_map_item.py."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
|
||||
# descriptors work properly.
|
||||
pytestmark = pytest.mark.usefixtures("qapp")
|
||||
|
||||
|
||||
def test_export_image_valid_path():
|
||||
"""export_image saves a PNG when grabToImage succeeds."""
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
|
||||
mock_image = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.image.return_value = mock_image
|
||||
item.grabToImage = MagicMock(return_value=mock_result)
|
||||
|
||||
assert item.export_image("/tmp/test_wafer.png") is True
|
||||
|
||||
|
||||
def test_export_image_empty_path():
|
||||
"""export_image returns False for empty/None path."""
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
@@ -32,29 +17,23 @@ def test_export_image_empty_path():
|
||||
assert item.export_image(None) is False
|
||||
|
||||
|
||||
def test_export_image_grab_fails():
|
||||
"""export_image returns False when grabToImage raises."""
|
||||
def test_export_image_zero_size_item_fails():
|
||||
"""export_image returns False when the item has no geometry to render."""
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed"))
|
||||
|
||||
assert item.export_image("/tmp/test_wafer.png") is False
|
||||
|
||||
|
||||
def test_export_image_save_fails():
|
||||
"""export_image returns False when image.save raises."""
|
||||
def test_export_image_unwritable_path_fails(tmp_path):
|
||||
"""export_image returns False when the target directory doesn't exist."""
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
mock_image = MagicMock()
|
||||
mock_image.save.side_effect = IOError("disk full")
|
||||
mock_result = MagicMock()
|
||||
mock_result.image.return_value = mock_image
|
||||
|
||||
item = WaferMapItem()
|
||||
item.grabToImage = MagicMock(return_value=mock_result)
|
||||
|
||||
assert item.export_image("/tmp/test_wafer.png") is False
|
||||
item.setWidth(100)
|
||||
item.setHeight(100)
|
||||
item.values = [10.0, 20.0, 30.0]
|
||||
assert item.export_image(str(tmp_path / "no_such_dir" / "wafer.png")) is False
|
||||
|
||||
|
||||
def test_thickness_properties():
|
||||
@@ -68,16 +47,89 @@ def test_thickness_properties():
|
||||
|
||||
|
||||
def test_thickness_setter_updates_data():
|
||||
"""Setting thicknessData updates _thickness_data."""
|
||||
"""Setting thicknessData stores [x, y, t2] triples."""
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
item._rebuild_thickness = MagicMock()
|
||||
|
||||
item.thicknessData = [1.0, 2.0, 3.0]
|
||||
item.thicknessData = [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||||
|
||||
assert item._thickness_data == [1.0, 2.0, 3.0]
|
||||
assert item._thickness_data == [[0.0, 10.0, 1.5], [5.0, -5.0, 2.0]]
|
||||
assert item._rebuild_thickness.called
|
||||
assert item.hasThickness is True
|
||||
|
||||
|
||||
THICKNESS_HEADER = "Lot Start Time,RC,Site#,Slot#,T2,NGOF,Wafer X,Wafer Y\n"
|
||||
|
||||
|
||||
def _write_csv(tmp_path, body, header=THICKNESS_HEADER):
|
||||
p = tmp_path / "thickness.csv"
|
||||
p.write_text(header + body)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_parse_thickness_csv_valid(tmp_path):
|
||||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||
|
||||
path = _write_csv(tmp_path,
|
||||
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||||
"t,1,2,7,2.5,0,-30.0,40.0\n")
|
||||
points, err = parse_thickness_csv(path)
|
||||
assert err == ""
|
||||
assert points == [[10.0, -20.0, 1.5], [-30.0, 40.0, 2.5]]
|
||||
|
||||
|
||||
def test_parse_thickness_csv_first_slot_only(tmp_path):
|
||||
"""Rows after the Slot# changes belong to another wafer and are skipped."""
|
||||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||
|
||||
path = _write_csv(tmp_path,
|
||||
"t,1,1,7,1.5,0,10.0,-20.0\n"
|
||||
"t,1,1,8,9.9,0,0.0,0.0\n")
|
||||
points, err = parse_thickness_csv(path)
|
||||
assert err == ""
|
||||
assert points == [[10.0, -20.0, 1.5]]
|
||||
|
||||
|
||||
def test_parse_thickness_csv_bad_header(tmp_path):
|
||||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||
|
||||
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n", header="A,B,C\n")
|
||||
points, err = parse_thickness_csv(path)
|
||||
assert points == []
|
||||
assert "header" in err
|
||||
|
||||
|
||||
def test_parse_thickness_csv_malformed_value(tmp_path):
|
||||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||
|
||||
path = _write_csv(tmp_path, "t,1,1,7,oops,0,10.0,-20.0\n")
|
||||
points, err = parse_thickness_csv(path)
|
||||
assert points == []
|
||||
assert "Malformed" in err
|
||||
|
||||
|
||||
def test_parse_thickness_csv_missing_file():
|
||||
from pygui.backend.visualization.wafer_map_item import parse_thickness_csv
|
||||
|
||||
points, err = parse_thickness_csv("/no/such/file.csv")
|
||||
assert points == []
|
||||
assert "Unable to read" in err
|
||||
|
||||
|
||||
def test_load_thickness_slot(tmp_path):
|
||||
"""loadThickness feeds parsed points into thicknessData."""
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
path = _write_csv(tmp_path, "t,1,1,7,1.5,0,10.0,-20.0\n")
|
||||
assert item.loadThickness(path) == ""
|
||||
assert item.hasThickness is True
|
||||
|
||||
assert item.loadThickness("/no/such/file.csv") != ""
|
||||
# Failed load keeps the previously loaded data.
|
||||
assert item.hasThickness is True
|
||||
|
||||
|
||||
def test_show_thickness_setter():
|
||||
@@ -89,3 +141,157 @@ def test_show_thickness_setter():
|
||||
|
||||
item.showThickness = True
|
||||
assert item._show_thickness is True
|
||||
|
||||
|
||||
def test_export_image_writes_real_png_headless(tmp_path):
|
||||
"""export_image must produce an actual decodable image file without a
|
||||
QQuickWindow — the button was silently failing because grabToImage()
|
||||
resolves asynchronously and never completes headless/immediately."""
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
item.setWidth(200)
|
||||
item.setHeight(200)
|
||||
item.values = [10.0, 20.0, 30.0]
|
||||
out = tmp_path / "wafer.png"
|
||||
|
||||
assert item.export_image(str(out)) is True
|
||||
assert out.exists()
|
||||
loaded = QImage(str(out))
|
||||
assert not loaded.isNull()
|
||||
assert loaded.width() == 200
|
||||
|
||||
|
||||
def test_export_image_includes_metrics_footer(tmp_path):
|
||||
"""With sensor values loaded, the export gains a footer strip below the
|
||||
map carrying the metric readouts (sensors / min / max / avg)."""
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
item.setWidth(200)
|
||||
item.setHeight(200)
|
||||
item.values = [10.0, 20.0, 30.0]
|
||||
out = tmp_path / "wafer.png"
|
||||
|
||||
assert item.export_image(str(out)) is True
|
||||
loaded = QImage(str(out))
|
||||
assert loaded.height() > 200 # footer strip appended
|
||||
# footer is painted opaque (dark bg), not transparent
|
||||
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
|
||||
|
||||
|
||||
def test_export_image_extra_line_grows_footer(tmp_path):
|
||||
"""Live export passes an extra metrics line; the footer gains a second
|
||||
row for it. Review export (no extra) keeps the single-row footer."""
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
item.setWidth(200)
|
||||
item.setHeight(200)
|
||||
item.values = [10.0, 20.0, 30.0]
|
||||
|
||||
review = tmp_path / "review.png"
|
||||
live = tmp_path / "live.png"
|
||||
assert item.export_image(str(review)) is True
|
||||
assert item.export_image(str(live), "Frames: 42 Errors: 0") is True
|
||||
|
||||
h_review = QImage(str(review)).height()
|
||||
h_live = QImage(str(live)).height()
|
||||
assert h_review > 200 # readout footer present
|
||||
assert h_live > h_review # extra live row appended
|
||||
|
||||
|
||||
def test_export_image_highlights_hottest_and_coldest(tmp_path):
|
||||
"""The exported map rings the hottest sensor in the high color and the
|
||||
coldest in the low color. With no bands set every marker fill is the
|
||||
in-range green, so any high/low-colored pixels must come from the rings."""
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
item.setWidth(200)
|
||||
item.setHeight(200)
|
||||
item.sensors = [
|
||||
{"label": "1", "x": -50.0, "y": 0.0},
|
||||
{"label": "2", "x": 0.0, "y": 0.0},
|
||||
{"label": "3", "x": 50.0, "y": 0.0},
|
||||
]
|
||||
item.values = [10.0, 20.0, 30.0]
|
||||
out = tmp_path / "wafer.png"
|
||||
assert item.export_image(str(out)) is True
|
||||
|
||||
img = QImage(str(out))
|
||||
found = {img.pixelColor(x, y).name()
|
||||
for y in range(img.height()) for x in range(img.width())}
|
||||
assert "#ef4444" in found # hottest ring (sensorHigh)
|
||||
assert "#5b9df5" in found # coldest ring (sensorLow)
|
||||
|
||||
|
||||
def test_show_extremes_toggle_gates_peak_rings(tmp_path):
|
||||
"""showExtremes (Display setting) hides the hot/cold rings when off.
|
||||
Same pixel probe as the highlight test: with rings off, no high/low
|
||||
colored pixels should appear anywhere."""
|
||||
from PySide6.QtGui import QImage
|
||||
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
assert item.showExtremes is True # rings on by default
|
||||
|
||||
item.setWidth(200)
|
||||
item.setHeight(200)
|
||||
item.sensors = [
|
||||
{"label": "1", "x": -50.0, "y": 0.0},
|
||||
{"label": "2", "x": 0.0, "y": 0.0},
|
||||
{"label": "3", "x": 50.0, "y": 0.0},
|
||||
]
|
||||
item.values = [10.0, 20.0, 30.0]
|
||||
item.showExtremes = False
|
||||
out = tmp_path / "wafer.png"
|
||||
assert item.export_image(str(out)) is True
|
||||
|
||||
img = QImage(str(out))
|
||||
found = {img.pixelColor(x, y).name()
|
||||
for y in range(img.height()) for x in range(img.width())}
|
||||
assert "#ef4444" not in found
|
||||
assert "#5b9df5" not in found
|
||||
|
||||
|
||||
def test_readout_text_full_stats():
|
||||
"""Export footer readout carries every READOUT stat with sensor indices.
|
||||
|
||||
Worked example: [10, 20, 30] → min 10 (#1), max 30 (#3), diff 20,
|
||||
avg 20, σ = sqrt(200/3) ≈ 8.16, 3σ ≈ 24.49.
|
||||
"""
|
||||
from pygui.backend.visualization.wafer_map_item import readout_text
|
||||
|
||||
assert readout_text([10.0, 20.0, 30.0]) == (
|
||||
"Sensors: 3 Min: 10.00°C (#1) Max: 30.00°C (#3) "
|
||||
"Diff: 20.00 Avg: 20.00 σ: 8.16 3σ: 24.49"
|
||||
)
|
||||
|
||||
|
||||
def test_readout_text_empty():
|
||||
from pygui.backend.visualization.wafer_map_item import readout_text
|
||||
|
||||
assert readout_text([]) == ""
|
||||
|
||||
|
||||
def test_export_image_no_data_fails(tmp_path):
|
||||
"""No file loaded / no stream played -> nothing meaningful to export."""
|
||||
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||
|
||||
item = WaferMapItem()
|
||||
item.setWidth(200)
|
||||
item.setHeight(200)
|
||||
out = tmp_path / "wafer.png"
|
||||
|
||||
assert item.export_image(str(out)) is False
|
||||
assert not out.exists()
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
"_mock_return_value":
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user