Compare commits
17 Commits
bb9b5d709a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3bec9031 | |||
| fed4d9b590 | |||
| 4bb855a940 | |||
| 94f917b116 | |||
| 25fa7507ce | |||
| 034f13b717 | |||
| 69753e35f9 | |||
| 278a48a2e4 | |||
| e2ce8778e0 | |||
| bdc667b2ed | |||
| b6249c2b8e | |||
| 2fe8aef805 | |||
| 4ee139ad8f | |||
| 7b20ffa376 | |||
| ac4448ed44 | |||
| ca158a7946 | |||
| 637b51b999 |
+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/
|
||||
|
||||
@@ -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,9 +64,11 @@ 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
|
||||
@@ -71,7 +78,7 @@ The project uses `uv` for dependency management and tooling, `Ruff` for linting
|
||||
A `Makefile` is provided to simplify common development and verification tasks:
|
||||
|
||||
| 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
|
||||
@@ -187,17 +197,20 @@ 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.
|
||||
- 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).
|
||||
|
||||

|
||||
|
||||
@@ -9,8 +9,8 @@ Popup {
|
||||
modal: true
|
||||
dim: true
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
width: 480
|
||||
height: 440
|
||||
width: 600
|
||||
height: 500
|
||||
anchors.centerIn: Overlay.overlay
|
||||
|
||||
onOpened: licenseModel.refresh()
|
||||
@@ -31,8 +31,8 @@ Popup {
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 24
|
||||
spacing: 16
|
||||
anchors.margins: 28
|
||||
spacing: 20
|
||||
|
||||
// ── Title ──
|
||||
Label {
|
||||
@@ -44,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 " + appVersion
|
||||
font.pixelSize: Theme.fontSm
|
||||
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 ──
|
||||
@@ -78,7 +71,7 @@ Popup {
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
Layout.minimumHeight: 150
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.panelBackground
|
||||
border.color: Theme.cardBorder
|
||||
@@ -86,8 +79,8 @@ Popup {
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
spacing: 2
|
||||
anchors.margins: 14
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
@@ -95,7 +88,8 @@ Popup {
|
||||
Label {
|
||||
text: "LICENSE"
|
||||
color: Theme.panelTitleText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.5
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
@@ -105,18 +99,18 @@ Popup {
|
||||
visible: false
|
||||
text: "Invalid license file"
|
||||
color: Theme.statusWarningColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
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: 90 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 50 }
|
||||
Label { text: "License Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||
spacing: 8
|
||||
Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
|
||||
Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
|
||||
Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
|
||||
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
@@ -133,11 +127,11 @@ Popup {
|
||||
model: licenseModel.licenses
|
||||
delegate: RowLayout {
|
||||
width: ListView.view.width
|
||||
spacing: 4
|
||||
Label { text: modelData.serial; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 100 }
|
||||
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 90 }
|
||||
Label { text: modelData.level; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.preferredWidth: 50 }
|
||||
Label { text: modelData.licDate; font.pixelSize: Theme.fontSm; color: Theme.bodyColor; Layout.fillWidth: true }
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +139,7 @@ Popup {
|
||||
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
|
||||
@@ -157,11 +151,12 @@ Popup {
|
||||
// ── Buttons ──
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Button {
|
||||
text: "Load License"
|
||||
Layout.preferredWidth: 120
|
||||
Layout.preferredHeight: 32
|
||||
Layout.preferredWidth: 140
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: licenseFileDialog.open()
|
||||
|
||||
background: Rectangle {
|
||||
@@ -173,6 +168,8 @@ Popup {
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
@@ -183,7 +180,7 @@ Popup {
|
||||
Button {
|
||||
text: "Close"
|
||||
Layout.preferredWidth: 100
|
||||
Layout.preferredHeight: 32
|
||||
Layout.preferredHeight: 36
|
||||
onClicked: root.close()
|
||||
|
||||
background: Rectangle {
|
||||
@@ -195,6 +192,8 @@ Popup {
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
color: Theme.buttonNeutralText
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
+60
-362
@@ -17,29 +17,20 @@ Rectangle {
|
||||
border.color: Theme.outerFrameBorder
|
||||
border.width: Theme.borderStrong
|
||||
|
||||
// ===== Global ToolTip Styling =====
|
||||
ToolTip.toolTip.background: Rectangle {
|
||||
color: Theme.cardBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
radius: Theme.radiusXs
|
||||
}
|
||||
ToolTip.toolTip.contentItem: Text {
|
||||
text: ToolTip.toolTip.text
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.family: Theme.uiFontFamily
|
||||
}
|
||||
|
||||
// ===== View State =====
|
||||
property int selectedTabIndex: 0
|
||||
onSelectedTabIndexChanged: {
|
||||
if (selectedTabIndex === 2) {
|
||||
file_browser.refreshFiles();
|
||||
streamController.unloadFile()
|
||||
} else if (streamController.mode === "live") {
|
||||
// Leaving the map tab mid-stream: stop and blank the live map.
|
||||
streamController.setMode("review")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
property bool memoryRead: false
|
||||
property bool waferDetected: false
|
||||
// TODO P2.2: add `property bool waferLicensed: false`; set it in
|
||||
@@ -100,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()
|
||||
}
|
||||
}
|
||||
@@ -227,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 {
|
||||
@@ -241,15 +255,6 @@ Rectangle {
|
||||
model: pillModel
|
||||
delegate: Button {
|
||||
id: pillBtn
|
||||
// TODO P3.1: lock MAP pill (index 2) when no license:
|
||||
// `enabled: index !== 2 || licenseModel.licenses.length > 0`
|
||||
// and dim icon/label when disabled. licensesChanged
|
||||
// notify already fires on Load License → unlocks live,
|
||||
// no restart. Rule is "any valid license" (not
|
||||
// per-wafer): Map also replays CSVs with no wafer
|
||||
// attached. Also gate the Map Loader (see TODO at the
|
||||
// StackLayout below) and the auto-switch at
|
||||
// onParsedDataReady. See plan §3.1.
|
||||
checked: root.selectedTabIndex === index
|
||||
flat: true
|
||||
Layout.fillWidth: true
|
||||
@@ -319,6 +324,11 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
AppToolTip {
|
||||
visible: pillBtn.hovered
|
||||
text: "<b>" + model.label + "</b>: " + model.desc
|
||||
}
|
||||
|
||||
onClicked: root.selectedTabIndex = index
|
||||
}
|
||||
}
|
||||
@@ -420,43 +430,6 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
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: "Tabs/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
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: runCompCol
|
||||
anchors.fill: parent
|
||||
@@ -498,8 +471,10 @@ Rectangle {
|
||||
color: Theme.bodyColor
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
ToolTip.visible: resetRunBtn.hovered
|
||||
ToolTip.text: "Reset comparison"
|
||||
AppToolTip {
|
||||
visible: resetRunBtn.hovered
|
||||
text: "Reset comparison"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,11 +537,13 @@ Rectangle {
|
||||
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 === ""
|
||||
ToolTip.visible: (sameFile || masterMissing || gateError !== "") && hovered
|
||||
ToolTip.text: masterMissing
|
||||
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||
: gateError !== "" ? gateError
|
||||
: "Choose two different runs to compare"
|
||||
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()
|
||||
@@ -617,100 +594,9 @@ Rectangle {
|
||||
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||
|
||||
// ── 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: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
onClicked: deviceController.clearSession()
|
||||
}
|
||||
|
||||
RailActionButton {
|
||||
label: "CLEAR LOG"
|
||||
iconSource: "../icons/x.svg"
|
||||
Layout.fillWidth: true
|
||||
onClicked: deviceController.clearActivityLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
StatusActionsPanel {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
|
||||
// ── Map tab: Source file browser ──────────────
|
||||
@@ -728,208 +614,16 @@ Rectangle {
|
||||
}
|
||||
|
||||
// ── BOX 3: Hardware Status Footer ──────────────────────────
|
||||
// Not relevant on the Data tab — Run Comparison takes its place above.
|
||||
Rectangle {
|
||||
ConnectionFooter {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -960,6 +654,10 @@ Rectangle {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
-761
@@ -3,6 +3,7 @@ import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Tabs.components
|
||||
import ISC.Wafer
|
||||
|
||||
// ===== Data Tab (Compare Content Only) =====
|
||||
@@ -56,7 +57,7 @@ Item {
|
||||
if (root.runAWaferType === "" || root.runBWaferType === "")
|
||||
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
|
||||
if (root.runAWaferType !== root.runBWaferType)
|
||||
return "Wafer family mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
||||
return "Wafer Family Mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
||||
return ""
|
||||
}
|
||||
property real warpingDistance: -1
|
||||
@@ -184,8 +185,8 @@ Item {
|
||||
root.compareWaferShape = result.wafer_shape || "round"
|
||||
root.compareWaferSize = result.wafer_size || 300.0
|
||||
root.errorMessage = ""
|
||||
scrubber.value = Math.floor(root.seriesLen / 2)
|
||||
scrubber.forceActiveFocus()
|
||||
chartCard.frameIndex = Math.floor(root.seriesLen / 2)
|
||||
chartCard.focusScrubber()
|
||||
if (root.frameCountA !== root.frameCountB) {
|
||||
var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
|
||||
var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
|
||||
@@ -204,250 +205,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reusable pieces ────────────────────────────────────────────
|
||||
component SectionTitle: Label {
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
|
||||
component PanelBox: Rectangle {
|
||||
color: Theme.sidePanelBackground
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
radius: Theme.sidePanelRadius
|
||||
}
|
||||
|
||||
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
|
||||
// indicator + check icon) instead of the platform-default CheckBox look.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Slider styled to match MapTab's right-rail heatmap slider (circular
|
||||
// accent handle that grows slightly on press).
|
||||
component PanelSlider: 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 } }
|
||||
}
|
||||
}
|
||||
|
||||
// Mini stat box under the alignment scrubber (styled to match DTW ReadoutCard)
|
||||
component ScrubStat: Rectangle {
|
||||
id: scrubStat
|
||||
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: scrubStat.label.toUpperCase()
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.2
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
text: scrubStat.value
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
font.family: Theme.codeFontFamily
|
||||
color: scrubStat.valueColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Editable stat box for scrubber readout (styled to match DTW ReadoutCard)
|
||||
component EditableScrubStat: Rectangle {
|
||||
id: edStat
|
||||
property string label
|
||||
property real value
|
||||
property string unit
|
||||
property string extraText: ""
|
||||
property bool editable: true
|
||||
signal valueEdited(real newValue)
|
||||
|
||||
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)
|
||||
scrubber.forceActiveFocus()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Large labeled value card for the DTW readout panel
|
||||
component ReadoutCard: Rectangle {
|
||||
id: readoutCard
|
||||
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: readoutCard.label
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.2
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
text: readoutCard.value
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
font.family: Theme.codeFontFamily
|
||||
color: readoutCard.valueColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Layout ───────────────────────────────────────────────
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
@@ -506,533 +263,53 @@ Item {
|
||||
spacing: 12
|
||||
|
||||
// Wafer Map Overlap View card
|
||||
PanelBox {
|
||||
OverlapMapCard {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 220
|
||||
|
||||
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: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked
|
||||
sensors: root.compareSensorLayout
|
||||
values: streamController.getSensorDiffAt(scrubReadout.scrubIdx)
|
||||
bands: root.diffBands(overlapMapItem.values)
|
||||
shape: root.compareWaferShape
|
||||
size: root.compareWaferSize
|
||||
target: 0
|
||||
margin: 1.0
|
||||
blend: blendSlider.value / 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: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked
|
||||
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: !enableOverlapCheck.checked ? "Overlap map disabled"
|
||||
: "No sensor map available"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontXs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sensorLayout: root.compareSensorLayout
|
||||
diffValues: streamController.getSensorDiffAt(Math.round(chartCard.frameIndex))
|
||||
diffBands: root.diffBands
|
||||
waferShape: root.compareWaferShape
|
||||
waferSize: root.compareWaferSize
|
||||
overlapEnabled: sidePanel.overlapEnabled
|
||||
blendAmount: sidePanel.blendAmount
|
||||
}
|
||||
|
||||
// Chart card with alignment scrubber
|
||||
PanelBox {
|
||||
ComparisonChartCard {
|
||||
id: chartCard
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 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: root.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: root.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 (root.seriesLen < 2) return
|
||||
var plotW = width - padLeft - padRight
|
||||
var x = padLeft + (scrubber.value / (root.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 (!root.hasSeries) return
|
||||
var all = root.trendDataA.concat(root.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, root.seriesLen)
|
||||
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen)
|
||||
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen)
|
||||
drawScrubMarker(ctx)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root
|
||||
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
|
||||
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
|
||||
}
|
||||
Connections {
|
||||
target: scrubber
|
||||
function onValueChanged() { overlayCanvas.requestPaint() }
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
visible: !root.hasSeries
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: !root.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: root.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
visible: !root.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: root.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, root.seriesLen - 1)
|
||||
stepSize: 1
|
||||
value: 0
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
trendDataA: root.trendDataA
|
||||
trendDataB: root.trendDataB
|
||||
seriesLen: root.seriesLen
|
||||
hasSeries: root.hasSeries
|
||||
comparing: root.comparing
|
||||
}
|
||||
}
|
||||
|
||||
// --- Right Column: run selection, overlap settings, readout ---
|
||||
ColumnLayout {
|
||||
ComparisonSidePanel {
|
||||
id: sidePanel
|
||||
Layout.fillWidth: false
|
||||
Layout.preferredWidth: 280
|
||||
Layout.minimumWidth: 280
|
||||
Layout.maximumWidth: 280
|
||||
Layout.preferredWidth: Theme.rightRailWidth
|
||||
Layout.minimumWidth: Theme.rightRailWidth
|
||||
Layout.maximumWidth: Theme.rightRailWidth
|
||||
Layout.fillHeight: true
|
||||
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" }
|
||||
|
||||
ReadoutCard {
|
||||
label: "DTW ALIGNMENT DISTANCE"
|
||||
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
|
||||
valueColor: root.warpingDistance >= 0
|
||||
? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
|
||||
: Theme.sideMutedText
|
||||
}
|
||||
ReadoutCard {
|
||||
label: "TEMPORAL FRAME OFFSET"
|
||||
value: root.warpingDistance >= 0
|
||||
? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / "
|
||||
+ (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s")
|
||||
: "--"
|
||||
valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
|
||||
}
|
||||
ReadoutCard {
|
||||
label: "MAX SENSOR DEVIATION"
|
||||
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
|
||||
valueColor: root.maxSensorDeviation >= 0
|
||||
? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.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, root.seriesLen - 1) + (Math.max(0, root.seriesLen - 1) <= 1 ? " frame" : " frames")
|
||||
editable: root.hasSeries
|
||||
onValueEdited: (newValue) => {
|
||||
var maxVal = Math.max(0, root.seriesLen - 1)
|
||||
var val = Math.max(0, Math.min(newValue, maxVal))
|
||||
scrubber.value = val
|
||||
}
|
||||
}
|
||||
|
||||
EditableScrubStat {
|
||||
label: "Time"
|
||||
value: scrubReadout.scrubTime
|
||||
unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s"
|
||||
editable: root.hasSeries
|
||||
onValueEdited: (newValue) => {
|
||||
var closestIdx = root.frameForTime(newValue)
|
||||
scrubber.value = closestIdx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(scrubber.value)
|
||||
readonly property bool hasA: scrubIdx < root.trendDataA.length
|
||||
readonly property bool hasB: scrubIdx < root.trendDataB.length
|
||||
readonly property real tempA: hasA ? root.trendDataA[scrubIdx] : 0
|
||||
readonly property real tempB: hasB ? root.trendDataB[scrubIdx] : 0
|
||||
readonly property real scrubTime: {
|
||||
var t = scrubIdx < root.timeDataA.length ? root.timeDataA[scrubIdx]
|
||||
: (scrubIdx < root.timeDataB.length ? root.timeDataB[scrubIdx] : 0)
|
||||
return t
|
||||
}
|
||||
readonly property real maxTime: {
|
||||
var ta = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
|
||||
var tb = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
|
||||
return Math.max(ta, tb)
|
||||
}
|
||||
|
||||
SectionTitle { text: "SCRUBBER READOUT" }
|
||||
|
||||
ScrubStat {
|
||||
label: "Temp (Run A)"
|
||||
value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
|
||||
valueColor: Theme.primaryAccent
|
||||
}
|
||||
ScrubStat {
|
||||
label: "Temp (Run B)"
|
||||
value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
|
||||
valueColor: Theme.themeSkill
|
||||
}
|
||||
ScrubStat {
|
||||
label: "Difference"
|
||||
value: (scrubReadout.hasA && scrubReadout.hasB)
|
||||
? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
warpingDistance: root.warpingDistance
|
||||
frameOffset: root.frameOffset
|
||||
frameOffsetSeconds: root.frameOffsetSeconds
|
||||
maxSensorDeviation: root.maxSensorDeviation
|
||||
seriesLen: root.seriesLen
|
||||
hasSeries: root.hasSeries
|
||||
trendDataA: root.trendDataA
|
||||
trendDataB: root.trendDataB
|
||||
timeDataA: root.timeDataA
|
||||
timeDataB: root.timeDataB
|
||||
frameForTime: root.frameForTime
|
||||
frameIndex: chartCard.frameIndex
|
||||
onFrameIndexRequested: (idx) => chartCard.frameIndex = idx
|
||||
onScrubberFocusRequested: chartCard.focusScrubber()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -122,7 +122,9 @@ 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();
|
||||
}
|
||||
}
|
||||
@@ -132,7 +134,11 @@ ColumnLayout {
|
||||
FolderDialog {
|
||||
id: dirOnlyDialog
|
||||
title: "Choose a folder to save Data"
|
||||
onAccepted: deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||
onAccepted: {
|
||||
var dir = root.cleanFolderUrl(selectedFolder);
|
||||
deviceController.setSaveDataDir(dir);
|
||||
file_browser.setCurrentDirectory(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
||||
|
||||
@@ -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,105 +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: 84
|
||||
implicitHeight: 34
|
||||
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.fontMd
|
||||
font.weight: parent.checked ? Font.Medium : Font.Normal
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
TabButton {
|
||||
id: liveTab
|
||||
text: "Live"
|
||||
enabled: deviceController.connectionStatus === "Connected"
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
implicitWidth: 84
|
||||
implicitHeight: 34
|
||||
// Disabled controls swallow no events at all, so hover for the
|
||||
// "why is this greyed out" tooltip needs its own MouseArea.
|
||||
ToolTip.visible: disabledHover.containsMouse && !enabled
|
||||
ToolTip.text: "Connect a wafer to enable Live mode"
|
||||
ToolTip.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.radiusSm - 1
|
||||
}
|
||||
contentItem: Row {
|
||||
spacing: 5
|
||||
leftPadding: 8
|
||||
Rectangle {
|
||||
id: liveTabDot
|
||||
width: 7; height: 7; radius: 3.5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
||||
|
||||
// Flash while actually streaming live
|
||||
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.pixelSize: Theme.fontMd
|
||||
font.weight: liveTab.checked ? Font.Medium : Font.Normal
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
height: parent.height
|
||||
}
|
||||
}
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
@@ -213,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 ───────────────────────────────────────────
|
||||
@@ -303,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,374 +40,106 @@ ColumnLayout {
|
||||
}
|
||||
}
|
||||
|
||||
// Live-mode stream stats + stop — moved here from the footer transport bar
|
||||
// so that bar collapses to zero height in live mode, freeing vertical
|
||||
// space for the trend chart.
|
||||
Rectangle {
|
||||
StreamControlPanel {
|
||||
Layout.fillWidth: true
|
||||
visible: streamController.mode === "live"
|
||||
implicitHeight: visible ? liveStatsCard.implicitHeight + 24 : 0
|
||||
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: liveStatsCard
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 12
|
||||
}
|
||||
id: readoutCol
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
text: "LIVE STREAM"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 4
|
||||
SectionTitle { text: "READOUT" }
|
||||
|
||||
ReadoutStat {
|
||||
label: "Min Temp"
|
||||
value: s.min !== undefined
|
||||
? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
|
||||
: "—"
|
||||
valueColor: Theme.sensorLow
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: "Received"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: streamController.receivedCount
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Max Temp"
|
||||
value: s.max !== undefined
|
||||
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
|
||||
: "—"
|
||||
valueColor: Theme.sensorHigh
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
ReadoutStat {
|
||||
label: "Diff"
|
||||
value: s.diff !== undefined ? s.diff : "—"
|
||||
valueColor: Theme.diffAccent
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: "Errors"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: streamController.errorCount
|
||||
color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "Average"
|
||||
value: s.avg !== undefined ? s.avg : "—"
|
||||
}
|
||||
|
||||
Button {
|
||||
id: stopStreamBtn
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
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: stopStreamBtn.down ? Theme.transportButtonHover
|
||||
: (stopStreamBtn.hovered ? Theme.transportButtonBg : "transparent")
|
||||
border.width: Theme.borderThin
|
||||
border.color: Theme.sideBorder
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: stopStreamBtn.text
|
||||
color: Theme.headingColor
|
||||
font: stopStreamBtn.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
onClicked: streamController.stopStream()
|
||||
ReadoutStat {
|
||||
label: "Sigma (Σ)"
|
||||
value: s.sigma !== undefined ? s.sigma : "—"
|
||||
}
|
||||
ReadoutStat {
|
||||
label: "3Σ Value"
|
||||
value: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||
}
|
||||
ReadoutStat {
|
||||
visible: s.ecMinDelta !== undefined
|
||||
label: "E-C Δ Min"
|
||||
value: s.ecMinDelta !== undefined
|
||||
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
|
||||
: "—"
|
||||
}
|
||||
ReadoutStat {
|
||||
visible: s.ecMaxDelta !== undefined
|
||||
label: "E-C Δ Max"
|
||||
value: s.ecMaxDelta !== undefined
|
||||
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
|
||||
: "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// ── DISPLAY ─────────────────────────────────────────────────────
|
||||
PanelBox {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: readoutCard.implicitHeight + 24
|
||||
color: Theme.sidePanelBackground
|
||||
radius: Theme.sidePanelRadius
|
||||
border.color: Theme.sideBorder
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: readoutCard
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
topMargin: 12
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
// Header Inside the Box
|
||||
Label {
|
||||
text: "READOUT"
|
||||
color: Theme.sideMutedText
|
||||
font.family: Theme.uiFontFamily
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.leftMargin: 14
|
||||
Layout.rightMargin: 14
|
||||
Layout.topMargin: 2
|
||||
Layout.bottomMargin: 10
|
||||
}
|
||||
|
||||
// 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.fontSm
|
||||
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.fontLg
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
// 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.fontSm
|
||||
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.fontLg
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
// 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.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: s.diff !== undefined ? s.diff : "—"
|
||||
color: Theme.diffAccent
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
color: Theme.cardBorder
|
||||
}
|
||||
|
||||
// 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.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: s.avg !== undefined ? s.avg : "—"
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
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.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: s.sigma !== undefined ? s.sigma : "—"
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontLg
|
||||
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.fontSm
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: s.threeSigma !== undefined ? s.threeSigma : "—"
|
||||
color: Theme.headingColor
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontLg
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
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.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 4
|
||||
}
|
||||
SectionTitle { text: "DISPLAY" }
|
||||
|
||||
PanelCheckBox {
|
||||
id: thicknessToggle
|
||||
text: "Show Thickness"
|
||||
checked: false
|
||||
font.pixelSize: Theme.fontSm
|
||||
// First check with no data prompts for the customer CSV;
|
||||
// unchecking only hides the overlay, data stays loaded.
|
||||
onToggled: {
|
||||
if (checked && !root.hasThicknessData)
|
||||
thicknessDialog.open();
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: thicknessDialog
|
||||
title: "Select Thickness Data File"
|
||||
nameFilters: ["CSV files (*.csv)"]
|
||||
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
||||
onRejected: thicknessToggle.checked = false
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
@@ -452,6 +149,13 @@ ColumnLayout {
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: extremesToggle
|
||||
text: "Highlight Min/Max"
|
||||
checked: true
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
|
||||
PanelCheckBox {
|
||||
id: clusterAverageToggle
|
||||
text: "Average Clusters"
|
||||
@@ -475,26 +179,16 @@ ColumnLayout {
|
||||
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 {
|
||||
@@ -509,33 +203,18 @@ 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.fontSm
|
||||
font.letterSpacing: 1.5
|
||||
font.bold: true
|
||||
Layout.bottomMargin: 4
|
||||
}
|
||||
SectionTitle { text: "THRESHOLDS" }
|
||||
|
||||
Label {
|
||||
text: "Set Point (°C)"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,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
|
||||
@@ -124,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
|
||||
|
||||
@@ -206,6 +206,7 @@ QtObject {
|
||||
readonly property int panelPadding: 12
|
||||
// Side rail layout
|
||||
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
|
||||
|
||||
+23
-16
@@ -9,12 +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 =====
|
||||
@@ -32,8 +42,15 @@ 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)
|
||||
@@ -54,30 +71,20 @@ def main() -> int:
|
||||
# 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.
|
||||
data_dir = str(settings_model._data_dir)
|
||||
raw_settings = LocalSettings.read_settings(data_dir)
|
||||
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}
|
||||
@@ -83,6 +80,7 @@ class DeviceController(QObject):
|
||||
# → 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
|
||||
@@ -143,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:
|
||||
@@ -218,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."""
|
||||
@@ -282,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
|
||||
@@ -372,6 +390,7 @@ class DeviceController(QObject):
|
||||
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()
|
||||
@@ -381,6 +400,7 @@ class DeviceController(QObject):
|
||||
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:
|
||||
@@ -537,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)
|
||||
@@ -554,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
|
||||
@@ -37,10 +42,10 @@ class SessionController(QObject):
|
||||
loadedFileChanged = Signal()
|
||||
loadFileError = Signal(str)
|
||||
clusterAveragingEnabledChanged = Signal()
|
||||
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||
# trend: per-frame avg for live graph
|
||||
trendData = Signal(str) # JSON array of floats
|
||||
trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
|
||||
trendReset = Signal() # live trend buffer cleared (new stream started)
|
||||
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
|
||||
# dicts to QML as an opaque empty wrapper with no accessible fields.
|
||||
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
|
||||
@@ -50,8 +55,7 @@ class SessionController(QObject):
|
||||
_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()
|
||||
@@ -59,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)
|
||||
@@ -83,11 +81,11 @@ 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
|
||||
|
||||
@@ -96,21 +94,6 @@ class SessionController(QObject):
|
||||
self._compare_recs_b: list = []
|
||||
self._compare_alignment_map: dict[int, int] = {}
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
# ---- properties QML binds to ----
|
||||
@Property(str, notify=modeChanged)
|
||||
def mode(self) -> str: return self._mode
|
||||
@@ -150,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 [
|
||||
@@ -175,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:
|
||||
@@ -204,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()
|
||||
@@ -221,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
|
||||
@@ -249,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()
|
||||
@@ -304,22 +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
|
||||
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
|
||||
@@ -327,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)
|
||||
@@ -678,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:
|
||||
@@ -708,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
|
||||
@@ -753,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()
|
||||
@@ -779,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,
|
||||
@@ -791,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:
|
||||
@@ -862,7 +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]
|
||||
@@ -65,21 +64,11 @@ class CsvRecorder:
|
||||
Returns:
|
||||
True on success (at least one frame written).
|
||||
"""
|
||||
from pygui.backend.data.data_records import is_official_csv
|
||||
from pygui.backend.data.data_records import is_data_row, is_official_csv
|
||||
|
||||
if start_frame < 0 or end_frame < start_frame:
|
||||
return False
|
||||
|
||||
def _is_data_row(stripped: str) -> bool:
|
||||
cols = [c.strip() for c in stripped.split(",") if c.strip()]
|
||||
if not cols:
|
||||
return False
|
||||
try:
|
||||
[float(c) for c in cols]
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
with open(source_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
@@ -99,7 +88,7 @@ class CsvRecorder:
|
||||
# official format: row 0 is the sensor-name header
|
||||
out.append(line)
|
||||
continue
|
||||
if not stripped or not _is_data_row(stripped):
|
||||
if not stripped or not is_data_row(stripped):
|
||||
continue
|
||||
frame_idx += 1
|
||||
if start_frame <= frame_idx <= end_frame:
|
||||
@@ -112,4 +101,3 @@ class CsvRecorder:
|
||||
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
|
||||
@@ -235,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()
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
from pygui.backend.license.license_manager import (
|
||||
LICENSE_FILENAME_RE,
|
||||
License,
|
||||
parse_license_blob,
|
||||
read_license_files,
|
||||
)
|
||||
from pygui.backend.license.license_model import LicenseModel
|
||||
|
||||
__all__ = [
|
||||
"LICENSE_FILENAME_RE",
|
||||
"License",
|
||||
"LicenseModel",
|
||||
"parse_license_blob",
|
||||
"read_license_files",
|
||||
]
|
||||
|
||||
@@ -50,7 +50,7 @@ class LicenseModel(QObject):
|
||||
self.refresh()
|
||||
|
||||
# ── Grid rows ─────────────────────────────────────────────────────
|
||||
@Property("QVariantList", notify=licensesChanged)
|
||||
@Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
|
||||
def licenses(self) -> list:
|
||||
"""Rows for the About grid: serial, mfg date, level, license date."""
|
||||
return [
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -73,7 +73,8 @@ 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:
|
||||
@@ -84,7 +85,8 @@ def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
|
||||
resampling across a NaN/zero-filled hole rings at the boundary, and any
|
||||
mask derived from the coarse grid keeps its staircase when upscaled.
|
||||
"""
|
||||
return _ndi_zoom(field, factor, order=3)
|
||||
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:
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
"""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
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
|
||||
@@ -20,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()
|
||||
@@ -43,46 +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 = 34
|
||||
self._margin_bottom: int = 16
|
||||
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
|
||||
@@ -94,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
|
||||
@@ -106,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
|
||||
@@ -118,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:
|
||||
@@ -127,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()
|
||||
|
||||
@@ -160,63 +181,38 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
return
|
||||
|
||||
left = self._padding + self._margin_left
|
||||
top = self._padding + self._margin_top
|
||||
bottom_inset = self._padding + self._margin_bottom
|
||||
plot_rect = QRectF(left, self._padding,
|
||||
plot_rect = QRectF(left, top,
|
||||
max(1, w - left - self._padding),
|
||||
max(1, h - self._padding - bottom_inset))
|
||||
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:
|
||||
lo, hi = lo - 5.0, hi + 5.0
|
||||
# Snap to a "nice" tick step (1/2/5 × 10^k) so the axis holds still
|
||||
# while streaming and only moves when data crosses a step boundary —
|
||||
# raw min/max rescaled every frame, which read as an unstable chart.
|
||||
raw_step = (hi - lo) / 4
|
||||
mag = 10 ** math.floor(math.log10(raw_step))
|
||||
step = next(s * mag for s in (1, 2, 5, 10) if s * mag >= raw_step)
|
||||
return math.floor(lo / step) * step, math.ceil(hi / step) * step
|
||||
|
||||
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),
|
||||
@@ -226,36 +222,29 @@ 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
|
||||
label_w = self._margin_left - 4 # 4px gutter before the plot area
|
||||
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:g}"
|
||||
painter.drawText(QRectF(0, y_px - 7, label_w, 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: sample index. `trendData` carries plain averages with no
|
||||
# timestamps attached, so index is the only x-coordinate available —
|
||||
# it reads correctly for review mode (index == frame number) and as a
|
||||
# relative recency order for the live rolling window. paint() already
|
||||
# guarantees len(self._data) >= 2 before calling this.
|
||||
n = len(self._data)
|
||||
cols = min(4, n - 1)
|
||||
# X-axis: elapsed seconds within the current rolling window.
|
||||
cols = 4
|
||||
y_px = plot_rect.bottom() + 4
|
||||
for i in range(cols + 1):
|
||||
idx = round((i / cols) * (n - 1))
|
||||
x_px = self._x_to_px(idx, n, plot_rect)
|
||||
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
|
||||
@@ -266,16 +255,36 @@ class TrendChartItem(QQuickPaintedItem):
|
||||
else:
|
||||
rect = QRectF(x_px - 20, y_px, 40, 14)
|
||||
align = Qt.AlignmentFlag.AlignHCenter
|
||||
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, str(idx))
|
||||
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)
|
||||
@@ -284,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,6 +27,7 @@ from PySide6.QtGui import (
|
||||
from PySide6.QtQml import QmlElement
|
||||
from PySide6.QtQuick import QQuickPaintedItem
|
||||
|
||||
from pygui.backend.models.frame_stats import compute_stats
|
||||
from pygui.backend.visualization.rbf_heatmap import (
|
||||
ellipse_alpha,
|
||||
interpolate_field,
|
||||
@@ -36,6 +37,59 @@ 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
|
||||
|
||||
@@ -51,6 +105,7 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
marginChanged = Signal()
|
||||
blendChanged = Signal()
|
||||
showLabelsChanged = Signal()
|
||||
showExtremesChanged = Signal()
|
||||
colorsChanged = Signal()
|
||||
shapeChanged = Signal()
|
||||
sizeChanged = Signal()
|
||||
@@ -69,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)
|
||||
@@ -107,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(
|
||||
@@ -127,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()
|
||||
@@ -138,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()
|
||||
@@ -148,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()
|
||||
@@ -159,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()
|
||||
@@ -170,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:
|
||||
@@ -182,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()
|
||||
@@ -202,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()
|
||||
@@ -210,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()
|
||||
@@ -233,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:
|
||||
@@ -246,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()
|
||||
@@ -255,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 ─────────────────────────────────────────────────────────────
|
||||
@@ -301,41 +385,73 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
# 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(
|
||||
@@ -554,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()
|
||||
@@ -702,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:
|
||||
@@ -714,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,15 +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
|
||||
|
||||
|
||||
# TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser)
|
||||
# THINKING: this already returns everything the UI needs (family names) —
|
||||
# the missing piece is a context-property exposure (SessionController or a
|
||||
# thin new property) and a setLayout(family) slot that calls load_layout()
|
||||
# below. No parsing work needed; this is a wiring-only task.
|
||||
# See docs/pending/alpha-release-polish-plan.md §6.3.
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -3,11 +3,14 @@
|
||||
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,
|
||||
remove_trailing_zeros,
|
||||
save_debug_csv,
|
||||
save_to_csv,
|
||||
)
|
||||
|
||||
@@ -321,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()
|
||||
|
||||
@@ -133,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}
|
||||
@@ -233,3 +291,12 @@ def test_device_service_port_caching(controller):
|
||||
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()
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
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):
|
||||
@@ -29,3 +29,24 @@ def test_at_end():
|
||||
assert not p.at_end
|
||||
p.seek(1)
|
||||
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]]
|
||||
|
||||
@@ -2,7 +2,12 @@ import math
|
||||
|
||||
import pytest
|
||||
|
||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
||||
from pygui.backend.models.frame_stats import (
|
||||
EdgeCenterStats,
|
||||
Stats,
|
||||
compute_edge_center,
|
||||
compute_stats,
|
||||
)
|
||||
|
||||
|
||||
def test_basic_stat():
|
||||
@@ -28,3 +33,43 @@ def test_ignores_nan():
|
||||
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)
|
||||
@@ -66,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,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,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,6 +53,49 @@ def test_pause_stop_timer(controller):
|
||||
controller.stop()
|
||||
assert controller.frameIndex == 0
|
||||
|
||||
def test_graph_data_empty_before_load(controller):
|
||||
assert controller.graphSensorNames == ""
|
||||
assert controller.graphSeriesJson == "[]"
|
||||
|
||||
def test_graph_data_after_load(controller):
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
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()
|
||||
@@ -119,10 +168,10 @@ Item {
|
||||
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()
|
||||
@@ -191,3 +240,75 @@ def test_export_segment_bad_index(qapp, controller, tmp_path):
|
||||
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
|
||||
|
||||
@@ -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,7 +1,11 @@
|
||||
"""Tests for src/pygui/backend/wafer_layouts.py."""
|
||||
import pytest
|
||||
|
||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
||||
from pygui.backend.wafer.wafer_layouts import (
|
||||
available_families,
|
||||
ec_pairs_for_wafer_id,
|
||||
load_layout,
|
||||
)
|
||||
from pygui.backend.wafer.zwafer_models import Sensor
|
||||
|
||||
|
||||
@@ -20,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("") == ()
|
||||
|
||||
+238
-31
@@ -8,20 +8,6 @@ import pytest
|
||||
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
|
||||
@@ -31,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():
|
||||
@@ -67,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():
|
||||
@@ -88,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":
|
||||
Reference in New Issue
Block a user