Compare commits

..

29 Commits

Author SHA1 Message Date
jack 9e3bec9031 feat(debug): add debug read with CSV export
- Parse F1 debug data via family-independent temp formula
  (bits 4-14 scaled by 2**(i-8), sign at bit 0)
- Save sensor+debug temps side-by-side to debug_info.csv
- Wire READ DEBUG button in StatusActionsPanel
- Rename ReplayChart → RunChart; move into GraphTab
- Extract PanelBox/SectionTitle/PanelSlider in ReadoutPanel
  to cut ~300 lines of duplicated styling
- Add rightRailWidth token to Theme for consistent rail sizing
- Wrap ReadoutPanel in ScrollView so Thresholds card stays
  reachable when E-C delta rows appear
2026-07-11 18:39:29 -07:00
jack fed4d9b590 feat: replay chart with zoomable viewpor 2026-07-10 20:54:24 -07:00
jack 4bb855a940 feat(graph): add graph tab for whole-run data
Provides a static line chart plotting multi-sensor temperature values
over time when a session file is loaded.
2026-07-10 20:06:48 -07:00
jack 94f917b116 feat(map): add edge-center delta stats, thickness overlay, and layout fix
- Add edge-center sensor pair tables and delta calculations per wafer family.
- Parse and interpolate wafer thickness CSV data for offscreen visualization.
- Refactor ReadoutPanel layout using Item + anchors to fix right-margin clipping.
- Compact E-C Delta text format to fit standard sidebar width.
- Add pytest suite covering delta calculations, layout pairs, and thickness CSV.
2026-07-10 17:32:12 -07:00
jack 25fa7507ce feat(map): add min/max highlighting toggle and live safeguards
- Add "Highlight Min/Max" option to ReadoutPanel showing peak rings.
- Reset to review mode and blank wafer map when switching away from map tab.
- Disable hover highlights and sensor dialog triggers during live stream.
- Lower trendPane height and add top margin headroom for tick labels.
- Add test coverage for blanking states, toggle overrides, and interactions.
2026-07-10 16:28:55 -07:00
jack 034f13b717 feat(map): add peak highlights and live metrics to PNG export
- Draw rings around hottest and coldest sensors on export.
- Extend export_image to support second line for live metrics.
- Format detailed sensor stats in export footer.
- Add test coverage for footer rows, overlays, and stats.
2026-07-10 15:39:30 -07:00
jack 69753e35f9 feat(map): add batch export and adjust layout alignment
- Implement synchronous offscreen wafer map PNG rendering and CSV summary.
- Add "Batch Export" button to file browser sidebar.
- Adjust trend pane bottom spacer to align with About button when visible.
- Fix type check errors and add pytest coverage for batch export.
2026-07-10 15:22:36 -07:00
jack 278a48a2e4 feat(transport): editable frame field + uniform spacing 2026-07-10 14:38:12 -07:00
jack e2ce8778e0 feat(files): sync Source panel browser with chosen save directory 2026-07-10 14:37:29 -07:00
jack bdc667b2ed refactor: extract StreamControlPanel and MetricRow from WaferMapTab 2026-07-09 15:16:11 -07:00
jack b6249c2b8e fix: added property var settingsPopup: null / property var aboutDialog: null to UtilityFooter.qml and updated onClicked to use local properties instead of utilFooter.parent.*. 2026-07-09 14:30:19 -07:00
jack 2fe8aef805 feat(ui): refine AboutDialog layout and add nav pill tooltips 2026-07-09 13:56:48 -07:00
jack 4ee139ad8f fix: The QML cross-component id resolution for root.waferDetected was failing
silently. Exposing a proper waferDetected property on the Python
DeviceController ensures reliable gating, and resetting it on refresh
blocks the actions until the next successful detection.
2026-07-09 13:07:31 -07:00
jack 7b20ffa376 refactor(qml): finalize tooltip standardization and settings module imports 2026-07-09 12:50:24 -07:00
jack ac4448ed44 refactor(backend): clean up package re-exports, settings model properties, and unused signals 2026-07-09 12:49:33 -07:00
jack ca158a7946 refactor(qml): extract sidebar panels and data tab comparison cards to modular components 2026-07-09 12:49:10 -07:00
jack 637b51b999 fix(qml): resolve runtime reference errors, missing imports, and port change notifications 2026-07-09 12:47:56 -07:00
jack bb9b5d709a feat: add global ToolTip styling to HomePage 2026-07-08 00:31:46 -07:00
jack b6903af625 feat: redesign StatusTab UI with bento-grid layout and add CSV metadata editing capabilities 2026-07-07 16:41:40 -07:00
jack 92f130b3bd feat: implement master file configuration, UI indicators, and a Run Comparison tool for the Data tab 2026-07-07 15:05:22 -07:00
jack 1e03227788 style: increase ReadoutPanel font sizes and add CSV test fixture for data logging 2026-07-07 12:10:47 -07:00
jack 7df7fd4c6f feat: implement C#-compatible AES-128-CBC license file parsing and integrate license management UI into AboutDialog 2026-07-07 11:15:06 -07:00
jack e2d05d2c33 refactor: relocate live stream controls to ReadoutPanel and update chart axis rendering 2026-07-07 10:38:42 -07:00
jack 799155f249 refactor: optimize RBF heatmap performance by decoupling grid resolution from viewport size and update UI file handling. 2026-07-06 21:02:22 -07:00
jack 015642eea0 chore: apply linting fixes and expand ruff coverage to test suite 2026-07-06 15:52:53 -07:00
jack d63332d619 feat: add load error signaling and comparison validation 2026-07-06 15:43:13 -07:00
jack 88b0214582 refactor(ui): consolidate Theme.qml tokens and replace unicode glyphs with SVG icons 2026-07-06 15:42:18 -07:00
jack 0014ccc184 fix: gate DTW comparisons on wafer type + recording category; surface load errors 2026-07-06 15:41:29 -07:00
jack 1989ba8e5a feat: add vector icons for transport controls, implement marker hover effects, and update dialog positioning 2026-07-06 13:51:45 -07:00
116 changed files with 7203 additions and 2993 deletions
+18
View File
@@ -40,3 +40,21 @@ dist/
*.spec *.spec
*.icns *.icns
*.ico *.ico
# Runtime scratch data
tmp/
# Superpowers brainstorming visual companion
.superpowers/
.llm-wiki/
.agents/
.pi/
# Planning / personal docs (root level)
CLAUDE.md
LEARNINGS.md
MIGRATION.md
AGENTS.md
CONVENTIONS.md
specs/
graphify-out/
+3 -3
View File
@@ -3,14 +3,14 @@
install: install:
uv sync uv sync
test: test: lint
uv run pytest tests/ uv run pytest tests/
lint: lint:
uv run ruff check src/ uv run ruff check src/ tests/
fix: fix:
uv run ruff check src/ --fix uv run ruff check src/ tests/ --fix
typecheck: typecheck:
uv run mypy src/ uv run mypy src/
+15 -1
View File
@@ -14,11 +14,13 @@ The `ISenseCloud` application provides a real-time monitor and analysis dashboar
- **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries. - **Custom Safety Limits & Thresholds**: Configurable alarm thresholds and set-points with automatic alerts when temperatures diverge from normal boundaries.
### App Showcase ### App Showcase
![ISenseCloud UI displaying successful connection on COM6](image-1.png) ![ISenseCloud UI displaying successful connection on COM6](image-1.png)
*Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.* *Figure 1: Main Status Dashboard displaying successful connection, active serial communications, and automated logging terminal.*
> [!NOTE] > [!NOTE]
> **Take Screenshots for Visual Placeholders:** > **Take Screenshots for Visual Placeholders:**
>
> - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here. > - **Wafer Heatmap Tab**: Take a screenshot of the Wafer Map tab showing the radial heatmap interpolation, save it to `docs/design/wafer_heatmap_tab.png` and add it here.
> - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here. > - **Temperature Trend Graph**: Take a screenshot of the Graph tab plotting multi-sensor trend lines, save it to `docs/design/temp_trend_tab.png` and add it here.
@@ -42,12 +44,15 @@ During the refactoring from the flat-layout prototype (`SettingTab` branch) to t
From the project root: From the project root:
### Option A: Using `uv` (Recommended) ### Option A: Using `uv` (Recommended)
If you have [uv](https://docs.astral.sh/uv/) installed: If you have [uv](https://docs.astral.sh/uv/) installed:
```bash ```bash
uv sync uv sync
``` ```
### Option B: Using `pip` ### Option B: Using `pip`
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows source .venv/bin/activate # or .venv\Scripts\activate on Windows
@@ -59,9 +64,11 @@ pip install -e . # install the `pygui` package (src/ layout) in edita
## Run ## Run
### Launch PySide6 QML App ### Launch PySide6 QML App
```bash ```bash
make run make run
``` ```
This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`. This launches the Qt window and loads the `ISC` QML module from `src/pygui/ISC/Main.qml`.
## Development ## Development
@@ -83,6 +90,7 @@ A `Makefile` is provided to simplify common development and verification tasks:
### Ruff Configuration ### Ruff Configuration
Ruff is configured in `pyproject.toml` to enforce: Ruff is configured in `pyproject.toml` to enforce:
- **E/W**: Pycodestyle errors and warnings - **E/W**: Pycodestyle errors and warnings
- **F**: Pyflakes linter rules - **F**: Pyflakes linter rules
- **I**: Import sorting (isort parity) - **I**: Import sorting (isort parity)
@@ -174,11 +182,13 @@ Window {
- If the app does not start, verify the virtual environment is active and dependencies are installed: - If the app does not start, verify the virtual environment is active and dependencies are installed:
*Using `uv`:* *Using `uv`:*
```bash ```bash
uv sync uv sync
``` ```
*Using `pip`:* *Using `pip`:*
```bash ```bash
source .venv/bin/activate # or .venv\Scripts\activate on Windows source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt pip install -r requirements.txt
@@ -187,17 +197,20 @@ Window {
- If no window appears, ensure you are running in a desktop session with GUI access. - If no window appears, ensure you are running in a desktop session with GUI access.
- **Windows COM Port Connection Issues:** - **Windows COM Port Connection Issues:**
* Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud. - Standard Windows serial drivers don't support custom baud rates (e.g. `888888`), throwing an expected `OSError(22)` warning before falling back to `115200` baud.
### Step-by-Step Windows Simulator Connection Guide ### Step-by-Step Windows Simulator Connection Guide
#### Step 1: Create the Virtual Serial Port Bridge #### Step 1: Create the Virtual Serial Port Bridge
Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**. Open **HHD Virtual Serial Port Tools**. Under the **Local Bridges** panel on the left, click the green **`+`** (Add) button to create a new port pair. Configure it to bridge **`COM5 ↔ COM6`**.
![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png) ![HHD Virtual Serial Port Tools showing COM5 ↔ COM6 Local Bridge](image.png)
#### Step 2: Configure and Start the Wafer Simulator #### Step 2: Configure and Start the Wafer Simulator
Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`): Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
1. Set the **Serial Port** to **`COM5`**. 1. Set the **Serial Port** to **`COM5`**.
2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox. 2. **Uncheck** the `Auto-create Virtual Port (com0com)` checkbox.
3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`). 3. Choose your desired **Wafer Type** (e.g., `aepwafer`) and **Family Code** (e.g., `A`).
@@ -206,6 +219,7 @@ Launch the **Wafer Simulator Control Panel** (`wafer_sim_gui.py`):
![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png) ![Wafer Simulator Control Panel configured on COM5 and streaming](image-2.png)
#### Step 3: Run the UI and Connect #### Step 3: Run the UI and Connect
Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green). Launch the `pygui` client application. On the left navigation rail, click **DETECT WAFER**. The application will scan all active COM ports, automatically detect the virtual wafer simulator on **`COM6`**, and update the status indicator to **Connected** (green).
![ISenseCloud UI displaying successful connection on COM6](image-1.png) ![ISenseCloud UI displaying successful connection on COM6](image-1.png)
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"scipy>=1.17.1", "scipy>=1.17.1",
"pyyaml>=6.0.3", "pyyaml>=6.0.3",
"dtw-python", "dtw-python",
"cryptography>=41.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+95 -29
View File
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
@@ -8,10 +9,12 @@ Popup {
modal: true modal: true
dim: true dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: 420 width: 600
height: 340 height: 500
anchors.centerIn: Overlay.overlay anchors.centerIn: Overlay.overlay
onOpened: licenseModel.refresh()
background: Rectangle { background: Rectangle {
radius: Theme.radiusMd radius: Theme.radiusMd
color: Theme.cardBackground color: Theme.cardBackground
@@ -19,10 +22,17 @@ Popup {
border.width: 1 border.width: 1
} }
FileDialog {
id: licenseFileDialog
title: "Load License"
nameFilters: ["License files (*.bin)"]
onAccepted: loadFailedLabel.visible = !licenseModel.loadLicenseFile(selectedFile.toString())
}
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 24 anchors.margins: 28
spacing: 16 spacing: 20
// ── Title ── // ── Title ──
Label { Label {
@@ -34,27 +44,20 @@ Popup {
// ── App info ── // ── App info ──
ColumnLayout { ColumnLayout {
spacing: 4 spacing: 6
Label { Label {
text: "ISenseCloud (ISC)" text: "ISenseCloud"
font.pixelSize: Theme.fontLg font.pixelSize: Theme.fontXl
font.bold: true
color: Theme.headingColor color: Theme.headingColor
} }
Label { Label {
text: "Version 0.1.0" text: "Version " + appVersion
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontMd
color: Theme.bodyColor color: Theme.bodyColor
} }
Label {
text: "Temperature-sensing wafer monitoring"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
} }
// ── Separator ── // ── Separator ──
@@ -67,7 +70,8 @@ Popup {
// ── License grid ── // ── License grid ──
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 100 Layout.fillHeight: true
Layout.minimumHeight: 150
radius: Theme.radiusSm radius: Theme.radiusSm
color: Theme.panelBackground color: Theme.panelBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
@@ -75,23 +79,38 @@ Popup {
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 10 anchors.margins: 14
spacing: 2 spacing: 6
RowLayout {
Layout.fillWidth: true
Label { Label {
text: "LICENSE" text: "LICENSE"
color: Theme.panelTitleText color: Theme.panelTitleText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 0.5 font.letterSpacing: 0.5
Layout.fillWidth: true
}
Label {
id: loadFailedLabel
visible: false
text: "Invalid license file"
color: Theme.statusWarningColor
font.pixelSize: Theme.fontSm
}
} }
// Grid header // Grid header
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 4 spacing: 8
Label { text: "Wafer SN"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 100 } Label { text: "Wafer SN"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 150 }
Label { text: "Mfg Date"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 } Label { text: "Mfg Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 120 }
Label { text: "Level"; font.pixelSize: Theme.fontXs; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 60 } Label { text: "Level"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.preferredWidth: 80 }
Label { text: "License Date"; font.pixelSize: Theme.fontSm; font.bold: true; color: Theme.subheadingColor; Layout.fillWidth: true }
} }
Rectangle { Rectangle {
@@ -100,10 +119,27 @@ Popup {
color: Theme.softBorder color: Theme.softBorder
} }
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
visible: licenseModel.licenses.length > 0
model: licenseModel.licenses
delegate: RowLayout {
width: ListView.view.width
spacing: 8
Label { text: modelData.serial; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 150 }
Label { text: modelData.mfgDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 120 }
Label { text: modelData.level; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.preferredWidth: 80 }
Label { text: modelData.licDate; font.pixelSize: Theme.fontMd; color: Theme.bodyColor; Layout.fillWidth: true }
}
}
Label { Label {
visible: licenseModel.licenses.length === 0
text: "No license loaded" text: "No license loaded"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontMd
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
@@ -112,12 +148,39 @@ Popup {
} }
} }
// ── Close ── // ── Buttons ──
RowLayout {
Layout.fillWidth: true
spacing: 12
Button {
text: "Load License"
Layout.preferredWidth: 140
Layout.preferredHeight: 36
onClicked: licenseFileDialog.open()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
}
contentItem: Label {
text: parent.text
color: Theme.buttonNeutralText
font.pixelSize: Theme.fontMd
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Item { Layout.fillWidth: true }
Button { Button {
text: "Close" text: "Close"
Layout.alignment: Qt.AlignRight
Layout.preferredWidth: 100 Layout.preferredWidth: 100
Layout.preferredHeight: 32 Layout.preferredHeight: 36
onClicked: root.close() onClicked: root.close()
background: Rectangle { background: Rectangle {
@@ -129,9 +192,12 @@ Popup {
contentItem: Label { contentItem: Label {
text: parent.text text: parent.text
color: Theme.buttonNeutralText color: Theme.buttonNeutralText
font.pixelSize: Theme.fontMd
font.bold: true
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
} }
} }
} }
}
+304 -261
View File
@@ -23,20 +23,38 @@ Rectangle {
if (selectedTabIndex === 2) { if (selectedTabIndex === 2) {
file_browser.refreshFiles(); file_browser.refreshFiles();
streamController.unloadFile() streamController.unloadFile()
} else if (streamController.mode === "live") {
// Leaving the map tab mid-stream: stop and blank the live map.
streamController.setMode("review")
} }
} }
property bool memoryRead: false property bool memoryRead: false
property bool waferDetected: false property bool waferDetected: false
// TODO P2.2: add `property bool waferLicensed: false`; set it in
// onDetectResult below via deviceController.waferLicensed() (slot from
// P1.2 — the detectResult payload is opaque in QML, can't read serial
// from it). Then READ/ERASE buttons get
// `enabled: root.waferDetected && root.waferLicensed`.
// See docs/pending/license-gating-plan.md §2.2.
Connections { Connections {
target: deviceController target: deviceController
function onDetectResult(result){ function onDetectResult(result){
root.waferDetected = (result != null && result != undefined) // detectResult payload is Signal(object): a dict may cross into QML
// as an opaque wrapper whose fields read as undefined, so only
// test truthiness (None on failure, dict on success).
root.waferDetected = !!result
} }
function onParsedDataReady(result) { function onParsedDataReady(result) {
if (result.success && result.csv_path) { if (result.success && result.csv_path) {
streamController.loadFile(result.csv_path) streamController.loadFile(result.csv_path)
// TODO P3.1: only auto-jump to Map when unlocked:
// `&& licenseModel.licenses.length > 0` — otherwise a
// successful read walks straight into the locked tab.
// See plan §3.1.
if (root.selectedTabIndex === 0) { if (root.selectedTabIndex === 0) {
root.selectedTabIndex = 2 root.selectedTabIndex = 2
} }
@@ -63,6 +81,7 @@ Rectangle {
function _doDetect() { function _doDetect() {
root.memoryRead = false root.memoryRead = false
root.waferDetected = false
streamController.setMode("review") streamController.setMode("review")
streamController.stopStream() streamController.stopStream()
deviceController.detectWafer() deviceController.detectWafer()
@@ -72,7 +91,9 @@ Rectangle {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save Data" title: "Choose a folder to save Data"
onAccepted: { onAccepted: {
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder)) var dir = root.cleanFolderUrl(selectedFolder)
deviceController.setSaveDataDir(dir)
file_browser.setCurrentDirectory(dir)
root._doDetect() root._doDetect()
} }
} }
@@ -181,8 +202,6 @@ Rectangle {
Layout.preferredWidth: Theme.sideRailWidth Layout.preferredWidth: Theme.sideRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.sideRailBackground color: Theme.sideRailBackground
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
@@ -201,9 +220,30 @@ Rectangle {
ListModel { ListModel {
id: pillModel id: pillModel
ListElement { label: "STATUS"; icon: "Tabs/icons/status.svg"; expandW: 86 } ListElement {
ListElement { label: "DATA"; icon: "Tabs/icons/data.svg"; expandW: 66 } label: "STATUS"
ListElement { label: "MAP"; icon: "Tabs/icons/map.svg"; expandW: 58 } icon: "Tabs/icons/status.svg"
expandW: 86
desc: "Monitor real-time device connection, serial communication, and execute wafer actions."
}
ListElement {
label: "DATA"
icon: "Tabs/icons/data.svg"
expandW: 66
desc: "Manage, review, compare (DTW), and split historical sensor data records."
}
ListElement {
label: "MAP"
icon: "Tabs/icons/map.svg"
expandW: 58
desc: "Visualize spatial sensor readings on a 2D thin-plate spline RBF interpolated heatmap."
}
ListElement {
label: "GRAPH"
icon: "Tabs/icons/bar-chart.svg"
expandW: 70
desc: "Whole-run multi-sensor temperature chart for the currently loaded file."
}
} }
RowLayout { RowLayout {
@@ -284,307 +324,306 @@ Rectangle {
} }
} }
AppToolTip {
visible: pillBtn.hovered
text: "<b>" + model.label + "</b>: " + model.desc
}
onClicked: root.selectedTabIndex = index onClicked: root.selectedTabIndex = index
} }
} }
} }
} }
// ── BOX 2: Context-Sensitive Rail Panel ──────────────────── // ── BOX 1.5: Run Comparison (Data tab only) ────────────────
Rectangle { Rectangle {
id: runComparisonBox
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.preferredHeight: runCompCol.implicitHeight + 28
Layout.minimumHeight: 120 visible: root.selectedTabIndex === 1
radius: Theme.sidePanelRadius radius: Theme.sidePanelRadius
border.color: Theme.sideBorder border.color: Theme.sideBorder
border.width: 1 border.width: 1
color: Theme.sidePanelBackground color: Theme.sidePanelBackground
ColumnLayout { readonly property var dataTab: dataTabLoader.item
component RunSlot: Rectangle {
id: slotBox
property string title
property string filePath: ""
property color accent: Theme.primaryAccent
property bool active: false
property bool locked: false
property string placeholderText: "Click to select"
signal cleared()
signal armed()
Layout.fillWidth: true
implicitHeight: 58
radius: Theme.radiusSm
color: Theme.fieldBackground
opacity: slotBox.locked ? 0.65 : 1.0
border.width: slotBox.active ? 2 : 1
border.color: slotBox.active ? accent : Theme.sideBorder
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
MouseArea {
anchors.fill: parent anchors.fill: parent
anchors.margins: 14 enabled: !slotBox.locked
cursorShape: slotBox.locked ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: slotBox.armed()
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8 spacing: 8
StackLayout { Rectangle {
Layout.fillWidth: true width: 8; height: 8; radius: 4
Layout.fillHeight: true color: slotBox.accent
currentIndex: root.selectedTabIndex === 0 ? 0 : 1 Layout.alignment: Qt.AlignVCenter
}
// ── Status tab: Hardware Actions ────────────────
ColumnLayout { ColumnLayout {
spacing: 6 Layout.fillWidth: true
spacing: 2
Label {
text: slotBox.title
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.0
color: slotBox.active ? slotBox.accent : Theme.sideMutedText
}
Label {
text: slotBox.filePath !== ""
? slotBox.filePath.split("/").pop()
: (slotBox.locked ? slotBox.placeholderText : (slotBox.active ? "<- Select file below..." : "Click to select"))
font.pixelSize: Theme.fontSm
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
Button {
visible: slotBox.filePath !== "" && !slotBox.locked
flat: true
implicitWidth: 24; implicitHeight: 24
onClicked: slotBox.cleared()
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
anchors.centerIn: parent
}
}
}
}
ColumnLayout {
id: runCompCol
anchors.fill: parent
anchors.margins: 14
spacing: 10
RowLayout {
Layout.fillWidth: true
spacing: 8
Label { Label {
text: "HARDWARE ACTIONS" text: "RUN COMPARISON (DTW)"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
topPadding: 6
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.bold: true font.bold: true
}
RailActionButton {
label: "DETECT WAFER"
iconSource: "../icons/detect.svg"
Layout.fillWidth: true Layout.fillWidth: true
enabled: !deviceController.operationInProgress elide: Text.ElideRight
onClicked: root._doDetect()
} }
RailActionButton { Button {
label: "READ MEMORY" id: resetRunBtn
iconSource: "../icons/read.svg" flat: true
implicitWidth: 32
implicitHeight: 32
visible: true
onClicked: if (runComparisonBox.dataTab) runComparisonBox.dataTab.resetComparison()
background: Rectangle {
radius: Theme.radiusSm
color: resetRunBtn.hovered ? Theme.flatButtonHover : "transparent"
}
contentItem: IconImage {
source: "Tabs/icons/rotate-ccw.svg"
width: 18; height: 18
sourceSize.width: 18
sourceSize.height: 18
color: Theme.bodyColor
anchors.centerIn: parent
}
AppToolTip {
visible: resetRunBtn.hovered
text: "Reset comparison"
}
}
}
PanelCheckBox {
id: useMasterCheck
Layout.fillWidth: true Layout.fillWidth: true
enabled: root.waferDetected text: "Compare vs Master File"
onClicked: deviceController.readMemoryAsync() checked: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
onToggled: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.useMasterFile = checked
if (checked) runComparisonBox.dataTab.activeBox = 2
}
} }
RailActionButton { RunSlot {
label: "ERASE MEMORY" readonly property bool useMaster: runComparisonBox.dataTab ? runComparisonBox.dataTab.useMasterFile : false
iconSource: "../icons/erase.svg" title: useMaster ? "MASTER FILE" : "REFERENCE (RUN A)"
accent: Theme.primaryAccent
locked: useMaster
placeholderText: useMaster
? (runComparisonBox.dataTab.runBWaferType !== ""
? "No master file for type " + runComparisonBox.dataTab.runBWaferType
: "Select Run B to resolve master")
: "Click to select"
active: runComparisonBox.dataTab ? (!useMaster && runComparisonBox.dataTab.activeBox === 1) : false
filePath: runComparisonBox.dataTab
? (useMaster ? runComparisonBox.dataTab.masterFileForRunB : runComparisonBox.dataTab.compareFileA)
: ""
onArmed: if (runComparisonBox.dataTab && !useMaster) runComparisonBox.dataTab.activeBox = 1
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileA = ""
runComparisonBox.dataTab.activeBox = 1
runComparisonBox.dataTab.clearResults()
}
}
RunSlot {
title: "SESSION (RUN B)"
accent: Theme.themeSkill
active: runComparisonBox.dataTab ? runComparisonBox.dataTab.activeBox === 2 : false
filePath: runComparisonBox.dataTab ? runComparisonBox.dataTab.compareFileB : ""
onArmed: if (runComparisonBox.dataTab) runComparisonBox.dataTab.activeBox = 2
onCleared: {
if (!runComparisonBox.dataTab) return
runComparisonBox.dataTab.compareFileB = ""
runComparisonBox.dataTab.activeBox = 2
runComparisonBox.dataTab.clearResults()
}
}
Button {
id: compareRunBtn
Layout.fillWidth: true Layout.fillWidth: true
destructive: true Layout.preferredHeight: 32
enabled: root.waferDetected readonly property var dt: runComparisonBox.dataTab
onClicked: deviceController.eraseMemory( readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
deviceController.selectedPort) readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
readonly property string gateError: !!dt ? dt.familyGateError : ""
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
AppToolTip {
visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
text: compareRunBtn.masterMissing
? "No master file registered for this wafer type (Settings → Master Files)"
: compareRunBtn.gateError !== "" ? compareRunBtn.gateError
: "Choose two different runs to compare"
}
onClicked: {
dt.comparing = true
dt.clearResults()
streamController.compareFiles(fileA, dt.compareFileB)
} }
Item { Layout.fillHeight: true } background: Rectangle {
radius: Theme.radiusSm
color: compareRunBtn.enabled
? (compareRunBtn.hovered ? Qt.lighter(Theme.primaryAccent, 1.1) : Theme.primaryAccent)
: Theme.buttonNeutralBackground
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
}
contentItem: Row {
spacing: 8
anchors.centerIn: parent
Label {
anchors.verticalCenter: parent.verticalCenter
text: !compareRunBtn.dt ? "Select both runs"
: compareRunBtn.dt.comparing
? "Comparing…"
: compareRunBtn.enabled
? "Run DTW Comparison"
: (compareRunBtn.masterMissing ? "No master for this type"
: compareRunBtn.sameFile ? "Choose different runs"
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
: "Select both runs")
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
}
BusyIndicator {
running: !!compareRunBtn.dt && compareRunBtn.dt.comparing
visible: !!compareRunBtn.dt && compareRunBtn.dt.comparing
width: 14; height: 14
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
} }
// ── Map tab: Source file browser ────────────── // ── BOX 2: Context-Sensitive Rail Panel ────────────────────
SourcePanel { StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions + Log Actions ──────────
StatusActionsPanel {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
} }
// ── Map tab: Source file browser ──────────────
Rectangle {
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
SourcePanel {
anchors.fill: parent
anchors.margins: 14
} }
} }
} }
// ── BOX 3: Hardware Status Footer ────────────────────────── // ── BOX 3: Hardware Status Footer ──────────────────────────
Rectangle { ConnectionFooter {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterConnection
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 6
// Row 1: Title + status badge (unchanged)
RowLayout {
spacing: 8
Layout.fillWidth: true
Label {
text: "CONNECTION FLOW"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.2
font.bold: true
}
Rectangle {
height: 18
width: badgeLabel.implicitWidth + 14
radius: 9
color: {
var s = deviceController.connectionStatus
if (s === "Connected") return Theme.statusSuccessColor
if (s === "Disconnected") return Theme.statusErrorColor
return Theme.statusWarningColor
}
Label {
id: badgeLabel
anchors.centerIn: parent
text: deviceController.connectionStatus || "Disconnected"
color: "#111111"
font.pixelSize: Theme.font2xs
font.bold: true
}
}
Item { Layout.fillWidth: true }
}
// Row 2: Status dot + descriptive text (replaces dotted trail)
Row {
spacing: 8
Layout.fillWidth: true
Rectangle {
id: statusDot
anchors.verticalCenter: parent.verticalCenter
width: 8
height: 8
radius: 4
color: deviceController.connectionStatus === "Connected"
? Theme.statusSuccessColor : Theme.statusErrorColor
SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected"
loops: Animation.Infinite
NumberAnimation { to: 0.4; duration: 1200 }
NumberAnimation { to: 1.0; duration: 1200 }
}
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: {
if (deviceController.connectionStatus === "Connected")
return deviceController.selectedPort || "Connected"
if (deviceController.selectedPort)
return deviceController.selectedPort
return "No port selected"
}
color: Theme.bodyColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
elide: Text.ElideRight
}
}
// Row 3: Mode chip — neutral gray tag
Row {
spacing: 6
Layout.fillWidth: true
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: modeChipLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Label {
id: modeChipLabel
anchors.centerIn: parent
text: streamController.mode
? streamController.mode.toUpperCase() : "REVIEW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.8
}
}
}
}
} }
// ── BOX 4: Utility Buttons ───────────────────────────────── // ── BOX 4: Utility Buttons ─────────────────────────────────
Rectangle { UtilityFooter {
id: utilFooter
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterUtility settingsPopup: settingsPopup
radius: Theme.sidePanelRadius aboutDialog: aboutDialog
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
RowLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 4
Button {
id: settingsBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "Tabs/icons/settings.svg"
onClicked: settingsPopup.open()
background: Rectangle {
radius: 8
color: settingsBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "Tabs/icons/settings.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "SETTINGS"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
Rectangle {
width: 1
height: 24
color: Theme.sideBorder
}
Button {
id: aboutBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "Tabs/icons/about.svg"
onClicked: aboutDialog.open()
background: Rectangle {
radius: 8
color: aboutBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "Tabs/icons/about.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "ABOUT"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
}
} }
} }
} }
@@ -594,8 +633,6 @@ Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.workspaceBackground color: Theme.workspaceBackground
border.color: Theme.workspaceBorder
border.width: 1
StackLayout { StackLayout {
anchors.fill: parent anchors.fill: parent
@@ -613,6 +650,12 @@ Rectangle {
} }
Loader { Loader {
source: "Tabs/WaferMapTab.qml" source: "Tabs/WaferMapTab.qml"
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
// so the tab never instantiates while locked. See plan §3.1.
active: StackLayout.index === root.selectedTabIndex
}
Loader {
source: "Tabs/GraphTab.qml"
active: StackLayout.index === root.selectedTabIndex active: StackLayout.index === root.selectedTabIndex
} }
} }
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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"
}
}
}
+26 -13
View File
@@ -1,8 +1,10 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Dialogs import QtQuick.Dialogs
import ISC import ISC
import ISC.Tabs.components
Item { Item {
id: root id: root
@@ -123,19 +125,17 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
Text { IconImage {
anchors.centerIn: parent anchors.centerIn: parent
text: "✓" source: "icons/check.svg"
font.pixelSize: Theme.fontMd width: 12; height: 12
font.bold: true sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0 opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { Behavior on opacity {
NumberAnimation { duration: Theme.durationFast } NumberAnimation { duration: Theme.durationFast }
} }
// Small nudge to center the checkmark perfectly
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
} }
} }
@@ -366,7 +366,7 @@ Item {
width: 22 width: 22
height: 22 height: 22
radius: height / 2 radius: height / 2
color: "white" color: Theme.toggleThumb
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
x: Theme.isDarkMode ? parent.width - width - 3 : 3 x: Theme.isDarkMode ? parent.width - width - 3 : 3
@@ -452,12 +452,9 @@ Item {
} }
} }
Label { Row {
anchors.centerIn: parent anchors.centerIn: parent
text: "↓ Scroll for more" spacing: 4
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.bodyColor
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0 opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
Behavior on opacity { Behavior on opacity {
@@ -465,6 +462,22 @@ Item {
duration: 400 duration: 400
} }
} }
IconImage {
anchors.verticalCenter: parent.verticalCenter
source: "icons/chevron-down.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "Scroll for more"
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.bodyColor
}
} }
} }
} }
+246 -246
View File
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs import QtQuick.Dialogs
import ISC import ISC
@@ -29,36 +30,45 @@ ColumnLayout {
radius: Theme.radiusSm radius: Theme.radiusSm
ColumnLayout { ColumnLayout {
anchors.centerIn: parent anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4 spacing: 4
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
}
Text { Text {
id: valueText id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: valSize font.pixelSize: valSize
font.weight: Font.Bold font.weight: Font.Bold
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter Layout.fillHeight: true
} Layout.fillWidth: true
Text { horizontalAlignment: Text.AlignHCenter
id: labelText verticalAlignment: Text.AlignVCenter
color: Theme.sideMutedText fontSizeMode: Text.HorizontalFit
font.pixelSize: Theme.fontXs minimumPixelSize: Theme.fontMd
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter
} }
} }
} }
component GridCell : RowLayout { // Stacked stat block: small label over big value, centered in its grid
// quadrant so the card's height is used instead of leaving dead space.
component GridCell : ColumnLayout {
property alias label: labelText.text property alias label: labelText.text
property alias value: valueText.text property alias value: valueText.text
property bool active: root.waferDetected property bool active: root.waferDetected
spacing: 8 spacing: 2
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true
Item { Layout.fillHeight: true }
Text { Text {
id: labelText id: labelText
color: Theme.sideMutedText color: Theme.sideMutedText
@@ -66,17 +76,15 @@ ColumnLayout {
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
Layout.fillWidth: true Layout.fillWidth: true
elide: Text.ElideRight elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
} }
Text { Text {
id: valueText id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontXl
font.weight: Font.Bold font.weight: Font.Bold
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
verticalAlignment: Text.AlignVCenter
} }
Item { Layout.fillHeight: true }
} }
// ── Aliases for external wiring ── // ── Aliases for external wiring ──
@@ -114,21 +122,75 @@ ColumnLayout {
id: saveDirDialog id: saveDirDialog
title: "Choose a folder to save." title: "Choose a folder to save."
onAccepted: { onAccepted: {
deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder)); var dir = root.cleanFolderUrl(selectedFolder);
deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
root.parseAndSavePendingRead(); root.parseAndSavePendingRead();
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // Directory-only picker for the DIRECTORY card button — sets the save dir
// ROW 1: Connection Status (100%) // without kicking off a parse/save of pending read data.
// ═══════════════════════════════════════════════════════════════════════ FolderDialog {
RowLayout { id: dirOnlyDialog
Layout.fillWidth: true title: "Choose a folder to save Data"
Layout.preferredHeight: 90 onAccepted: {
Layout.maximumHeight: 90 var dir = root.cleanFolderUrl(selectedFolder);
spacing: 8 deviceController.setSaveDataDir(dir);
file_browser.setCurrentDirectory(dir);
}
}
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
// Cancel keeps the CSV, skips the sidecar — matching C# behavior loosely.
Dialog {
id: editCsvDialog
parent: Overlay.overlay
modal: true
title: "Edit CSV Metadata"
width: 460
anchors.centerIn: parent
standardButtons: Dialog.Save | Dialog.Cancel
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
ColumnLayout {
anchors.fill: parent
spacing: 6
Text {
text: root.csvPath.split("/").pop()
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
elide: Text.ElideMiddle
Layout.fillWidth: true
}
Label { text: "Wafer" }
TextField { id: metaWafer; Layout.fillWidth: true }
Label { text: "Date" }
TextField { id: metaDate; Layout.fillWidth: true }
Label { text: "Chamber" }
TextField { id: metaChamber; Layout.fillWidth: true }
Label { text: "Notes" }
TextField { id: metaNotes; Layout.fillWidth: true }
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROWS 1-2 (3-column bento):
// [Connection Status] [Total Runtime ] [Wafer & Sensor]
// [Directory (span 2) ] [ (rowspan) ]
// ═══════════════════════════════════════════════════════════════════════
GridLayout {
Layout.fillWidth: true
Layout.preferredHeight: 188
Layout.maximumHeight: 188
columns: 3
rowSpacing: 8
columnSpacing: 8
// ── Connection Status: two cells wide, top-left ──
Rectangle { Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
color: Theme.cardBackground color: Theme.cardBackground
@@ -137,43 +199,24 @@ ColumnLayout {
radius: Theme.radiusSm radius: Theme.radiusSm
ColumnLayout { ColumnLayout {
id: connStatusCol anchors.fill: parent
anchors.left: parent.left anchors.margins: Theme.panelPadding
anchors.right: parent.right spacing: 6
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.panelPadding
anchors.rightMargin: Theme.panelPadding
spacing: 8
Text { Text {
text: "CONNECTION STATUS" text: "CONNECTION STATUS"
color: Theme.sideMutedText color: Theme.sideMutedText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.bold: true
font.letterSpacing: 1.2 font.letterSpacing: 1.5
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
} }
Item { Layout.fillHeight: true }
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 0 spacing: 8
ColumnLayout {
spacing: 1
Text {
text: "HOST PC"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
}
Text {
text: "localhost"
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
}
}
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
@@ -211,7 +254,7 @@ ColumnLayout {
id: statusPillText id: statusPillText
anchors.centerIn: parent anchors.centerIn: parent
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE") text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
color: "#111111" color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
font.weight: Font.Bold font.weight: Font.Bold
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
@@ -238,44 +281,97 @@ ColumnLayout {
} }
} }
ColumnLayout {
spacing: 1
Text {
text: "COM PORT"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
}
Text { Text {
text: root.portName text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight elide: Text.ElideMiddle
}
}
} }
} }
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ── Wafer & Sensor Data: far right, spans both rows ──
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%) Rectangle {
// ═══════════════════════════════════════════════════════════════════════ Layout.rowSpan: 2
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
RowLayout {
Layout.preferredWidth: 65
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: 8 color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4
Text {
text: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
Layout.bottomMargin: 2
}
GridLayout {
Layout.fillWidth: true
Layout.fillHeight: true
columns: 2
rowSpacing: 6
columnSpacing: 16
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
}
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Theme.cardBorder
opacity: 0.5
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
active: root.waferDetected
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
}
}
}
}
// ── Directory: bottom-left cell ──
Rectangle { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -289,38 +385,24 @@ ColumnLayout {
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: 6 spacing: 6
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
}
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
}
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 8 spacing: 8
Text {
text: "DIRECTORY"
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
Layout.fillWidth: true
}
Rectangle { Rectangle {
visible: root.csvPath !== "" visible: root.csvPath !== ""
width: sessionSavedLabel.implicitWidth + 12 implicitWidth: sessionSavedLabel.implicitWidth + 12
height: 18 implicitHeight: 18
radius: 4 radius: 4
color: Theme.fieldBackground color: Theme.fieldBackground
border.color: Theme.sideBorder border.color: Theme.sideBorder
@@ -337,17 +419,33 @@ ColumnLayout {
font.letterSpacing: 0.6 font.letterSpacing: 0.6
} }
} }
}
Item { Layout.fillWidth: true } Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
spacing: 12
// Path pinned bottom-left
Text {
text: deviceController.saveDataDir || "Not configured"
color: Theme.bodyColor
font.pixelSize: Theme.fontMd
font.family: Theme.uiFontFamily
font.italic: true
opacity: 0.7
elide: Text.ElideMiddle
Layout.fillWidth: true
}
Button { Button {
id: browseBtn id: browseBtn
text: "Save As" text: "Select Folder"
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
implicitHeight: 24 implicitHeight: 24
implicitWidth: 70
hoverEnabled: true hoverEnabled: true
background: Rectangle { background: Rectangle {
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
@@ -362,97 +460,25 @@ ColumnLayout {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
onClicked: saveDirDialog.open() onClicked: dirOnlyDialog.open()
}
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 4
Text {
text: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
Layout.bottomMargin: 2
}
GridLayout {
Layout.fillWidth: true
Layout.fillHeight: true
columns: 2
rowSpacing: 4
columnSpacing: 16
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
active: root.waferDetected
}
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
opacity: 0.5
}
GridCell {
id: waferSerialCell
label: "Serial"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 1 && info[1]) ? info[1] : "—";
}
}
GridCell {
id: waferSensorsCell
label: "Sensors"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
}
}
} }
} }
} }
} }
// ── Total Runtime: bottom center ──
StatCard { StatCard {
id: runtimeCard id: runtimeCard
Layout.preferredWidth: 35 label: "TOTAL RUNTIME"
value: { value: {
var info = deviceController.lastWaferInfo; var info = deviceController.lastWaferInfo;
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—"; if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
var secs = parseFloat(info[4]);
if (isNaN(secs)) return info[4] + "s";
var m = Math.floor(secs / 60);
var s = Math.round(secs % 60);
return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
} }
label: "TOTAL RUNTIME"
} }
} }
@@ -476,6 +502,16 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: 36 Layout.preferredHeight: 36
color: Theme.subtleSectionBackground color: Theme.subtleSectionBackground
radius: Theme.radiusMd
// Cover bottom corners so only top-left and top-right are rounded
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: parent.radius
color: parent.color
}
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
@@ -483,66 +519,27 @@ ColumnLayout {
anchors.rightMargin: Theme.panelPadding anchors.rightMargin: Theme.panelPadding
spacing: 8 spacing: 8
Text { IconImage {
text: "📄" source: "icons/file-text.svg"
font.pixelSize: Theme.fontSm width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.sideMutedText
visible: root.csvPath !== "" visible: root.csvPath !== ""
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
Label { Label {
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG" text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
color: Theme.bodyColor color: Theme.sideMutedText
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm font.pixelSize: Theme.fontMd
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8 font.letterSpacing: 1.5
font.weight: Font.Medium font.bold: true
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
elide: Text.ElideMiddle elide: Text.ElideMiddle
Layout.fillWidth: true Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
} }
Button {
id: refreshSessionBtn
text: "Refresh"
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: Text {
text: refreshSessionBtn.text
color: Theme.bodyColor
font: refreshSessionBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearSession()
}
Button {
id: clearLogBtn
text: "Clear"
font.pixelSize: Theme.fontSm
font.family: Theme.uiFontFamily
implicitHeight: 24
hoverEnabled: true
background: Rectangle {
color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: Text {
text: clearLogBtn.text
color: Theme.bodyColor
font: clearLogBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: deviceController.clearActivityLog()
}
} }
Rectangle { Rectangle {
@@ -563,7 +560,9 @@ ColumnLayout {
TextArea { TextArea {
id: activityLog id: activityLog
width: parent.width width: parent.width
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.") // Bound to backend so log survives tab switches (Loader
// destroys this tab); clears only via Clear Log / restart.
text: deviceController.activityLog || qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
readOnly: true readOnly: true
font.family: "monospace" font.family: "monospace"
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
@@ -574,12 +573,6 @@ ColumnLayout {
} }
} }
Connections {
target: deviceController
function onActivityLogUpdated(newLog) {
activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review.");
}
}
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -631,6 +624,13 @@ ColumnLayout {
root.dataRows = result.rows; root.dataRows = result.rows;
root.dataCols = result.cols; root.dataCols = result.cols;
root.csvPath = result.csv_path || ""; root.csvPath = result.csv_path || "";
if (root.csvPath) {
metaWafer.text = result.serialNumber || "";
metaDate.text = Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss");
metaChamber.text = settingsModel.chamberId || "";
metaNotes.text = "";
editCsvDialog.open();
}
} else { } else {
root.dataParsed = false; root.dataParsed = false;
} }
+84 -177
View File
@@ -1,6 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Tabs.components import ISC.Tabs.components
@@ -10,30 +10,26 @@ Item {
id: root id: root
anchors.fill: parent anchors.fill: parent
// Live elapsed-time counter // Wire streamController.trendDelta (Signal(str) carrying one new
property int _liveSecs: 0 // [elapsed_s, value] pair per live frame) into the trend chart, which
Timer { // accumulates its own bounded 60s window. trendReset clears it when a
id: liveTimer // new stream starts. Parsing happens on the Python TrendChartItem side
interval: 1000 // so malformed payloads are logged there instead of crashing the QML
repeat: true // binding.
running: streamController.mode === "live" && streamController.state !== "idle" property string loadErrorMessage: ""
onTriggered: root._liveSecs++
onRunningChanged: if (!running)
root._liveSecs = 0
}
function fmtTime(s) {
var m = Math.floor(s / 60);
var ss = s % 60;
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
}
// Wire streamController.trendData (Signal(str) carrying JSON list of floats)
// into the trend chart's data property. The slot parseJsonToData() is defined
// on the Python TrendChartItem; we use it so malformed payloads are logged
// there instead of crashing the QML binding.
Connections { Connections {
target: streamController target: streamController
function onTrendData(avgsJson) { function onTrendDelta(deltaJson) {
trendChart.setDataFromJson(avgsJson); trendChart.appendDelta(deltaJson);
}
function onTrendReset() {
trendChart.clearTrend();
}
function onLoadedFileChanged() {
root.loadErrorMessage = "";
}
function onLoadFileError(message) {
root.loadErrorMessage = message;
} }
} }
@@ -47,110 +43,6 @@ Item {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 10 spacing: 10
// Mode toggle
TabBar {
id: modeBar
currentIndex: 0 // Default to "Review"
spacing: 2
padding: 2
background: Rectangle {
color: Theme.subtleSectionBackground
radius: Theme.radiusSm
border.color: Theme.cardBorder
border.width: Theme.borderThin
}
TabButton {
text: "Review"
implicitWidth: 64
implicitHeight: 28
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
TabButton {
text: "Live"
enabled: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
implicitWidth: 58
implicitHeight: 28
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusSm - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
font.weight: parent.checked ? Font.Medium : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
onCurrentIndexChanged: if (currentIndex === 1) {
// Guard: revert to Review if not connected
if (deviceController.connectionStatus !== "Connected") {
currentIndex = 0;
return;
}
// Entering Live mode
streamController.setMode("live");
var fc = "";
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
fc = deviceController.lastWaferInfo[0];
}
streamController.startStream(deviceController.selectedPort, fc);
} else {
// Entering Review Mode (automatically calls stopStream in backend)
streamController.setMode("review");
}
}
// Live source info: WiFi icon + port · serial
RowLayout {
visible: streamController.mode === "live"
spacing: 5
// WiFi icon (Unicode approximation; swap for SVG if available)
Label {
id: liveIndicator
text: "◉"
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
font.pixelSize: Theme.fontSm
SequentialAnimation on opacity {
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
loops: Animation.Infinite
NumberAnimation {
to: 0.2
duration: 600
easing.type: Easing.InOutQuad
}
NumberAnimation {
to: 1.0
duration: 600
easing.type: Easing.InOutQuad
}
onRunningChanged: {
if (!running)
liveIndicator.opacity = 1.0;
}
}
}
Label {
text: "Live stream"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
}
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
} }
@@ -205,48 +97,40 @@ Item {
font.letterSpacing: 1.2 font.letterSpacing: 1.2
} }
} }
// Record start/stop toggle (Live mode)
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) // ── Load error banner ───────────────────────────────────────────
Button { Rectangle {
visible: streamController.mode === "live" Layout.fillWidth: true
text: "Stats" Layout.preferredHeight: errorRow.implicitHeight + 16
onClicked: streamStatsDialog.open() visible: root.loadErrorMessage !== ""
color: Theme.errorSurface
border.color: Theme.statusErrorColor
border.width: 1
radius: Theme.radiusSm
RowLayout {
id: errorRow
anchors.fill: parent
anchors.margins: 8
spacing: 8
IconImage {
source: "icons/triangle-alert.svg"
width: 16; height: 16
sourceSize.width: 16
sourceSize.height: 16
color: Theme.errorTextSoft
Layout.alignment: Qt.AlignTop
} }
StreamStatsDialog { Label {
id: streamStatsDialog text: root.loadErrorMessage
elapsedSecs: root._liveSecs color: Theme.errorTextSoft
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
} }
Button {
text: "Export PNG"
onClicked: exportDialog.open()
}
FileDialog {
id: exportDialog
title: "Export Wafer Map"
fileMode: FileDialog.SaveFile
nameFilters: ["PNG files (*.png)"]
onAccepted: waferView.exportImage(String(selectedFile).replace(/^file:\/\//, ""))
} }
} }
@@ -260,55 +144,78 @@ Item {
ColumnLayout { ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: 8 spacing: 16
WaferMapView { WaferMapView {
id: waferView id: waferView
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.preferredHeight: 400
Layout.minimumHeight: 280
blend: readoutPanel.heatmapBlend blend: readoutPanel.heatmapBlend
showLabels: readoutPanel.showLabels showLabels: readoutPanel.showLabels
showExtremes: readoutPanel.showExtremes
showThickness: readoutPanel.showThickness showThickness: readoutPanel.showThickness
} }
Rectangle { Rectangle {
id: trendPane id: trendPane
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 160 Layout.fillHeight: false
Layout.preferredHeight: 220
Layout.minimumHeight: 120
visible: trendChart.hasData && streamController.mode === "live" visible: trendChart.hasData && streamController.mode === "live"
color: Theme.cardBackground color: Theme.cardBackground
border.color: Theme.cardBorder border.color: Theme.cardBorder
border.width: 1 border.width: 1
radius: Theme.radiusMd radius: Theme.radiusMd
TrendChartItem { ColumnLayout {
id: trendChart
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: 8
spacing: 10
TrendChartItem {
id: trendChart
Layout.fillWidth: true
Layout.fillHeight: true
}
} }
} }
Item { Item {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 16 visible: transportBar.hasContent
implicitHeight: trendPane.visible ? 52 : 16
} }
TransportBar { TransportBar {
id: transportBar
Layout.fillWidth: true Layout.fillWidth: true
} }
} }
// Readout panel — floating wrapper box // Readout panel — scrolls instead of clipping so the last card
Rectangle { // (Thresholds) is always reachable even if content outgrows
Layout.preferredWidth: 220 // the available height (e.g. once E-C delta rows appear).
ScrollView {
Layout.preferredWidth: Theme.rightRailWidth
Layout.fillHeight: true Layout.fillHeight: true
color: "transparent"
border.color: "transparent"
border.width: 0
clip: true clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ReadoutPanel { ReadoutPanel {
id: readoutPanel id: readoutPanel
anchors.fill: parent width: parent.width
anchors.margins: 0 hasThicknessData: waferView.hasThickness
onExportRequested: function(filePath, extra) {
waferView.exportImage(filePath, extra);
}
onThicknessFileChosen: function(filePath) {
var err = waferView.loadThickness(filePath);
if (err !== "") {
root.loadErrorMessage = err;
readoutPanel.showThickness = false;
}
}
} }
} }
} }
@@ -0,0 +1,25 @@
import QtQuick
import QtQuick.Controls
import ISC
// Single seam for tooltip chrome — every hover tip in the app instantiates
// this instead of styling QtQuick.Controls ToolTip.background/contentItem
// inline at each call site.
ToolTip {
id: root
delay: 400
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
contentItem: Text {
text: root.text
color: Theme.headingColor
font: root.font
}
background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
}
@@ -0,0 +1,260 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts
import ISC
// Curve overlay chart card with the alignment timeline scrubber.
PanelBox {
id: card
required property var trendDataA
required property var trendDataB
required property int seriesLen
required property bool hasSeries
required property bool comparing
// Scrubber position — the internal Slider is private; consumers
// read/write frameIndex and call focusScrubber().
property alias frameIndex: scrubber.value
function focusScrubber() { scrubber.forceActiveFocus() }
implicitHeight: chartCol.implicitHeight + 24
ColumnLayout {
id: chartCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
SectionTitle {
text: "CURVE OVERLAY"
Layout.fillWidth: true
}
Row {
spacing: 16
visible: card.hasSeries
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 200
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true
Canvas {
id: overlayCanvas
anchors.fill: parent
visible: card.hasSeries
readonly property real padLeft: 50
readonly property real padRight: 16
readonly property real padTop: 24
readonly property real padBottom: 36
// Nice tick step (1/2/5 × 10^k) — same scheme as TrendChartItem,
// so labels land on round numbers instead of raw data min/max.
function niceStep(rawStep) {
var mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
return [1, 2, 5, 10].map(function (s) { return s * mag })
.find(function (s) { return s >= rawStep })
}
function drawGrid(ctx, minV, maxV, yStep, dataLen) {
var plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom
var range = (maxV - minV) || 1
ctx.strokeStyle = Theme.chartGridLine
ctx.lineWidth = 1
ctx.fillStyle = Theme.chartAxisText
ctx.font = "10px " + Theme.uiFontFamily
ctx.textAlign = "right"
var ySteps = Math.max(1, Math.round(range / yStep))
var yDec = Math.max(0, -Math.floor(Math.log10(yStep)))
for (var yi = 0; yi <= ySteps; yi++) {
var yFrac = yi / ySteps
var yPx = padTop + plotH * yFrac
var yVal = maxV - yFrac * range
ctx.beginPath()
ctx.moveTo(padLeft, yPx)
ctx.lineTo(padLeft + plotW, yPx)
ctx.stroke()
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
}
ctx.textAlign = "center"
var xMax = dataLen - 1
if (xMax > 0) {
var xStep = niceStep(xMax / 5 || 1)
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
var xPx = padLeft + (xIdx / xMax) * plotW
ctx.fillText(xIdx.toString(), xPx, height - 20)
}
}
ctx.save()
ctx.translate(14, padTop + plotH / 2)
ctx.rotate(-Math.PI / 2)
ctx.textAlign = "center"
ctx.font = "12px " + Theme.uiFontFamily
ctx.fillText("Celcius (°C)", 0, 0)
ctx.restore()
ctx.textAlign = "center"
ctx.font = "12px " + Theme.uiFontFamily
ctx.fillText("Frame", padLeft + plotW / 2, height - 4)
}
function drawSeries(ctx, series, minV, range, color, dataLen) {
// x is mapped against the shared frame domain (dataLen), not the
// series' own length — otherwise a shorter run's line stretches to
// fill the whole plot instead of stopping where its data ends.
if (series.length < 2) return
var plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
for (var i = 0; i < series.length; i++) {
var x = padLeft + (i / (dataLen - 1)) * plotW
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
function drawScrubMarker(ctx) {
if (card.seriesLen < 2) return
var plotW = width - padLeft - padRight
var x = padLeft + (scrubber.value / (card.seriesLen - 1)) * plotW
ctx.strokeStyle = Theme.primaryAccent
ctx.lineWidth = 1.5
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(x, padTop)
ctx.lineTo(x, height - padBottom)
ctx.stroke()
ctx.setLineDash([])
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
if (!card.hasSeries) return
var all = card.trendDataA.concat(card.trendDataB)
var minV = Math.min.apply(null, all)
var maxV = Math.max.apply(null, all)
var padding = (maxV - minV) * 0.05
minV -= padding
maxV += padding
// Snap bounds to nice-step multiples -> stable round-number axis
var yStep = niceStep((maxV - minV) / 5 || 1)
minV = Math.floor(minV / yStep) * yStep
maxV = Math.ceil(maxV / yStep) * yStep
var range = (maxV - minV) || 1
drawGrid(ctx, minV, maxV, yStep, card.seriesLen)
drawSeries(ctx, card.trendDataA, minV, range, Theme.primaryAccent, card.seriesLen)
drawSeries(ctx, card.trendDataB, minV, range, Theme.themeSkill, card.seriesLen)
drawScrubMarker(ctx)
}
Connections {
target: card
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
}
Connections {
target: scrubber
function onValueChanged() { overlayCanvas.requestPaint() }
}
}
// Empty state
ColumnLayout {
anchors.centerIn: parent
visible: !card.hasSeries
spacing: 8
IconImage {
Layout.alignment: Qt.AlignHCenter
visible: !card.comparing
source: "../icons/bar-chart.svg"
width: 32; height: 32
sourceSize.width: 32
sourceSize.height: 32
color: Theme.sideMutedText
}
Label {
Layout.alignment: Qt.AlignHCenter
text: card.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: !card.comparing
text: "Click a run box on the right, then select a file from the left panel."
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
}
}
}
// ── Alignment Timeline Scrubber ────────────
ColumnLayout {
Layout.fillWidth: true
spacing: 4
visible: card.hasSeries
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
SectionTitle {
text: "TIMELINE SCRUBBER"
Layout.fillWidth: true
Layout.topMargin: 8
}
Slider {
id: scrubber
Layout.fillWidth: true
leftPadding: overlayCanvas.padLeft
rightPadding: overlayCanvas.padRight
from: 0
to: Math.max(1, card.seriesLen - 1)
stepSize: 1
value: 0
}
}
}
}
@@ -0,0 +1,239 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// Right-column bento stack: DTW readout, overlap settings, frame/time
// controls, and scrubber readout. Pure view — all comparison state lives
// on DataTab; scrubber writes go up via frameIndexRequested.
ColumnLayout {
id: panel
required property real warpingDistance
required property int frameOffset
required property real frameOffsetSeconds
required property real maxSensorDeviation
required property int seriesLen
required property bool hasSeries
required property var trendDataA
required property var trendDataB
required property var timeDataA
required property var timeDataB
required property var frameForTime // function(seconds) -> frame index
required property real frameIndex // bound from ComparisonChartCard.frameIndex
// Overlap settings outputs (consumed by OverlapMapCard via DataTab)
property alias overlapEnabled: enableOverlapCheck.checked
property alias blendAmount: blendSlider.value
signal frameIndexRequested(real idx)
signal scrubberFocusRequested()
spacing: 12
// Bento 3: DTW Comparison Readout
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: readoutCol.implicitHeight + 24
ColumnLayout {
id: readoutCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
SectionTitle { text: "DTW READOUT" }
ReadoutStat {
label: "DTW ALIGNMENT DISTANCE"
value: panel.warpingDistance >= 0 ? panel.warpingDistance.toFixed(2) : "--"
valueColor: panel.warpingDistance >= 0
? (panel.warpingDistance < 50 ? Theme.metricGood : panel.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
: Theme.sideMutedText
}
ReadoutStat {
label: "TEMPORAL FRAME OFFSET"
value: panel.warpingDistance >= 0
? ((panel.frameOffset >= 0 ? "+" : "") + panel.frameOffset + " frames / "
+ (panel.frameOffsetSeconds >= 0 ? "+" : "") + panel.frameOffsetSeconds.toFixed(1) + "s")
: "--"
valueColor: panel.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
}
ReadoutStat {
label: "MAX SENSOR DEVIATION"
value: panel.maxSensorDeviation >= 0 ? panel.maxSensorDeviation.toFixed(2) + "°C" : "--"
valueColor: panel.maxSensorDeviation >= 0
? (panel.maxSensorDeviation < 1.0 ? Theme.metricGood : panel.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
: Theme.sideMutedText
}
}
}
// Bento 2: Overlap Map Settings
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: overlapCol.implicitHeight + 24
ColumnLayout {
id: overlapCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
SectionTitle { text: "OVERLAP SETTINGS" }
PanelCheckBox {
id: enableOverlapCheck
Layout.fillWidth: true
checked: true
text: "Enable Overlap Map"
font.pixelSize: Theme.fontSm
}
ColumnLayout {
Layout.fillWidth: true
spacing: 6
visible: enableOverlapCheck.checked
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Blend"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
Layout.preferredWidth: 40
}
PanelSlider {
id: blendSlider
Layout.fillWidth: true
from: 0; to: 100; value: 50
stepSize: 1
}
Label {
text: Math.round(blendSlider.value) + "%"
font.pixelSize: Theme.fontSm
color: Theme.sideMutedText
Layout.preferredWidth: 30
}
}
// Diff color legend
Row {
Layout.fillWidth: true
spacing: 10
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
Label { text: "B cooler"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
}
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Match"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
}
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
}
}
}
}
}
// Bento: Frame / Time Controls
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: frameTimeCol.implicitHeight + 32
ColumnLayout {
id: frameTimeCol
anchors.fill: parent
anchors.margins: 16
spacing: 20
SectionTitle { text: "FRAME / TIME" }
EditableScrubStat {
label: "Frame"
value: scrubReadout.scrubIdx
unit: "/ " + Math.max(0, panel.seriesLen - 1) + (Math.max(0, panel.seriesLen - 1) <= 1 ? " frame" : " frames")
editable: panel.hasSeries
onValueEdited: (newValue) => {
var maxVal = Math.max(0, panel.seriesLen - 1)
var val = Math.max(0, Math.min(newValue, maxVal))
panel.frameIndexRequested(val)
}
onEditingDone: panel.scrubberFocusRequested()
}
EditableScrubStat {
label: "Time"
value: scrubReadout.scrubTime
unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s"
editable: panel.hasSeries
onValueEdited: (newValue) => {
var closestIdx = panel.frameForTime(newValue)
panel.frameIndexRequested(closestIdx)
}
onEditingDone: panel.scrubberFocusRequested()
}
}
}
// Bento: Scrub Readout
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: scrubReadout.implicitHeight + 24
ColumnLayout {
id: scrubReadout
anchors.fill: parent
anchors.margins: 12
spacing: 8
readonly property int scrubIdx: Math.round(panel.frameIndex)
readonly property bool hasA: scrubIdx < panel.trendDataA.length
readonly property bool hasB: scrubIdx < panel.trendDataB.length
readonly property real tempA: hasA ? panel.trendDataA[scrubIdx] : 0
readonly property real tempB: hasB ? panel.trendDataB[scrubIdx] : 0
readonly property real scrubTime: {
var t = scrubIdx < panel.timeDataA.length ? panel.timeDataA[scrubIdx]
: (scrubIdx < panel.timeDataB.length ? panel.timeDataB[scrubIdx] : 0)
return t
}
readonly property real maxTime: {
var ta = panel.timeDataA.length > 0 ? panel.timeDataA[panel.timeDataA.length - 1] : 0
var tb = panel.timeDataB.length > 0 ? panel.timeDataB[panel.timeDataB.length - 1] : 0
return Math.max(ta, tb)
}
SectionTitle { text: "SCRUBBER READOUT" }
ReadoutStat {
label: "Temp (Run A)"
value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
valueColor: Theme.primaryAccent
}
ReadoutStat {
label: "Temp (Run B)"
value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
valueColor: Theme.themeSkill
}
ReadoutStat {
label: "Difference"
value: (scrubReadout.hasA && scrubReadout.hasB)
? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
}
}
}
Item { Layout.fillHeight: true }
}
@@ -0,0 +1,102 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// ── BOX 3: Hardware Status Footer ──────────────────────────
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterConnection
visible: root.selectedTabIndex !== 1
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
clip: true
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 6
// Row 1: Title
Label {
text: "CONNECTION FLOW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
}
// Row 2: Port / descriptive text
Label {
Layout.fillWidth: true
text: {
if (deviceController.connectionStatus === "Connected")
return deviceController.selectedPort || "Connected"
if (deviceController.selectedPort)
return deviceController.selectedPort
return "No port selected"
}
color: Theme.bodyColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
elide: Text.ElideRight
}
Item { Layout.fillHeight: true }
// Row 3: Mode chip left, status badge bottom-right
RowLayout {
spacing: 6
Layout.fillWidth: true
Rectangle {
width: modeChipLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Label {
id: modeChipLabel
anchors.centerIn: parent
text: streamController.mode
? streamController.mode.toUpperCase() : "REVIEW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.8
}
}
Item { Layout.fillWidth: true }
Rectangle {
height: 18
width: badgeLabel.implicitWidth + 14
radius: 9
color: {
var s = deviceController.connectionStatus
if (s === "Connected") return Theme.statusSuccessColor
if (s === "Disconnected") return Theme.statusErrorColor
return Theme.statusWarningColor
}
Label {
id: badgeLabel
anchors.centerIn: parent
text: deviceController.connectionStatus || "Disconnected"
color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs
font.bold: true
}
}
}
}
}
@@ -0,0 +1,113 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// Editable stat box for scrubber readout (styled to match DTW ReadoutCard)
Rectangle {
id: edStat
property string label
property real value
property string unit
property string extraText: ""
property bool editable: true
signal valueEdited(real newValue)
// Fires when the input field commits (Enter/focus loss), even if the
// entered text was invalid — callers use it to hand focus back to the
// timeline scrubber, matching the pre-extraction behavior.
signal editingDone()
Layout.fillWidth: true
implicitHeight: edStatRow.implicitHeight + 16
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: (edStat.editable && inputField.activeFocus) ? Theme.fieldBorderFocus : Theme.cardBorder
border.width: (edStat.editable && inputField.activeFocus) ? Theme.borderStrong : Theme.borderThin
RowLayout {
id: edStatRow
anchors.fill: parent
anchors.margins: 8
spacing: 6
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: edStat.label.toUpperCase()
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
RowLayout {
spacing: 4
TextField {
id: inputField
readOnly: !edStat.editable
hoverEnabled: edStat.editable
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
background: Rectangle {
visible: edStat.editable
color: Theme.fieldBackground
radius: Theme.radiusXs
border.color: inputField.activeFocus ? Theme.fieldBorderFocus : (inputField.hovered ? Theme.fieldBorderFocus : Theme.fieldBorder)
border.width: 1
}
padding: 0
leftPadding: edStat.editable ? 6 : 0
rightPadding: edStat.editable ? 6 : 0
topPadding: edStat.editable ? 5 : 0
bottomPadding: edStat.editable ? 5 : 0
selectByMouse: edStat.editable
inputMethodHints: edStat.label === "Time" ? Qt.ImhFormattedNumbersOnly : Qt.ImhDigitsOnly
Layout.preferredWidth: edStat.editable ? Math.max(36, contentWidth + 12) : contentWidth
Layout.preferredHeight: edStat.editable ? 32 : implicitHeight
text: edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
onEditingFinished: {
var val = parseFloat(text)
if (!isNaN(val)) {
edStat.valueEdited(val)
}
text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
edStat.editingDone()
}
Connections {
target: edStat
function onValueChanged() {
if (!inputField.activeFocus) {
inputField.text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
}
}
}
}
Label {
text: edStat.unit
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
Layout.alignment: Qt.AlignVCenter
}
}
}
Label {
visible: edStat.extraText !== ""
text: edStat.extraText
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: Theme.headingColor
Layout.alignment: Qt.AlignVCenter
}
}
}
@@ -0,0 +1,28 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
RowLayout {
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
spacing: 4
Label {
text: label
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
Layout.fillWidth: true
}
Label {
text: value
color: valueColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
@@ -0,0 +1,86 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts
import ISC
import ISC.Wafer
// Wafer overlap map card — renders per-sensor Run A/Run B temperature
// differences at the current scrub position.
PanelBox {
id: card
required property var sensorLayout
required property var diffValues
required property var diffBands // function(values) -> band thresholds
required property string waferShape
required property real waferSize
required property bool overlapEnabled
required property real blendAmount // 0100, percent
ColumnLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
SectionTitle {
text: "WAFER OVERLAP"
Layout.fillWidth: true
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true
WaferMapItem {
id: overlapMapItem
anchors.fill: parent
visible: card.sensorLayout.length > 0 && card.overlapEnabled
sensors: card.sensorLayout
values: card.diffValues
bands: card.diffBands(overlapMapItem.values)
shape: card.waferShape
size: card.waferSize
target: 0
margin: 1.0
blend: card.blendAmount / 100
showLabels: true
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange
highColor: Theme.sensorHigh
textColor: Theme.headingColor
}
ColumnLayout {
anchors.centerIn: parent
visible: card.sensorLayout.length === 0 || !card.overlapEnabled
spacing: 4
IconImage {
Layout.alignment: Qt.AlignHCenter
source: "../icons/map.svg"
width: 24; height: 24
sourceSize.width: 24
sourceSize.height: 24
color: Theme.sideMutedText
}
Label {
Layout.alignment: Qt.AlignHCenter
text: !card.overlapEnabled ? "Overlap map disabled"
: "No sensor map available"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
}
}
}
}
@@ -0,0 +1,10 @@
import QtQuick
import ISC
// Card container for the Data tab bento boxes.
Rectangle {
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.sidePanelRadius
}
@@ -0,0 +1,43 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import ISC
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
// indicator + check icon) instead of the platform-default CheckBox look.
CheckBox {
id: toggle
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
IconImage {
anchors.centerIn: parent
source: "../icons/check.svg"
width: 12; height: 12
sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
@@ -0,0 +1,19 @@
import QtQuick
import QtQuick.Controls
import ISC
// Slider styled to match MapTab's right-rail heatmap slider (circular
// accent handle that grows slightly on press).
Slider {
id: panelSlider
handle: Rectangle {
x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width)
y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2
implicitWidth: 18
implicitHeight: 18
radius: 9
color: Theme.primaryAccent
scale: panelSlider.pressed ? 1.15 : 1.0
Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } }
}
}
@@ -12,6 +12,9 @@ Rectangle {
property string label: "" property string label: ""
property string iconSource: "" property string iconSource: ""
property bool destructive: false property bool destructive: false
// Set true to show the button as "active" (e.g. a one-shot toggle
// that's mid-operation), independent of hover/press state.
property bool toggled: false
property color _textColor: !root.enabled ? Theme.sideMutedText property color _textColor: !root.enabled ? Theme.sideMutedText
: root.destructive ? Theme.statusErrorColor : root.destructive ? Theme.statusErrorColor
: Theme.headingColor : Theme.headingColor
@@ -20,7 +23,7 @@ Rectangle {
implicitHeight: Theme.sideButtonHeight implicitHeight: Theme.sideButtonHeight
radius: 8 radius: 8
color: mouseArea.containsMouse || mouseArea.pressed color: root.toggled || mouseArea.containsMouse || mouseArea.pressed
? Theme.sideActiveBackground : "transparent" ? Theme.sideActiveBackground : "transparent"
border.width: 0 border.width: 0
+117 -329
View File
@@ -1,61 +1,26 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
// ===== Readout Panel ===== // ===== Readout Panel =====
// Right-rail panel with three bordered sections: READOUT, DISPLAY, THRESHOLDS. // Right-rail panel with three bento-tile sections: READOUT, DISPLAY,
// Cards match the left-rail SOURCE / CONNECTION FLOW style. // THRESHOLDS. Uses the same PanelBox / SectionTitle / ReadoutStat /
// PanelCheckBox / PanelSlider components as the Data tab's
// ComparisonSidePanel, so both right rails share one visual language.
ColumnLayout { ColumnLayout {
id: root
spacing: 6 spacing: 6
property var s: streamController.stats property var s: streamController.stats
property alias showLabels: labelsToggle.checked property alias showLabels: labelsToggle.checked
property alias showExtremes: extremesToggle.checked
property alias heatmapBlend: heatmapSlider.value property alias heatmapBlend: heatmapSlider.value
property alias showThickness: thicknessToggle.checked property alias showThickness: thicknessToggle.checked
property bool hasThicknessData: false
component PanelCheckBox: CheckBox { signal exportRequested(string filePath, string extra)
id: toggle signal thicknessFileChosen(string filePath)
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durationFast }
}
Text {
anchors.centerIn: parent
text: "✓"
font.pixelSize: Theme.fontSm
font.bold: true
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
component BadgePill: Rectangle { component BadgePill: Rectangle {
implicitWidth: badgeLabel.implicitWidth + 10 implicitWidth: badgeLabel.implicitWidth + 10
@@ -75,279 +40,127 @@ ColumnLayout {
} }
} }
Rectangle { StreamControlPanel {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: readoutCard.implicitHeight + 24 onExportRequested: function(filePath, extra) {
color: Theme.sidePanelBackground root.exportRequested(filePath, extra);
radius: Theme.sidePanelRadius }
border.color: Theme.sideBorder }
border.width: 1
// ── READOUT ─────────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: readoutCol.implicitHeight + 24
ColumnLayout { ColumnLayout {
id: readoutCard id: readoutCol
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right spacing: 8
top: parent.top
topMargin: 12
}
spacing: 0
// Header Inside the Box SectionTitle { text: "READOUT" }
Label {
text: "READOUT" ReadoutStat {
color: Theme.sideMutedText label: "Min Temp"
font.family: Theme.uiFontFamily value: s.min !== undefined
font.pixelSize: Theme.fontXs ? s.min + ((s.minIndex !== undefined && s.minIndex >= 0) ? " #" + s.minIndex : "")
font.letterSpacing: 1.5 : "—"
font.bold: true valueColor: Theme.sensorLow
Layout.leftMargin: 14 }
Layout.rightMargin: 14 ReadoutStat {
Layout.topMargin: 2 label: "Max Temp"
Layout.bottomMargin: 10 value: s.max !== undefined
? s.max + ((s.maxIndex !== undefined && s.maxIndex >= 0) ? " #" + s.maxIndex : "")
: "—"
valueColor: Theme.sensorHigh
}
ReadoutStat {
label: "Diff"
value: s.diff !== undefined ? s.diff : "—"
valueColor: Theme.diffAccent
}
ReadoutStat {
label: "Average"
value: s.avg !== undefined ? s.avg : "—"
}
ReadoutStat {
label: "Sigma (Σ)"
value: s.sigma !== undefined ? s.sigma : "—"
}
ReadoutStat {
label: "3Σ Value"
value: s.threeSigma !== undefined ? s.threeSigma : "—"
}
ReadoutStat {
visible: s.ecMinDelta !== undefined
label: "E-C Δ Min"
value: s.ecMinDelta !== undefined
? s.ecMinDelta.toFixed(2) + " (#" + s.ecMinEdgeIndex + "→#" + s.ecMinCenterIndex + ")"
: "—"
}
ReadoutStat {
visible: s.ecMaxDelta !== undefined
label: "E-C Δ Max"
value: s.ecMaxDelta !== undefined
? s.ecMaxDelta.toFixed(2) + " (#" + s.ecMaxEdgeIndex + "→#" + s.ecMaxCenterIndex + ")"
: "—"
}
}
} }
// Min Temp Row // ── DISPLAY ─────────────────────────────────────────────────────
RowLayout { PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
Layout.leftMargin: 14 Layout.preferredHeight: displayCard.implicitHeight + 24
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "MIN TEMP"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.min !== undefined ? s.min : "—"
color: Theme.sensorLow
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
Label {
visible: (s.minIndex !== undefined) && s.minIndex >= 0
text: "#" + (s.minIndex !== undefined ? s.minIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
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.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: s.max !== undefined ? s.max : "—"
color: Theme.sensorHigh
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
Label {
visible: (s.maxIndex !== undefined) && s.maxIndex >= 0
text: "#" + (s.maxIndex !== undefined ? s.maxIndex : "")
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
Layout.alignment: Qt.AlignBottom
bottomPadding: 3
}
}
}
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.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.diff !== undefined ? s.diff : "—"
color: Theme.isDarkMode ? "#A78BFA" : "#8B5CF6"
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
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.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.avg !== undefined ? s.avg : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "SIGMA (Σ)"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.sigma !== undefined ? s.sigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
// 3-Sigma Row
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 14
Layout.rightMargin: 14
Layout.preferredHeight: 32
Label {
text: "3Σ VALUE"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: s.threeSigma !== undefined ? s.threeSigma : "—"
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontMd
font.bold: true
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: displayCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
id: displayCard id: displayCard
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right
top: parent.top
margins: 12
}
spacing: 8 spacing: 8
Label { SectionTitle { text: "DISPLAY" }
text: "DISPLAY"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
PanelCheckBox { PanelCheckBox {
id: thicknessToggle id: thicknessToggle
text: "Show Thickness" text: "Show Thickness"
checked: false checked: false
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
// First check with no data prompts for the customer CSV;
// unchecking only hides the overlay, data stays loaded.
onToggled: {
if (checked && !root.hasThicknessData)
thicknessDialog.open();
}
}
FileDialog {
id: thicknessDialog
title: "Select Thickness Data File"
nameFilters: ["CSV files (*.csv)"]
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
onRejected: thicknessToggle.checked = false
} }
PanelCheckBox { PanelCheckBox {
id: labelsToggle id: labelsToggle
text: "Labels" text: "Labels"
checked: true checked: true
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
}
PanelCheckBox {
id: extremesToggle
text: "Highlight Min/Max"
checked: true
font.pixelSize: Theme.fontSm
} }
PanelCheckBox { PanelCheckBox {
id: clusterAverageToggle id: clusterAverageToggle
text: "Average Clusters" text: "Average Clusters"
checked: streamController.clusterAveragingEnabled checked: streamController.clusterAveragingEnabled
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
onCheckedChanged: streamController.clusterAveragingEnabled = checked onCheckedChanged: streamController.clusterAveragingEnabled = checked
} }
@@ -363,35 +176,25 @@ ColumnLayout {
Label { Label {
text: "Heatmap" text: "Heatmap"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
Layout.preferredWidth: 52 Layout.preferredWidth: 52
} }
Slider { PanelSlider {
id: heatmapSlider id: heatmapSlider
from: 0 from: 0
to: 1 to: 1
value: 0 value: 0
Layout.fillWidth: true Layout.fillWidth: true
ToolTip.visible: hovered
ToolTip.text: Math.round(value * 100) + "%"
handle: Rectangle { AppToolTip {
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width) visible: heatmapSlider.hovered
y: heatmapSlider.topPadding + heatmapSlider.availableHeight / 2 - height / 2 text: Math.round(heatmapSlider.value * 100) + "%"
implicitWidth: 18
implicitHeight: 18
radius: 9
color: Theme.primaryAccent
scale: heatmapSlider.pressed ? 1.15 : 1.0
Behavior on scale {
NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard }
}
} }
} }
Label { Label {
text: Math.round(heatmapSlider.value * 100) + "%" text: Math.round(heatmapSlider.value * 100) + "%"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
Layout.preferredWidth: 32 Layout.preferredWidth: 32
horizontalAlignment: Text.AlignRight horizontalAlignment: Text.AlignRight
@@ -400,38 +203,23 @@ ColumnLayout {
} }
} }
Rectangle { // ── THRESHOLDS ──────────────────────────────────────────────────
PanelBox {
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: thresholdCard.implicitHeight + 24 Layout.preferredHeight: thresholdCard.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
ColumnLayout { ColumnLayout {
id: thresholdCard id: thresholdCard
anchors { anchors.fill: parent
left: parent.left anchors.margins: 12
right: parent.right
top: parent.top
margins: 12
}
spacing: 6 spacing: 6
Label { SectionTitle { text: "THRESHOLDS" }
text: "THRESHOLDS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 4
}
Label { Label {
text: "Set Point (°C)" text: "Set Point (°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
TextField { TextField {
id: spField id: spField
@@ -461,7 +249,7 @@ ColumnLayout {
Label { Label {
text: "Margin (±°C)" text: "Margin (±°C)"
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
} }
TextField { TextField {
id: mgField id: mgField
@@ -492,7 +280,7 @@ ColumnLayout {
id: autoCheck id: autoCheck
text: "Auto range (mean ± 1σ)" text: "Auto range (mean ± 1σ)"
checked: true checked: true
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
onCheckedChanged: pushThresholds() onCheckedChanged: pushThresholds()
} }
} }
@@ -0,0 +1,39 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// Small labeled value tile — DTW readout panel, scrubber readout, and any
// other compact label/value stat display share this one shape.
Rectangle {
id: readoutStat
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 52
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: readoutStat.label.toUpperCase()
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
Label {
text: readoutStat.value
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: readoutStat.valueColor
}
}
}
@@ -10,6 +10,10 @@ Dialog {
standardButtons: Dialog.NoButton standardButtons: Dialog.NoButton
width: 300 width: 300
// Anchor to the top-left of the window instead of the default centered position.
x: 24
y: 24
// called by WaferMapView's TapHandler // called by WaferMapView's TapHandler
property int sensorIndex: -1 property int sensorIndex: -1
property string sensorLabel: "" property string sensorLabel: ""
@@ -60,7 +64,7 @@ Dialog {
id: overrideTag id: overrideTag
anchors.centerIn: parent anchors.centerIn: parent
text: "OVERRIDE" text: "OVERRIDE"
color: "white" color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontXs
font.bold: true font.bold: true
font.letterSpacing: 1 font.letterSpacing: 1
+135
View File
@@ -0,0 +1,135 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
import ISC.Wafer
// ===== Run Chart =====
// Zoomable whole-run multi-sensor chart (C# parity: PopupChartForm.cs).
// Lives in the Graph tab. Mouse wheel zooms around the cursor, drag pans;
// the strip below shows the aggregate min/max/avg over the *visible* range
// (not the whole run), matching PopupChartForm's recalc_stats. Cursor/
// playhead tracking is out of scope — see specs/plans/2026-07-10-popup-chart.md
// and docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
Item {
id: root
property alias sensorNames: chart.sensorNames
property alias seriesData: chart.seriesData
property alias title: chart.title
ColumnLayout {
anchors.fill: parent
spacing: 8
Item {
Layout.fillWidth: true
Layout.fillHeight: true
GraphQuickItem {
id: chart
anchors.fill: parent
showMinMaxMarkers: true
xLabel: "Measurement Interval"
yLabel: "Temperature (°C)"
}
MouseArea {
id: interaction
anchors.fill: parent
acceptedButtons: Qt.LeftButton
property real lastX: 0
onWheel: function(wheel) {
var frac = Math.max(0, Math.min(1, wheel.x / width));
var factor = wheel.angleDelta.y > 0 ? 0.8 : 1.25;
chart.zoomAtFraction(frac, factor);
wheel.accepted = true;
}
onPressed: function(mouse) {
lastX = mouse.x;
}
onPositionChanged: function(mouse) {
if (pressed && width > 0) {
chart.panByFraction(-(mouse.x - lastX) / width);
lastX = mouse.x;
}
}
}
}
// ── Min/Max/Avg readout strip + Reset Zoom ─────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 16
Label {
text: "Min:"
color: Theme.sensorLow
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMin.toFixed(2) + (chart.viewMinSensor ? " (" + chart.viewMinSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Max:"
color: Theme.sensorHigh
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewMax.toFixed(2) + (chart.viewMaxSensor ? " (" + chart.viewMaxSensor + ")" : "")
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Label {
text: "Avg:"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
Label {
text: chart.viewAvg.toFixed(2)
color: Theme.headingColor
font.family: Theme.codeFontFamily
font.pixelSize: Theme.fontSm
}
Item { Layout.fillWidth: true }
Button {
id: resetZoomBtn
implicitHeight: 32
hoverEnabled: true
text: "Reset Zoom"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
background: Rectangle {
radius: Theme.radiusSm
color: resetZoomBtn.pressed ? Theme.transportButtonHover : Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
}
contentItem: Text {
text: resetZoomBtn.text
color: Theme.headingColor
font: resetZoomBtn.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: chart.resetZoom()
}
}
}
}
@@ -0,0 +1,12 @@
import QtQuick
import QtQuick.Controls
import ISC
// Uppercase card heading — shared by the Data tab cards.
Label {
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
color: Theme.sideMutedText
}
@@ -1,5 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import Qt.labs.qmlmodels import Qt.labs.qmlmodels
import ISC import ISC
@@ -181,10 +182,17 @@ Dialog {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 12 anchors.rightMargin: 12
text: "✕"
implicitWidth: 32 implicitWidth: 32
implicitHeight: 32 implicitHeight: 32
onClicked: root.close() onClicked: root.close()
contentItem: IconImage {
source: "../icons/x.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.buttonNeutralText
anchors.centerIn: parent
}
} }
} }
+124 -51
View File
@@ -46,24 +46,9 @@ ColumnLayout {
csvEditorDialog.open(); csvEditorDialog.open();
} }
ToolTip { AppToolTip {
id: editTooltip
visible: editBtn.hovered visible: editBtn.hovered
text: "Edit" text: "Edit"
delay: 400
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
contentItem: Text {
text: editTooltip.text
color: Theme.headingColor
font: editTooltip.font
}
background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
} }
} }
@@ -84,24 +69,9 @@ ColumnLayout {
} }
onClicked: file_browser.refreshFiles() onClicked: file_browser.refreshFiles()
ToolTip { AppToolTip {
id: refreshTooltip
visible: refreshBtn.hovered visible: refreshBtn.hovered
text: "Refresh" text: "Refresh"
delay: 400
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
contentItem: Text {
text: refreshTooltip.text
color: Theme.headingColor
font: refreshTooltip.font
}
background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
} }
} }
} }
@@ -122,10 +92,12 @@ ColumnLayout {
} }
contentItem: RowLayout { contentItem: RowLayout {
spacing: 6 spacing: 6
Label { IconImage {
text: "▤" source: "../icons/folder.svg"
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm
} }
Label { Label {
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
@@ -188,23 +160,52 @@ ColumnLayout {
return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0; return (modelData.baseName + modelData.waferType + modelData.date).toLowerCase().indexOf(q) >= 0;
} }
// Active highlight logic: on Data tab, highlight compareFileA/B; otherwise highlight loadedFile (for Map tab). readonly property var dataTab: {
readonly property bool isActive:{
try { try {
if (root.selectedTabIndex === 1 && dataTabLoader.item) { return (root.selectedTabIndex === 1 && dataTabLoader.item) ? dataTabLoader.item : null;
return modelData.fileName === dataTabLoader.item.compareFileA || modelData.fileName === dataTabLoader.item.compareFileB; } catch (e) { return null; }
} }
} catch (e) {}
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile; // Run A's effective file: the master file when "Compare vs Master
// File" is toggled on, otherwise the manually selected file.
readonly property string effectiveRunAFile: fileItem.dataTab
? (fileItem.dataTab.useMasterFile ? fileItem.dataTab.masterFileForRunB : fileItem.dataTab.compareFileA)
: ""
readonly property bool isRunA: fileItem.dataTab !== null
&& fileItem.effectiveRunAFile !== "" && modelData.fileName === fileItem.effectiveRunAFile
readonly property bool isRunB: fileItem.dataTab !== null
&& modelData.fileName === fileItem.dataTab.compareFileB
// Active highlight logic: on Data tab, highlight the Run A/Run B files (or the
// resolved master file standing in for Run A); otherwise highlight loadedFile (Map tab).
readonly property bool isActive: fileItem.dataTab !== null
? (fileItem.isRunA || fileItem.isRunB)
: (streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile)
// A file registered as any family's master in Settings → Master Files.
readonly property bool isMasterFile: {
var m = settingsModel.masters
for (var fam in m) {
if (m[fam] && m[fam] === modelData.fileName) return true
} }
return false
}
readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile
visible: matchesFilter visible: matchesFilter
height: matchesFilter ? implicitHeight : 0 height: matchesFilter ? implicitHeight : 0
enabled: !liveFileBlocked
opacity: liveFileBlocked ? 0.45 : 1.0
Behavior on opacity {
NumberAnimation { duration: Theme.durationFast }
}
onClicked: streamController.loadFile(modelData.fileName) onClicked: streamController.loadFile(modelData.fileName)
background: Rectangle { background: Rectangle {
color: fileItem.isActive ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent" color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
radius: Theme.radiusSm radius: Theme.radiusSm
Behavior on color { Behavior on color {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
@@ -223,13 +224,15 @@ ColumnLayout {
bottomMargin: 5 bottomMargin: 5
} }
color: { color: {
if (fileItem.isRunA) return Theme.primaryAccent;
if (fileItem.isRunB) return Theme.themeSkill;
var t = modelData.waferType; var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") if (t === "A" || t === "E" || t === "P")
return "#3B82F6"; return Theme.familyBlueAccent;
if (t === "B" || t === "C" || t === "D") if (t === "B" || t === "C" || t === "D")
return "#10B981"; return Theme.familyGreenAccent;
if (t === "Z") if (t === "Z")
return "#8B5CF6"; return Theme.familyVioletAccent;
return Theme.headingColor; return Theme.headingColor;
} }
} }
@@ -249,19 +252,46 @@ ColumnLayout {
color: { color: {
var t = modelData.waferType; var t = modelData.waferType;
if (t === "A" || t === "E" || t === "P") if (t === "A" || t === "E" || t === "P")
return "#1D4ED8"; return Theme.familyBlueFill;
if (t === "B" || t === "C" || t === "D") if (t === "B" || t === "C" || t === "D")
return "#065F46"; return Theme.familyGreenFill;
if (t === "Z") if (t === "Z")
return "#7C3AED"; return Theme.familyVioletFill;
return "#374151"; return Theme.familyNeutralFill;
} }
Label { Label {
anchors.centerIn: parent anchors.centerIn: parent
text: modelData.waferType || "?" text: modelData.waferType || "?"
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
color: "#FFFFFF" color: Theme.textOnColor
}
// Recording indicator dot — distinguishes a live-recorded CSV
// (SessionController.startRecording) from a read-memory dump
// (DeviceController.parseAndSaveData).
Rectangle {
visible: modelData.isRecording === true
width: 10
height: 10
radius: 5
color: Theme.recordColor
border.color: Theme.subtleSectionBackground
border.width: 1.5
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: -2
anchors.rightMargin: -2
}
// Accent ring — this file is registered as a master (Settings → Master Files).
Rectangle {
visible: fileItem.isMasterFile
anchors.fill: parent
radius: parent.radius
color: "transparent"
border.color: Theme.primaryAccent
border.width: 2
} }
} }
@@ -270,7 +300,11 @@ ColumnLayout {
spacing: 1 spacing: 1
Layout.fillWidth: true Layout.fillWidth: true
// Primary: wafer type + serial number // Primary: wafer type + serial number, plus a REC badge for recordings
RowLayout {
Layout.fillWidth: true
spacing: 6
Label { Label {
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
color: Theme.headingColor color: Theme.headingColor
@@ -280,6 +314,45 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
} }
Rectangle {
visible: modelData.isRecording === true
radius: 4
color: Qt.alpha(Theme.recordColor, 0.18)
implicitWidth: recLabel.implicitWidth + 10
implicitHeight: recLabel.implicitHeight + 4
Layout.alignment: Qt.AlignVCenter
Label {
id: recLabel
anchors.centerIn: parent
text: "REC"
color: Theme.recordColor
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.6
}
}
Rectangle {
visible: fileItem.isMasterFile
radius: 4
color: Qt.alpha(Theme.primaryAccent, 0.18)
implicitWidth: masterLabel.implicitWidth + 10
implicitHeight: masterLabel.implicitHeight + 4
Layout.alignment: Qt.AlignVCenter
Label {
id: masterLabel
anchors.centerIn: parent
text: "MASTER"
color: Theme.primaryAccent
font.pixelSize: Theme.font2xs
font.bold: true
font.letterSpacing: 0.6
}
}
}
// Secondary: date · time // Secondary: date · time
RowLayout { RowLayout {
spacing: 3 spacing: 3
@@ -212,6 +212,7 @@ Popup {
Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm } Label { text: "START (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm } Label { text: "END (s)"; Layout.preferredWidth: 120; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm } Label { text: "AVG TEMP (°C)"; Layout.fillWidth: true; color: Theme.sideMutedText; font.bold: true; font.pixelSize: Theme.fontSm }
Item { Layout.preferredWidth: 80 }
} }
} }
@@ -267,6 +268,31 @@ Popup {
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
} }
Button {
id: exportBtn
text: "Export"
Layout.preferredWidth: 80
Layout.preferredHeight: 26
hoverEnabled: true
onClicked: streamController.exportSegment(index)
background: Rectangle {
radius: Theme.radiusXs
color: exportBtn.pressed ? Theme.buttonNeutralPressed
: exportBtn.hovered ? Theme.buttonNeutralHover
: Theme.buttonNeutralBackground
border.color: Theme.fieldBorder
border.width: 1
}
contentItem: Label {
text: exportBtn.text
color: Theme.buttonNeutralText
font.pixelSize: Theme.fontXs
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
} }
} }
} }
@@ -295,6 +321,16 @@ Popup {
color: Theme.bodyColor color: Theme.bodyColor
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
} }
// ── Export feedback ───────────────────────────────────────
Label {
id: exportStatus
visible: text !== ""
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
elide: Text.ElideMiddle
Layout.fillWidth: true
}
} }
// ── Backend Connection ──────────────────────────────────────── // ── Backend Connection ────────────────────────────────────────
@@ -302,6 +338,7 @@ Popup {
target: streamController target: streamController
function onSplitResult(result) { function onSplitResult(result) {
root.segmenting = false; root.segmenting = false;
exportStatus.text = "";
if (result && result.success) { if (result && result.success) {
root.segments = result.segments || []; root.segments = result.segments || [];
console.log("[SplitDialog] Segmented into", root.segments.length, "phases"); console.log("[SplitDialog] Segmented into", root.segments.length, "phases");
@@ -310,6 +347,13 @@ Popup {
console.log("[SplitDialog] Split failed:", result.error || "unknown"); console.log("[SplitDialog] Split failed:", result.error || "unknown");
} }
} }
function onSegmentExported(result) {
if (result && result.success)
exportStatus.text = "Exported: " + result.path.split("/").pop();
else
exportStatus.text = "Export failed: " + (result.error || "unknown");
}
} }
} }
@@ -0,0 +1,115 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts
import ISC
import ISC.Tabs.components
// ── Status tab: Hardware Actions + Log Actions ──────────
ColumnLayout {
spacing: Theme.sideRailSpacing
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
id: hwActionsCol
anchors.fill: parent
anchors.margins: 14
spacing: 6
Label {
text: "HARDWARE ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "DETECT WAFER"
iconSource: "../icons/detect.svg"
Layout.fillWidth: true
enabled: !deviceController.operationInProgress
onClicked: root._doDetect()
}
RailActionButton {
label: "READ MEMORY"
iconSource: "../icons/read.svg"
Layout.fillWidth: true
enabled: deviceController.waferDetected && !deviceController.operationInProgress
onClicked: deviceController.readMemoryAsync()
}
RailActionButton {
label: "ERASE MEMORY"
iconSource: "../icons/erase.svg"
Layout.fillWidth: true
destructive: true
enabled: deviceController.waferDetected && !deviceController.operationInProgress
onClicked: deviceController.eraseMemory(
deviceController.selectedPort)
}
RailActionButton {
label: "READ DEBUG"
iconSource: "../icons/read.svg"
Layout.fillWidth: true
// One-shot toggle: "on" while the debug read is in flight,
// resets automatically once deviceController flips status.
toggled: deviceController.connectionStatus === "Reading debug..."
enabled: deviceController.waferDetected && !deviceController.operationInProgress
onClicked: deviceController.readDebug(deviceController.selectedPort)
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: logActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
id: logActionsCol
anchors.fill: parent
anchors.margins: 14
spacing: 6
Label {
text: "LOG ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "REFRESH SESSION"
iconSource: "../icons/refresh.svg"
Layout.fillWidth: true
enabled: !deviceController.operationInProgress
onClicked: deviceController.clearSession()
}
RailActionButton {
label: "CLEAR LOG"
iconSource: "../icons/x.svg"
Layout.fillWidth: true
onClicked: deviceController.clearActivityLog()
}
}
}
Item { Layout.fillHeight: true }
}
@@ -0,0 +1,374 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Dialogs
import QtQuick.Layouts
import ISC
Rectangle {
id: root
Layout.fillWidth: true
implicitHeight: contentCol.implicitHeight + 24
color: Theme.sidePanelBackground
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
signal exportRequested(string filePath, string extra)
property int _liveSecs: 0
Connections {
target: streamController
function onModeChanged() {
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
modeBar.currentIndex = 0;
}
}
}
Timer {
id: liveTimer
interval: 1000
repeat: true
running: streamController.mode === "live" && streamController.state !== "idle"
onTriggered: root._liveSecs++
onRunningChanged: if (!running) root._liveSecs = 0
}
function fmtTime(s) {
var m = Math.floor(s / 60);
var ss = s % 60;
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
}
function switchToReview() {
modeBar.currentIndex = 0;
}
ColumnLayout {
id: contentCol
anchors {
fill: parent
topMargin: 10
leftMargin: 12
rightMargin: 12
bottomMargin: 12
}
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 6
TabBar {
id: modeBar
currentIndex: 0
spacing: 2
padding: 2
Layout.fillWidth: true
Layout.preferredHeight: 28
background: Rectangle {
color: Theme.subtleSectionBackground
radius: Theme.radiusXs
border.color: Theme.cardBorder
border.width: 1
}
TabButton {
text: "Review"
Layout.fillWidth: true
implicitHeight: 24
font.pixelSize: Theme.fontMd
font.weight: parent.checked ? Font.Medium : Font.Normal
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusXs - 1
}
contentItem: Text {
text: parent.text
color: parent.checked ? Theme.headingColor : Theme.bodyColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
TabButton {
id: liveTab
text: "Live"
enabled: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
Layout.fillWidth: true
implicitHeight: 24
font.pixelSize: Theme.fontMd
font.weight: parent.checked ? Font.Medium : Font.Normal
AppToolTip {
visible: disabledHover.containsMouse && !liveTab.enabled
text: "Connect a wafer to enable Live mode"
delay: 300
}
MouseArea {
id: disabledHover
anchors.fill: parent
enabled: !liveTab.enabled
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
background: Rectangle {
color: parent.checked ? Theme.tabActiveBackground : "transparent"
radius: Theme.radiusXs - 1
}
contentItem: Item {
Row {
spacing: 4
anchors.centerIn: parent
Rectangle {
id: liveTabDot
width: 5; height: 5; radius: 2.5
anchors.verticalCenter: parent.verticalCenter
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
SequentialAnimation on opacity {
running: streamController.mode === "live" && streamController.state !== "idle"
loops: Animation.Infinite
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
}
}
Text {
text: liveTab.text
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
font: liveTab.font
verticalAlignment: Text.AlignVCenter
height: parent.height
}
}
}
}
onCurrentIndexChanged: {
if (currentIndex === 1) {
if (deviceController.connectionStatus !== "Connected") {
currentIndex = 0;
return;
}
streamController.setMode("live");
var fc = "";
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
fc = deviceController.lastWaferInfo[0];
}
streamController.startStream(deviceController.selectedPort, fc);
} else {
streamController.setMode("review");
}
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
Label {
text: "METRICS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 2
}
MetricRow {
label: "Received Frames"
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
}
MetricRow {
label: "Errors"
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
}
MetricRow {
label: "Resyncs"
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
}
MetricRow {
label: "Elapsed"
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
Label {
text: "ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.letterSpacing: 1.5
font.bold: true
Layout.bottomMargin: 2
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Button {
id: recordBtn
Layout.fillWidth: true
implicitHeight: 28
enabled: streamController.mode === "live"
opacity: enabled ? 1.0 : 0.4
text: streamController.recording ? "Stop REC" : "Record"
AppToolTip {
visible: recordBtn.hovered && !recordBtn.enabled
text: "Connect to Live to start recording"
delay: 300
}
onClicked: {
if (streamController.recording) {
streamController.stopRecording();
} else {
var info = deviceController.lastWaferInfo;
var serial = (info && info.length > 1) ? info[1] : "";
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
}
}
background: Rectangle {
radius: Theme.radiusSm
color: recordBtn.down ? Theme.transportButtonHover
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
}
contentItem: Text {
text: recordBtn.text
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
id: exportBtn
Layout.fillWidth: true
implicitHeight: 28
text: "Export"
// Nothing to export until a file is loaded or a stream has played
enabled: streamController.sensorValues.length > 0
opacity: enabled ? 1 : 0.4
onClicked: {
// Default into <saveDataDir>/Export (created on demand)
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
exportDialog.open()
}
background: Rectangle {
radius: Theme.radiusSm
color: exportBtn.down ? Theme.transportButtonHover
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
}
contentItem: Text {
text: exportBtn.text
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
FileDialog {
id: exportDialog
title: "Export Wafer Map"
fileMode: FileDialog.SaveFile
defaultSuffix: "png"
nameFilters: ["PNG files (*.png)"]
onAccepted: {
var path = String(selectedFile).replace(/^file:\/\//, "");
var extra = "";
if (streamController.mode === "live") {
extra = "Frames: " + streamController.receivedCount
+ " Errors: " + streamController.errorCount
+ " Resyncs: " + streamController.resyncCount
+ " Elapsed: " + root.fmtTime(root._liveSecs);
}
root.exportRequested(path, extra);
}
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
Button {
id: primaryBtn
Layout.fillWidth: true
implicitHeight: 32
text: streamController.mode === "live" ? "STOP" : "START"
enabled: streamController.mode === "live"
? true
: deviceController.connectionStatus === "Connected"
opacity: enabled ? 1.0 : 0.4
AppToolTip {
visible: primaryBtn.hovered && !primaryBtn.enabled
text: "Connect a wafer to enable Live mode"
delay: 300
}
onClicked: {
if (streamController.mode === "live") {
streamController.stopStream();
modeBar.currentIndex = 0;
} else {
modeBar.currentIndex = 1;
}
}
background: Rectangle {
radius: Theme.radiusSm
color: primaryBtn.down ? Theme.transportButtonHover
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
border.width: Theme.borderThin
border.color: Theme.sideBorder
}
contentItem: Text {
text: primaryBtn.text
color: Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.letterSpacing: 1.0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
@@ -1,128 +0,0 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import ISC
// ===== Stream Stats Dialog =====
// Popup showing live stream statistics: received frames, errors,
// resync count, and elapsed time. Opened from the Live toolbar.
Popup {
id: root
modal: true
dim: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
width: 320
implicitHeight: content.implicitHeight + 40
anchors.centerIn: Overlay.overlay
// Elapsed seconds supplied by the Live timer in WaferMapTab.
property int elapsedSecs: 0
function fmtTime(s) {
var m = Math.floor(s / 60);
var ss = s % 60;
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
}
background: Rectangle {
radius: Theme.radiusMd
color: Theme.cardBackground
border.color: Theme.cardBorder
border.width: 1
}
component StatRow: RowLayout {
property string label
property string value
Layout.fillWidth: true
spacing: 8
Label {
text: label
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
Layout.fillWidth: true
}
Label {
text: value
color: Theme.headingColor
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
}
}
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 20
spacing: 16
// ── Header ────────────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
spacing: 10
Label {
text: "STREAM DATA"
font.pixelSize: Theme.fontLg
font.bold: true
color: Theme.headingColor
Layout.fillWidth: true
}
Button {
text: "✕"
flat: true
Layout.preferredWidth: 32
Layout.preferredHeight: 32
onClicked: root.close()
background: Rectangle {
radius: Theme.radiusXs
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
}
contentItem: Label {
text: parent.text
color: Theme.bodyColor
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// ── Stats box ─────────────────────────────────────────────
Rectangle {
Layout.fillWidth: true
implicitHeight: statRows.implicitHeight + 20
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
id: statRows
anchors.fill: parent
anchors.margins: 10
spacing: 8
StatRow {
label: "Received frames"
value: streamController.receivedCount
}
StatRow {
label: "Errors"
value: streamController.errorCount
}
StatRow {
label: "Resyncs"
value: streamController.resyncCount
}
StatRow {
label: "Elapsed"
value: root.fmtTime(root.elapsedSecs)
}
}
}
}
}
+90 -145
View File
@@ -1,11 +1,14 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
// ===== Control Bar ===== // ===== Control Bar =====
// Mode-aware footer: cross-fades between Live and Review layouts. // Review-mode transport footer (frame counter, play/pause/step, speed).
// Both variants render as a single centered pill — no full-width bar. // Live-mode's Received/Errors/Stop moved to ReadoutPanel's "LIVE STREAM"
// card so this bar can collapse to zero height and free vertical space for
// the trend chart while streaming.
Item { Item {
id: bar id: bar
Layout.fillWidth: true Layout.fillWidth: true
@@ -15,120 +18,13 @@ Item {
} }
clip: true clip: true
// Hide entirely when in review mode with no file loaded // Only shown in review mode, and only once a file is loaded
readonly property bool hasContent: { readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
if (streamController.mode === "live") return true;
return streamController.loadedFile !== "";
}
readonly property bool isLive: streamController.mode === "live"
// ── LIVE MODE ─────────────────────────────────────────────────
Item {
id: liveContent
anchors.fill: parent
opacity: bar.isLive ? 1.0 : 0.0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
// Single centered pill for Received + Errors + Stop
Rectangle {
anchors.centerIn: parent
implicitWidth: liveRow.implicitWidth + 48
implicitHeight: 68
radius: Theme.radiusMd
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
Row {
id: liveRow
anchors.centerIn: parent
spacing: 24
// Received counter
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Received: " + streamController.receivedCount
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
}
// Separator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// Errors counter
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Errors: " + streamController.errorCount
color: streamController.errorCount > 0
? Theme.statusWarningColor : Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
}
// Separator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 36
color: Theme.sideBorder
}
// STOP button
Button {
id: stopBtn
anchors.verticalCenter: parent.verticalCenter
implicitWidth: 112
implicitHeight: 48
hoverEnabled: true
text: "STOP"
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.weight: Font.Medium
font.letterSpacing: 1.0
background: Rectangle {
radius: 8
color: stopBtn.pressed ? Theme.transportButtonHover
: Theme.transportButtonBg
border.color: Theme.sideBorder
border.width: 1
Behavior on color {
ColorAnimation { duration: Theme.durationFast }
}
}
contentItem: Text {
text: parent.text
color: Theme.headingColor
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: streamController.stopStream()
}
}
}
}
// ── REVIEW MODE ────────────────────────────────────────────── // ── REVIEW MODE ──────────────────────────────────────────────
Item { Item {
id: reviewContent id: reviewContent
anchors.fill: parent anchors.fill: parent
opacity: !bar.isLive ? 1.0 : 0.0
visible: opacity > 0
Behavior on opacity {
NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard }
}
// Single centered pill for frame counter + transport + speed // Single centered pill for frame counter + transport + speed
Rectangle { Rectangle {
@@ -146,15 +42,69 @@ Item {
spacing: 24 spacing: 24
// Frame counter // Frame counter
Text { Row {
spacing: 6
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Frame " + (streamController.frameIndex + 1)
+ " / " + streamController.frameTotal Label {
text: "Frame"
color: Theme.sideMutedText color: Theme.sideMutedText
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Medium font.weight: Font.Medium
leftPadding: 8 anchors.verticalCenter: parent.verticalCenter
}
TextField {
id: frameInput
text: String(streamController.frameIndex + 1)
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
font.family: Theme.codeFontFamily
color: Theme.headingColor
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
selectByMouse: true
inputMethodHints: Qt.ImhDigitsOnly
padding: 0
width: Math.max(44, contentWidth + 16)
height: 28
anchors.verticalCenter: parent.verticalCenter
background: Rectangle {
color: Theme.fieldBackground
radius: Theme.radiusXs
border.color: frameInput.activeFocus ? Theme.fieldBorderFocus : (frameInput.hovered ? Theme.fieldBorderFocus : Theme.sideBorder)
border.width: 1
}
onEditingFinished: {
var val = parseInt(text)
if (!isNaN(val)) {
var targetFrame = Math.max(1, Math.min(val, streamController.frameTotal))
streamController.seek(targetFrame - 1)
}
text = String(streamController.frameIndex + 1)
}
Connections {
target: streamController
function onFrameUpdated() {
if (!frameInput.activeFocus) {
frameInput.text = String(streamController.frameIndex + 1)
}
}
}
}
Label {
text: "/ " + streamController.frameTotal
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontMd
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
} }
// Separator 1 // Separator 1
@@ -177,8 +127,6 @@ Item {
implicitWidth: 56 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: "⏮"
font.pixelSize: 24
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: skipStartBtn.pressed ? Theme.transportButtonHover color: skipStartBtn.pressed ? Theme.transportButtonHover
@@ -188,12 +136,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/prev.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: streamController.step(-1) onClicked: streamController.step(-1)
} }
@@ -205,8 +154,6 @@ Item {
implicitWidth: 56 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: "■"
font.pixelSize: 22
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: reviewStopBtn.pressed ? Theme.transportButtonHover color: reviewStopBtn.pressed ? Theme.transportButtonHover
@@ -216,12 +163,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/stop.svg"
width: 20; height: 20
sourceSize.width: 20
sourceSize.height: 20
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: streamController.stop() onClicked: streamController.stop()
} }
@@ -230,11 +178,9 @@ Item {
Button { Button {
id: playPauseBtn id: playPauseBtn
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
implicitWidth: 64 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: streamController.playing ? "⏸" : "▶"
font.pixelSize: 24
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: playPauseBtn.pressed ? Theme.transportButtonHover color: playPauseBtn.pressed ? Theme.transportButtonHover
@@ -244,13 +190,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: parent.text === "▶" ? 4 : 0
} }
onClicked: { onClicked: {
if (streamController.playing) { if (streamController.playing) {
@@ -268,8 +214,6 @@ Item {
implicitWidth: 56 implicitWidth: 56
implicitHeight: 56 implicitHeight: 56
hoverEnabled: true hoverEnabled: true
text: "⏭"
font.pixelSize: 24
background: Rectangle { background: Rectangle {
radius: 8 radius: 8
color: skipEndBtn.pressed ? Theme.transportButtonHover color: skipEndBtn.pressed ? Theme.transportButtonHover
@@ -279,12 +223,13 @@ Item {
ColorAnimation { duration: Theme.durationFast } ColorAnimation { duration: Theme.durationFast }
} }
} }
contentItem: Text { contentItem: IconImage {
text: parent.text source: "../icons/next.svg"
width: 22; height: 22
sourceSize.width: 22
sourceSize.height: 22
color: Theme.headingColor color: Theme.headingColor
font: parent.font anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
onClicked: streamController.step(1) onClicked: streamController.step(1)
} }
@@ -0,0 +1,115 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.impl
import QtQuick.Layouts
import ISC
// ── BOX 4: Utility Buttons ─────────────────────────────────
Rectangle {
id: utilFooter
Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterUtility
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
property var settingsPopup: null
property var aboutDialog: null
RowLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 4
Button {
id: settingsBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "../icons/settings.svg"
onClicked: if (settingsPopup) settingsPopup.open()
background: Rectangle {
radius: 8
color: settingsBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "../icons/settings.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "SETTINGS"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
Rectangle {
width: 1
height: 24
color: Theme.sideBorder
}
Button {
id: aboutBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "../icons/about.svg"
onClicked: if (aboutDialog) aboutDialog.open()
background: Rectangle {
radius: 8
color: aboutBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "../icons/about.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "ABOUT"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
}
}
+28 -2
View File
@@ -7,7 +7,9 @@ Item {
id: root id: root
property real blend: 0.0 property real blend: 0.0
property bool showLabels: true property bool showLabels: true
property alias showExtremes: map.showExtremes
property alias showThickness: map.showThickness property alias showThickness: map.showThickness
property alias hasThickness: map.hasThickness
WaferMapItem { WaferMapItem {
id: map id: map
@@ -29,8 +31,28 @@ Item {
highColor: Theme.sensorHigh highColor: Theme.sensorHigh
textColor: Theme.headingColor textColor: Theme.headingColor
// Grows the hovered marker smoothly; hoverScale setter triggers a repaint.
hoverScale: 1.0 + 0.35 * hoverPulse
property real hoverPulse: map.hoveredIndex >= 0 ? 1.0 : 0.0
Behavior on hoverPulse {
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
}
HoverHandler {
id: hoverHandler
onPointChanged: {
map.hoveredIndex = streamController.mode === "live"
? -1
: map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
}
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
}
TapHandler { TapHandler {
onTapped: ev => { onTapped: ev => {
if (streamController.mode === "live")
return;
var idx = map.which_marker(ev.position.x, ev.position.y); var idx = map.which_marker(ev.position.x, ev.position.y);
if (idx >= 0) if (idx >= 0)
replaceDialog.openFor(idx); replaceDialog.openFor(idx);
@@ -42,7 +64,11 @@ Item {
id: replaceDialog id: replaceDialog
} }
function exportImage(filePath) { function exportImage(filePath, extra) {
return map.export_image(filePath); return map.export_image(filePath, extra || "");
}
function loadThickness(filePath) {
return map.loadThickness(filePath);
} }
} }
+17 -1
View File
@@ -6,5 +6,21 @@ TransportBar 1.0 TransportBar.qml
WaferMapView 1.0 WaferMapView.qml WaferMapView 1.0 WaferMapView.qml
ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml ReplaceSensorDialog 1.0 ReplaceSensorDialog.qml
RailActionButton 1.0 RailActionButton.qml RailActionButton 1.0 RailActionButton.qml
SelectFileDialog 1.0 SelectFileDialog.qml
SplitDialog 1.0 SplitDialog.qml SplitDialog 1.0 SplitDialog.qml
StreamStatsDialog 1.0 StreamStatsDialog.qml StreamControlPanel 1.0 StreamControlPanel.qml
StatusActionsPanel 1.0 StatusActionsPanel.qml
ConnectionFooter 1.0 ConnectionFooter.qml
UtilityFooter 1.0 UtilityFooter.qml
PanelCheckBox 1.0 PanelCheckBox.qml
ReadoutStat 1.0 ReadoutStat.qml
MetricRow 1.0 MetricRow.qml
AppToolTip 1.0 AppToolTip.qml
SectionTitle 1.0 SectionTitle.qml
PanelBox 1.0 PanelBox.qml
PanelSlider 1.0 PanelSlider.qml
EditableScrubStat 1.0 EditableScrubStat.qml
ComparisonChartCard 1.0 ComparisonChartCard.qml
OverlapMapCard 1.0 OverlapMapCard.qml
ComparisonSidePanel 1.0 ComparisonSidePanel.qml
RunChart 1.0 RunChart.qml
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>

After

Width:  |  Height:  |  Size: 262 B

+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" x2="18" y1="20" y2="10" />
<line x1="12" x2="12" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>

After

Width:  |  Height:  |  Size: 239 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 236 B

+17
View File
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>

After

Width:  |  Height:  |  Size: 392 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>

After

Width:  |  Height:  |  Size: 342 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="14" y="3" width="5" height="18" rx="1" />
<rect x="6" y="3" width="5" height="18" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="6 3 20 12 6 21 6 3" />
</svg>

After

Width:  |  Height:  |  Size: 250 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 18-6-6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 238 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 297 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

After

Width:  |  Height:  |  Size: 346 B

+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

-1
View File
@@ -5,4 +5,3 @@ WaferMapTab 1.0 WaferMapTab.qml
DataTab 1.0 DataTab.qml DataTab 1.0 DataTab.qml
SettingsTab 1.0 SettingsTab.qml SettingsTab 1.0 SettingsTab.qml
StatusTab 1.0 StatusTab.qml StatusTab 1.0 StatusTab.qml
SelectFileDialog 1.0 SelectFileDialog.qml
+72 -25
View File
@@ -7,18 +7,23 @@ import QtQuick
// ============================================================================= // =============================================================================
// Sections: // Sections:
// 1. Mode flag // 1. Mode flag
// 2. Typography // 2. Typography (families, pixel sizes, weights)
// 3. Motion // 3. Motion
// 4. Tone palette (raw tones; everything else derives from these) // 4. Tone palette (raw tones; everything else derives from these)
// 5. Surfaces (page / card / panel / workspace backgrounds) // 5. Surfaces (page / card / panel / workspace backgrounds)
// 6. Borders // 6. Borders
// 7. Text (headings, body, panel titles) // 7. Text (headings, body, panel titles)
// 8. Controls (fields, buttons, checkbox) // 8. Controls (fields, buttons, checkbox, toggle)
// 9. Tracks (pill toggle + scrollbar) // 9. Interaction overlays (hover / active washes — mode-aware)
// 10. Tabs (footer tab bar) // 10. Tracks (pill toggle + scrollbar)
// 11. Side rail // 11. Tabs (mode toggle pills)
// 12. Status (success / warning / error) // 12. Side rail
// 13. Geometry (radius / border width / spacing / sizing) // 13. Transport / toolbar
// 14. Status & badges (success / warning / error, badge text)
// 15. Sensor bands (wafer map dots)
// 16. Wafer family badges (source list avatars + accent bars)
// 17. Comparison & charts (metric grading, diff accent, canvas grid)
// 18. Geometry (radius / border width / spacing / sizing)
// ============================================================================= // =============================================================================
// Based on Codex themes: // Based on Codex themes:
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6 // Dark (oscurange): surface #0B0B0F, ink #E6E6E6
@@ -29,7 +34,10 @@ QtObject {
// ── 1. Mode ────────────────────────────────────────────────────────────── // ── 1. Mode ──────────────────────────────────────────────────────────────
property bool isDarkMode: true property bool isDarkMode: true
// ── 2. Typography ────────────────────────────────────────────────────── // ── 2. Typography ────────────────────────────────────────────────────────
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
// pixelSize scale — map to closest token, tune globally // pixelSize scale — map to closest token, tune globally
readonly property int font2xs: 9 // tiny metadata readonly property int font2xs: 9 // tiny metadata
readonly property int fontXs: 11 // captions, micro labels readonly property int fontXs: 11 // captions, micro labels
@@ -44,22 +52,20 @@ QtObject {
readonly property int fontWeightMedium: 500 readonly property int fontWeightMedium: 500
readonly property int fontWeightBold: 700 readonly property int fontWeightBold: 700
// ── 3. Motion ────────────────────────────────────────────────────────── // ── 3. Motion ────────────────────────────────────────────────────────────
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash) readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
readonly property int durationBase: 180 // standard transitions (pill switch, row select) readonly property int durationBase: 180 // standard transitions (pill switch, row select)
readonly property int durationSlow: 260 // larger transitions (panel open/close) readonly property int durationSlow: 260 // larger transitions (panel open/close)
readonly property var easeStandard: Easing.OutCubic readonly property var easeStandard: Easing.OutCubic
// ── 4. Tone palette (Codex theme v1 inspired) ─────────────────────────── // ── 4. Tone palette (Codex theme v1 inspired) ───────────────────────────
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7" readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B" readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333" readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853" readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38" readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
readonly property color tone100: themeSurface readonly property color tone100: themeSurface
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1" readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
@@ -71,7 +77,7 @@ QtObject {
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E" readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE" readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
// ── 3. Surfaces ────────────────────────────────────────────────────────── // ── 5. Surfaces ──────────────────────────────────────────────────────────
readonly property color pageBackground: tone100 readonly property color pageBackground: tone100
readonly property color cardBackground: tone150 readonly property color cardBackground: tone150
readonly property color panelBackground: tone150 readonly property color panelBackground: tone150
@@ -79,7 +85,7 @@ QtObject {
readonly property color responseBackground: tone150 readonly property color responseBackground: tone150
readonly property color subtleSectionBackground: tone250 readonly property color subtleSectionBackground: tone250
// ── 4. Borders ─────────────────────────────────────────────────────────── // ── 6. Borders ───────────────────────────────────────────────────────────
readonly property color cardBorder: toneBorder readonly property color cardBorder: toneBorder
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2" readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color responseBorder: toneBorder readonly property color responseBorder: toneBorder
@@ -88,14 +94,14 @@ QtObject {
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2" readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1" readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
// ── 5. Text ────────────────────────────────────────────────────────────── // ── 7. Text ──────────────────────────────────────────────────────────────
readonly property color headingColor: toneText readonly property color headingColor: toneText
readonly property color bodyColor: toneMute readonly property color bodyColor: toneMute
readonly property color subheadingColor: toneMute readonly property color subheadingColor: toneMute
readonly property color panelTitleText: toneMute readonly property color panelTitleText: toneMute
readonly property color disabledText: toneMute readonly property color disabledText: toneMute
// ── 6. Controls ────────────────────────────────────────────────────────── // ── 8. Controls ──────────────────────────────────────────────────────────
// Fields // Fields
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF" readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
readonly property color fieldText: toneText readonly property color fieldText: toneText
@@ -111,32 +117,48 @@ QtObject {
readonly property color primaryAccent: themeAccent readonly property color primaryAccent: themeAccent
// Checkbox // Checkbox
readonly property color checkboxText: toneText readonly property color checkboxText: toneText
// Toggle switch thumb (white knob reads on both track colors)
readonly property color toggleThumb: "#FFFFFF"
// ── 7. Tracks (pill toggle, scrollbar thumb) ───────────────────────────── // ── 9. Interaction overlays ──────────────────────────────────────────────
// Mode-aware washes. Never use raw Qt.rgba(1,1,1,x) in views: a white
// overlay is invisible on light-mode surfaces.
readonly property color listHoverBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.04) : Qt.rgba(0, 0, 0, 0.03)
readonly property color listActiveBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.05)
readonly property color flatButtonHover: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.06)
readonly property color cardWash: isDarkMode ? Qt.rgba(1, 1, 1, 0.02) : Qt.rgba(0, 0, 0, 0.02)
// ── 10. Tracks (pill toggle, scrollbar thumb) ────────────────────────────
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE" readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
// ── 8. Tabs (mode toggle pills) ───────────────────────────────────────── // ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF" readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
// ── 9. Side rail ──────────────────────────────────────────────────────── // ── 12. Side rail ────────────────────────────────────────────────────────
readonly property color sideRailBackground: tone150 readonly property color sideRailBackground: tone150
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1" readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF" readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
readonly property color sideBorder: toneBorder readonly property color sideBorder: toneBorder
readonly property color sideMutedText: toneMute readonly property color sideMutedText: toneMute
// ── 10a. Transport / toolbar surfaces ──────────────────────────────────── // ── 13. Transport / toolbar surfaces ────────────────────────────────────
readonly property color transportButtonBg: tone250 readonly property color transportButtonBg: tone250
readonly property color transportButtonHover: tone300 readonly property color transportButtonHover: tone300
readonly property color liveColor: themeAdded readonly property color liveColor: themeAdded
readonly property color recordColor: themeRemoved readonly property color recordColor: themeRemoved
// ── 10. Status ─────────────────────────────────────────────────────────── // ── 14. Status & badges ──────────────────────────────────────────────────
readonly property color statusSuccessColor: themeAdded readonly property color statusSuccessColor: themeAdded
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
readonly property color statusErrorColor: themeRemoved readonly property color statusErrorColor: themeRemoved
// Text placed ON a filled status pill / saturated chip:
readonly property color statusBadgeText: "#111111" // dark ink on bright status fills, both modes
readonly property color textOnColor: "#FFFFFF" // white text on deep saturated fills (avatars etc.)
// Error banner (soft red surface + readable red text)
readonly property color errorSurface: Qt.alpha(statusErrorColor, 0.15)
readonly property color errorTextSoft: isDarkMode ? "#FCA5A5" : "#B91C1C"
// -- 10b. Sensor bands (wafer map dots) // ── 15. Sensor bands (wafer map dots) ────────────────────────────────────
readonly property color sensorInRange: statusSuccessColor readonly property color sensorInRange: statusSuccessColor
// ponytail: high-temp shares error red — semantically correct (dangerous value), // ponytail: high-temp shares error red — semantically correct (dangerous value),
// re-hue if colorblind testing demands a different channel. // re-hue if colorblind testing demands a different channel.
@@ -145,7 +167,31 @@ QtObject {
readonly property color waferRingColor: toneBorder readonly property color waferRingColor: toneBorder
readonly property color waferAxisColor: softBorder readonly property color waferAxisColor: softBorder
// ── 11. Geometry ───────────────────────────────────────────────────────── // ── 16. Wafer family badges (SourcePanel list) ───────────────────────────
// Accent = left bar / outline shade; Fill = avatar circle behind textOnColor.
// Family grouping mirrors FileBrowser's waferType first-letter convention.
readonly property color familyBlueAccent: "#3B82F6" // A / E / P
readonly property color familyBlueFill: "#1D4ED8"
readonly property color familyGreenAccent: "#10B981" // B / C / D
readonly property color familyGreenFill: "#065F46"
readonly property color familyVioletAccent: "#8B5CF6" // Z
readonly property color familyVioletFill: "#7C3AED"
readonly property color familyNeutralFill: "#374151" // unknown family
// ── 17. Comparison & charts ──────────────────────────────────────────────
// Metric grading for DTW readout cards (good → caution → bad).
// Dark values are pastel (readable on tone150); light values are deepened
// so they still pass on near-white cards.
readonly property color metricGood: isDarkMode ? "#6EE7B7" : "#059669"
readonly property color metricWarn: isDarkMode ? "#FDE047" : "#B45309"
readonly property color metricBad: isDarkMode ? "#EF4444" : "#DC2626"
// DIFF column accent in ReadoutPanel
readonly property color diffAccent: themeViolet
// Canvas chart primitives (DTW plot grid + axis labels)
readonly property color chartGridLine: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.10)
readonly property color chartAxisText: isDarkMode ? Qt.rgba(1, 1, 1, 0.35) : Qt.rgba(0, 0, 0, 0.45)
// ── 18. Geometry ─────────────────────────────────────────────────────────
// Radius // Radius
readonly property int radiusXs: 6 // fields, tight elements readonly property int radiusXs: 6 // fields, tight elements
readonly property int radiusSm: 8 // buttons readonly property int radiusSm: 8 // buttons
@@ -159,7 +205,8 @@ QtObject {
readonly property int rightPaneGap: 6 readonly property int rightPaneGap: 6
readonly property int panelPadding: 12 readonly property int panelPadding: 12
// Side rail layout // Side rail layout
readonly property int sideRailWidth: 280 readonly property int sideRailWidth: 300
readonly property int rightRailWidth: 280
readonly property int sideRailMargin: 16 readonly property int sideRailMargin: 16
readonly property int sideRailSpacing: 16 readonly property int sideRailSpacing: 16
readonly property int sideButtonHeight: 44 readonly property int sideButtonHeight: 44
+43 -16
View File
@@ -1,5 +1,6 @@
import logging import logging
import sys import sys
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQml import QQmlApplicationEngine
@@ -8,11 +9,22 @@ from PySide6.QtWidgets import QApplication
from pygui.backend.controllers.device_controller import DeviceController from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.data.constants import default_data_dir
from pygui.backend.data.file_browser import FileBrowser from pygui.backend.data.file_browser import FileBrowser
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel from pygui.backend.data.local_settings_model import LocalSettingsModel
from pygui.backend.license.license_model import LicenseModel
# Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer). # Importing wafer_map_item + trend_chart_item registers @QmlElement types (QML: import ISC.Wafer).
from pygui.backend.visualization import (
graph_quick_item as graph_quick_item, # noqa: F401
)
from pygui.backend.visualization import (
trend_chart_item as trend_chart_item, # noqa: F401
)
from pygui.backend.visualization import (
wafer_map_item as wafer_map_item, # noqa: F401
)
# ===== Application Entry Point ===== # ===== Application Entry Point =====
@@ -30,34 +42,49 @@ def main() -> int:
app = QApplication(sys.argv) app = QApplication(sys.argv)
engine = QQmlApplicationEngine() engine = QQmlApplicationEngine()
# ===== Shared Models ===== # ===== Shared Settings =====
settings_model = LocalSettingsModel() # One LocalSettings instance, one disk read. LocalSettingsModel and
# DeviceController both hold this same object, so a save from the
# Settings tab is visible to the device layer immediately — no reload,
# no second copy to go stale.
data_dir = str(default_data_dir())
raw_settings = LocalSettings.read_settings(data_dir)
settings_model = LocalSettingsModel(raw_settings, data_dir)
select_file_dialog_model = FileBrowser() select_file_dialog_model = FileBrowser()
settings_model.loadSettings() settings_model.loadSettings()
engine.rootContext().setContextProperty("settingsModel", settings_model) engine.rootContext().setContextProperty("settingsModel", settings_model)
engine.rootContext().setContextProperty("file_browser", select_file_dialog_model) engine.rootContext().setContextProperty("file_browser", select_file_dialog_model)
# App version from package metadata so the About dialog can't drift
# from pyproject.toml.
try:
app_version = version("pygui")
except PackageNotFoundError:
app_version = "dev"
engine.rootContext().setContextProperty("appVersion", app_version)
# ===== Device Controller (serial comm) ===== # ===== Device Controller (serial comm) =====
data_dir = str(settings_model._data_dir) # TODO P1.1: construct LicenseModel BEFORE DeviceController and pass
raw_settings = LocalSettings.read_settings(data_dir) # license_lookup=license_model.levelForWafer into the ctor.
# THINKING: controller must answer "is this serial licensed?" at
# read/erase time (P2.1); a bound Callable[[str], str] avoids a
# LicenseModel import in the controller and keeps it testable with a
# lambda. See docs/pending/license-gating-plan.md §1.1.
device_controller = DeviceController(raw_settings, data_dir) device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller) engine.rootContext().setContextProperty("deviceController", device_controller)
# Source panel browses the same folder recordings/reads are saved to.
select_file_dialog_model.setCurrentDirectory(str(device_controller.saveDataDir))
# ===== License Model (About dialog grid, replay trial) =====
license_model = LicenseModel(data_dir)
engine.rootContext().setContextProperty("licenseModel", license_model)
# ===== Session Controller (live/review wafer dashboard) ===== # ===== Session Controller (live/review wafer dashboard) =====
raw_settings_dict = raw_settings.__dict__.copy() if hasattr(raw_settings, '__dict__') else {} stream_controller = SessionController()
stream_controller = SessionController(settings=raw_settings_dict)
engine.rootContext().setContextProperty("streamController", stream_controller) engine.rootContext().setContextProperty("streamController", stream_controller)
# Persist session state back to settings when it changes
def _persist_session_settings():
session_state = stream_controller.collect_settings()
for k, v in session_state.items():
if hasattr(raw_settings, k):
setattr(raw_settings, k, v)
LocalSettings.save_settings(data_dir, raw_settings)
stream_controller.settingsChanged.connect(_persist_session_settings)
# ===== QML Startup ===== # ===== QML Startup =====
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the # The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
# package directory is the import path the engine searches for qmldir. # package directory is the import path the engine searches for qmldir.
-26
View File
@@ -1,26 +0,0 @@
# ===== Backend Package =====
# Backward-compatible re-exports so existing callers keep working
# while imports are migrated to the new sub-package paths.
from pygui.backend.controllers.device_controller import DeviceController # noqa: F401
from pygui.backend.controllers.session_controller import SessionController # noqa: F401
from pygui.backend.data.csv_file_metadata import CSVFileMetadata # noqa: F401
from pygui.backend.data.csv_recorder import CsvRecorder # noqa: F401
from pygui.backend.data.data_records import read_data_records # noqa: F401
from pygui.backend.data.file_browser import FileBrowser # noqa: F401
from pygui.backend.data.local_settings import LocalSettings # noqa: F401
from pygui.backend.data.local_settings_model import LocalSettingsModel # noqa: F401
from pygui.backend.models.data_model import TemperatureTableModel # noqa: F401
from pygui.backend.models.frame import Frame # noqa: F401
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data # noqa: F401
from pygui.backend.models.frame_stats import Stats, compute_stats # noqa: F401
from pygui.backend.models.sensor_editor import SensorEditor # noqa: F401
from pygui.backend.models.session_model import SessionModel # noqa: F401
from pygui.backend.models.stability_detector import StabilityDetector # noqa: F401
from pygui.backend.models.threshold_classifier import ThresholdConfig # noqa: F401
from pygui.backend.visualization.graph_view import GraphView # noqa: F401
from pygui.backend.visualization.rbf_heatmap import interpolate_field # noqa: F401
from pygui.backend.visualization.wafer_map_item import WaferMapItem # noqa: F401
from pygui.backend.wafer.wafer_layouts import available_families, load_layout # noqa: F401
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData # noqa: F401
from pygui.backend.wafer.zwafer_parser import ZWaferParser # noqa: F401
@@ -1,5 +0,0 @@
# ===== Controllers Sub-package =====
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController
__all__ = ["DeviceController", "SessionController"]
@@ -13,24 +13,20 @@ from typing import Any, Optional
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.models.data_model import TemperatureTableModel from pygui.backend.models.data_model import TemperatureTableModel
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary
from pygui.backend.visualization.graph_view import GraphView from pygui.backend.visualization.graph_view import GraphView
from pygui.serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
convert_to_debug_temperatures,
convert_to_temperatures, convert_to_temperatures,
parse_binary_data, parse_binary_data,
remove_trailing_zeros, remove_trailing_zeros,
save_debug_csv,
save_to_csv, save_to_csv,
) )
from pygui.serialcomm.serial_port import WaferInfo from pygui.serialcomm.serial_port import WaferInfo
# import pygui.backend.wafer_map_item
stream_controller = SessionController()
# engine.rootContext().setContextProperty("streamController", stream_controller)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -44,6 +40,7 @@ class DeviceController(QObject):
portsUpdated = Signal(list) portsUpdated = Signal(list)
connectionStatusChanged = Signal() connectionStatusChanged = Signal()
operationInProgressChanged = Signal() operationInProgressChanged = Signal()
waferDetectedChanged = Signal()
selectedPortChanged = Signal() selectedPortChanged = Signal()
detectResult = Signal(object) # WaferInfo dict or None detectResult = Signal(object) # WaferInfo dict or None
readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str} readResult = Signal(dict) # {"success": bool, "bytes": int} or {"error": str}
@@ -73,7 +70,17 @@ class DeviceController(QObject):
self._activity_log: list[str] = [] self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None self._raw_bytes: Optional[bytes] = None
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv") self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
# (default lambda s: "") and add:
# def _wafer_license_level(self) -> str:
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
# plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
# THINKING: single shared guard — per-call-site checks are the
# sibling-caller bug (fix read, forget erase). Empty serial → ""
# → denied = safe default. Called by P2.1.
# See docs/pending/license-gating-plan.md §1.2.
self._last_wafer_info: dict[str, Any] = {} self._last_wafer_info: dict[str, Any] = {}
self._wafer_detected: bool = False
self._last_csv_path: str = "" self._last_csv_path: str = ""
self._selected_port: str = "" self._selected_port: str = ""
self._data_row_count: int = 0 self._data_row_count: int = 0
@@ -116,6 +123,11 @@ class DeviceController(QObject):
"""Whether a hardware operation is currently running.""" """Whether a hardware operation is currently running."""
return self._operation_in_progress return self._operation_in_progress
@Property(str, notify=activityLogUpdated)
def activityLog(self) -> str:
"""Full activity log text; survives QML tab reloads."""
return "\n".join(self._activity_log)
@Property(str, notify=activityLogUpdated) @Property(str, notify=activityLogUpdated)
def saveDataDir(self) -> str: def saveDataDir(self) -> str:
"""Directory where parsed CSV data files are saved.""" """Directory where parsed CSV data files are saved."""
@@ -129,6 +141,14 @@ class DeviceController(QObject):
self._append_log(f"Save data dir set to: {path}") self._append_log(f"Save data dir set to: {path}")
self._save_status() self._save_status()
@Slot(result=str)
@slot_error_boundary
def exportDir(self) -> str:
"""Ensure and return the Export subfolder under saveDataDir."""
path = Path(self._save_data_dir) / "Export"
path.mkdir(parents=True, exist_ok=True)
return str(path)
@Slot(str) @Slot(str)
@slot_error_boundary @slot_error_boundary
def openCsvFile(self, file_path: str) -> None: def openCsvFile(self, file_path: str) -> None:
@@ -204,6 +224,17 @@ class DeviceController(QObject):
info.get("cycleCount", 0), info.get("cycleCount", 0),
] ]
@Property(bool, notify=waferDetectedChanged)
def waferDetected(self) -> bool:
"""Whether a wafer is currently detected and ready for read/erase."""
return self._wafer_detected
def _set_wafer_detected(self, detected: bool) -> None:
if self._wafer_detected != detected:
self._wafer_detected = detected
self.waferDetectedChanged.emit()
@Property(object, notify=detectResult) @Property(object, notify=detectResult)
def dataModel(self) -> TemperatureTableModel: def dataModel(self) -> TemperatureTableModel:
"""QAbstractTableModel for the parsed temperature data table.""" """QAbstractTableModel for the parsed temperature data table."""
@@ -268,6 +299,7 @@ class DeviceController(QObject):
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
self._selected_port = "" self._selected_port = ""
self._last_wafer_info = {} self._last_wafer_info = {}
self._set_wafer_detected(False)
self._data_row_count = 0 self._data_row_count = 0
self._data_col_count = 0 self._data_col_count = 0
self._raw_bytes = None self._raw_bytes = None
@@ -324,6 +356,7 @@ class DeviceController(QObject):
self._graph_view.resetChart() self._graph_view.resetChart()
self._activity_log.clear() self._activity_log.clear()
self.detectResult.emit(None) self.detectResult.emit(None)
self.selectedPortChanged.emit()
self.parsedDataReady.emit({"success": False}) self.parsedDataReady.emit({"success": False})
self._set_operation_progress(True) self._set_operation_progress(True)
@@ -349,19 +382,28 @@ class DeviceController(QObject):
self, port: Optional[str], info: Optional[WaferInfo] self, port: Optional[str], info: Optional[WaferInfo]
) -> None: ) -> None:
"""Main-thread completion handler for detectWafer.""" """Main-thread completion handler for detectWafer."""
try:
if info is not None: if info is not None:
self._selected_port = port or "" # Build the dict (can raise) before declaring success, so a
self._set_connection_status("Connected") # bad payload can't leave status="Connected" while
# waferDetected stays false and Read/Erase stay greyed.
wafer_dict = self._wafer_info_to_dict(info) wafer_dict = self._wafer_info_to_dict(info)
self._selected_port = port or ""
self._last_wafer_info = wafer_dict self._last_wafer_info = wafer_dict
self._set_wafer_detected(True)
self._set_connection_status("Connected")
self.detectResult.emit(wafer_dict) self.detectResult.emit(wafer_dict)
self.selectedPortChanged.emit()
self._save_status() self._save_status()
else: else:
self._selected_port = "" self._selected_port = ""
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
self._append_log("No wafer detected on any port") self._append_log("No wafer detected on any port")
self._last_wafer_info = {} self._last_wafer_info = {}
self._set_wafer_detected(False)
self.detectResult.emit(None) self.detectResult.emit(None)
self.selectedPortChanged.emit()
finally:
self._set_operation_progress(False) self._set_operation_progress(False)
@Slot() @Slot()
@@ -377,6 +419,16 @@ class DeviceController(QObject):
self._append_log("Already busy — ignoring read request") self._append_log("Already busy — ignoring read request")
return return
# TODO P2.1: license guard —
# if self._wafer_license_level() == "":
# self._append_log("Wafer not licensed — load its license .bin (About dialog)")
# self.readResult.emit({"error": "Wafer not licensed"})
# return
# THINKING: this slot is the trust boundary; the QML enabled
# binding (P2.2) is UX only. Reuses existing readResult error
# shape → zero new signals. Depends on P1.2. Same guard goes in
# eraseMemory (emit eraseResult there). See plan §2.1.
if not self._selected_port: if not self._selected_port:
self._append_log("No wafer detected — run Detect Wafer first") self._append_log("No wafer detected — run Detect Wafer first")
self.readResult.emit({"error": "No port — detect wafer first"}) self.readResult.emit({"error": "No port — detect wafer first"})
@@ -436,6 +488,9 @@ class DeviceController(QObject):
if self._operation_in_progress: if self._operation_in_progress:
self._append_log("Already busy — ignoring erase request") self._append_log("Already busy — ignoring erase request")
return return
# TODO P2.1: same license guard as readMemoryAsync, but emit
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
self._set_operation_progress(True) self._set_operation_progress(True)
self._set_connection_status("Erasing...") self._set_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports()) self.portsUpdated.emit(self._service.enumerate_ports())
@@ -502,10 +557,29 @@ class DeviceController(QObject):
self._debugFinished.emit({"error": "F1 read failed"}) self._debugFinished.emit({"error": "F1 read failed"})
return return
fc = (
self._last_wafer_info.get("familyCode", "")
if self._last_wafer_info
else ""
)
sensor_hex = parse_binary_data(sensor_data, fc)
debug_hex = parse_binary_data(debug_data, fc)
if not sensor_hex or not debug_hex:
self._debugFinished.emit({"error": "Debug binary parse failed"})
return
temp_data = convert_to_temperatures(sensor_hex, fc)
debug_temp_data = convert_to_debug_temperatures(debug_hex)
csv_path = save_debug_csv(temp_data, debug_temp_data, self._save_data_dir)
if csv_path is None:
self._debugFinished.emit({"error": "Debug CSV save failed"})
return
self._debugFinished.emit({ self._debugFinished.emit({
"success": True, "success": True,
"sensor_bytes": len(sensor_data), "sensor_bytes": len(sensor_data),
"debug_bytes": len(debug_data), "debug_bytes": len(debug_data),
"csv_path": csv_path,
}) })
except Exception as exc: except Exception as exc:
log.exception("Debug worker crashed: %s", exc) log.exception("Debug worker crashed: %s", exc)
@@ -519,10 +593,11 @@ class DeviceController(QObject):
self._set_connection_status("Connected") self._set_connection_status("Connected")
sensor_bytes = result.get("sensor_bytes", 0) sensor_bytes = result.get("sensor_bytes", 0)
debug_bytes = result.get("debug_bytes", 0) debug_bytes = result.get("debug_bytes", 0)
self._append_log( csv_path = result.get("csv_path", "")
f"Debug read complete: {sensor_bytes} sensor bytes, " msg = f"Debug read complete: {sensor_bytes} sensor bytes, {debug_bytes} debug bytes"
f"{debug_bytes} debug bytes" if csv_path:
) msg += f", CSV written to {csv_path}"
self._append_log(msg)
else: else:
self._set_connection_status("Disconnected") self._set_connection_status("Disconnected")
err_msg = result.get("error", "Debug read failed") err_msg = result.get("error", "Debug read failed")
@@ -11,12 +11,17 @@ from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
from pygui.backend.data.csv_recorder import CsvRecorder from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data from pygui.backend.models.frame_player import (
from pygui.backend.models.replay_stats import ReplayStatsTracker FramePlayer,
all_sensor_series,
frames_from_wafer_data,
)
from pygui.backend.models.frame_stats import compute_edge_center
from pygui.backend.models.sensor_editor import SensorEditor from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.threshold_classifier import ThresholdConfig from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader from pygui.serialcomm.stream_reader import StreamReader
@@ -35,21 +40,22 @@ class SessionController(QObject):
recordingChanged = Signal() recordingChanged = Signal()
sensorsChanged = Signal() sensorsChanged = Signal()
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str)
clusterAveragingEnabledChanged = Signal() clusterAveragingEnabledChanged = Signal()
settingsChanged = Signal() # emitted when session state changes that should be persisted
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
# trend: per-frame avg for live graph # trend: per-frame avg for live graph
trendData = Signal(str) # JSON array of floats trendDelta = Signal(str) # JSON [[elapsed_s, avg]] — one new point per live frame
trendReset = Signal() # live trend buffer cleared (new stream started)
# NOTE: dict (QVariantMap), not object — Signal(object) delivers Python # NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
# dicts to QML as an opaque empty wrapper with no accessible fields. # dicts to QML as an opaque empty wrapper with no accessible fields.
comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b comparisonResult = Signal(dict) # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
splitResult = Signal(dict) # dict with success, segments splitResult = Signal(dict) # dict with success, segments
segmentExported = Signal(dict) # dict with success, path | error
# private: marshal a worker-thread frame onto the main thread # private: marshal a worker-thread frame onto the main thread
_liveFrame = Signal(object) # Frame _liveFrame = Signal(object) # Frame
_liveError = Signal() # worker-thread error ping _liveError = Signal() # worker-thread error ping
def __init__(self, parent: QObject | None = None, def __init__(self, parent: QObject | None = None) -> None:
settings: dict | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._mode = MODE_REVIEW self._mode = MODE_REVIEW
self._model = SessionModel() self._model = SessionModel()
@@ -57,15 +63,9 @@ class SessionController(QObject):
self._reader: Optional[StreamReader] = None self._reader: Optional[StreamReader] = None
self._recorder = CsvRecorder() self._recorder = CsvRecorder()
self._sensors: list[Sensor] = [] self._sensors: list[Sensor] = []
self._last = None # last SessionUpdate self._last: Optional[SessionUpdate] = None
self._elapsed = 0.0 self._elapsed = 0.0
self._trend_buffer: list[float] = [] # rolling window self._stream_start_time: float = 0.0
self._trend_timestamps: list[float] = [] # monotonic timestamps
# Trend refresh timer - update every 1s
self._trend_timer = QTimer(self)
self._trend_timer.setInterval(1000)
self._trend_timer.timeout.connect(self._flush_trend)
self._play_timer = QTimer(self) self._play_timer = QTimer(self)
self._play_timer.timeout.connect(self._advance) self._play_timer.timeout.connect(self._advance)
@@ -81,28 +81,18 @@ class SessionController(QObject):
self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection) self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)
self._sensor_editor = SensorEditor() self._sensor_editor = SensorEditor()
self._ec_pairs: tuple[tuple[int, int], ...] = ()
self._last_raw_frame: Frame | None = None self._last_raw_frame: Frame | None = None
self._loaded_file: str = "" self._loaded_file: str = ""
self._cluster_averaging_enabled = False self._cluster_averaging_enabled = False
self._active_clusters: list[list[int]] = [] self._active_clusters: list[list[int]] = []
self._stats_tracker = ReplayStatsTracker()
self._received_count: int = 0 self._received_count: int = 0
self._error_count: int = 0 self._error_count: int = 0
# Restore persisted state if available # Comparison cache for dynamic overlap mapping
self._load_settings(settings or {}) self._compare_recs_a: list = []
self._compare_recs_b: list = []
# ---- persisted settings ---- self._compare_alignment_map: dict[int, int] = {}
def _load_settings(self, settings: dict) -> None:
"""Restore session state from persisted settings dict."""
# Do NOT auto-restore the last session file — always start fresh.
def collect_settings(self) -> dict:
"""Return a dict of session state to persist."""
return {
"session_last_file": self._loaded_file,
}
# ---- properties QML binds to ---- # ---- properties QML binds to ----
@Property(str, notify=modeChanged) @Property(str, notify=modeChanged)
@@ -143,7 +133,7 @@ class SessionController(QObject):
"value": round(v, 2), "band": band, "index": i}) "value": round(v, 2), "band": band, "index": i})
return out return out
@Property("QVariantList", notify=sensorsChanged) @Property("QVariantList", notify=sensorsChanged) # type: ignore[arg-type]
def sensorLayout(self) -> list: def sensorLayout(self) -> list:
"""[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors.""" """[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
return [ return [
@@ -168,14 +158,14 @@ class SessionController(QObject):
"""Wafer size in mm.""" """Wafer size in mm."""
return getattr(self._sensors, "size", 300.0) return getattr(self._sensors, "size", 300.0)
@Property("QVariantList", notify=frameUpdated) @Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
def sensorValues(self) -> list: def sensorValues(self) -> list:
"""[float] in sensor order.""" """[float] in sensor order."""
if not self._last: if not self._last:
return [] return []
return [round(v, 3) for v in self._last.values] return [round(v, 3) for v in self._last.values]
@Property("QVariantList", notify=frameUpdated) @Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
def sensorBands(self) -> list: def sensorBands(self) -> list:
"""['in_range'|'high'|'low'] in sensor order.""" """['in_range'|'high'|'low'] in sensor order."""
if not self._last: if not self._last:
@@ -197,15 +187,43 @@ class SessionController(QObject):
if not self._last: if not self._last:
return {} return {}
s = self._last.stats s = self._last.stats
return {"min": round(s.min, 2), "minIndex": s.min_index + 1, out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
"max": round(s.max, 2), "maxIndex": s.max_index + 1, "max": round(s.max, 2), "maxIndex": s.max_index + 1,
"diff": round(s.diff, 2), "avg": round(s.avg, 2), "diff": round(s.diff, 2), "avg": round(s.avg, 2),
"sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)} "sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
ec = compute_edge_center(self._last.values, self._ec_pairs,
self._sensor_editor.replaced_indices())
if ec is not None:
out.update({
"ecMinEdgeIndex": ec.min_edge_index + 1,
"ecMinCenterIndex": ec.min_center_index + 1,
"ecMinEdgeValue": round(ec.min_edge, 2),
"ecMinCenterValue": round(ec.min_center, 2),
"ecMinDelta": round(ec.min_delta, 2),
"ecMaxEdgeIndex": ec.max_edge_index + 1,
"ecMaxCenterIndex": ec.max_center_index + 1,
"ecMaxEdgeValue": round(ec.max_edge, 2),
"ecMaxCenterValue": round(ec.max_center, 2),
"ecMaxDelta": round(ec.max_delta, 2),
})
return out
@Property(str, notify=loadedFileChanged) @Property(str, notify=loadedFileChanged)
def loadedFile(self) -> str: return self._loaded_file def loadedFile(self) -> str: return self._loaded_file
@Property("QVariantList", notify=frameUpdated) @Property(str, notify=loadedFileChanged)
def graphSensorNames(self) -> str:
"""Comma-joined sensor names for the whole-run Graph tab."""
names, _ = all_sensor_series(list(self._player.frames))
return ",".join(names)
@Property(str, notify=loadedFileChanged)
def graphSeriesJson(self) -> str:
"""JSON per-sensor value series (one list per sensor) for the Graph tab."""
_, series = all_sensor_series(list(self._player.frames))
return json.dumps(series)
@Property("QVariantList", notify=frameUpdated) # type: ignore[arg-type]
def overriddenSensors(self) -> list[int]: def overriddenSensors(self) -> list[int]:
"""Indices of sensors that currently have a replacement or offset.""" """Indices of sensors that currently have a replacement or offset."""
return self._sensor_editor.active_indices() return self._sensor_editor.active_indices()
@@ -214,7 +232,7 @@ class SessionController(QObject):
def clusterAveragingEnabled(self) -> bool: def clusterAveragingEnabled(self) -> bool:
return self._cluster_averaging_enabled return self._cluster_averaging_enabled
@clusterAveragingEnabled.setter @clusterAveragingEnabled.setter # type: ignore[no-redef]
def clusterAveragingEnabled(self, value: bool) -> None: def clusterAveragingEnabled(self, value: bool) -> None:
if self._cluster_averaging_enabled == value: if self._cluster_averaging_enabled == value:
return return
@@ -242,6 +260,12 @@ class SessionController(QObject):
return return
if mode != "live": if mode != "live":
self.stopStream() self.stopStream()
if self._mode == "live":
# Leaving a live session: blank the wafer map instead of
# freezing the last streamed frame on screen.
self._last = None
self._last_raw_frame = None
self.frameUpdated.emit()
self._play_timer.stop() self._play_timer.stop()
self._mode = mode self._mode = mode
self.modeChanged.emit() self.modeChanged.emit()
@@ -268,24 +292,25 @@ class SessionController(QObject):
frames = [] frames = []
try:
if is_official_csv(file_path): if is_official_csv(file_path):
sensors, records = read_official_csv(file_path) sensors, records = read_official_csv(file_path)
frames = frames_from_wafer_data(None, records) frames = frames_from_wafer_data(None, records)
else: else:
try:
data, _ = ZWaferParser().parse(file_path) data, _ = ZWaferParser().parse(file_path)
except (ValueError, KeyError) as exc:
log.warning("Could not parse %s: %s", file_path, exc)
return
if data is None or not data.sensors: if data is None or not data.sensors:
log.warning("Could not parse %s", file_path) self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
return return
records = read_data_records(file_path) records = read_data_records(file_path)
sensors = data.sensors sensors = data.sensors
frames = frames_from_wafer_data(data, records) frames = frames_from_wafer_data(data, records)
if not sensors or not frames: if not sensors or not frames:
log.warning("No sensors or data in %s", file_path) self.loadFileError.emit("No sensors or frames found in data file.")
return
except Exception as exc:
log.warning("Could not parse file %s: %s", file_path, exc)
self.loadFileError.emit(f"Load error: {exc}")
return return
wafer_id = "" wafer_id = ""
@@ -296,22 +321,17 @@ class SessionController(QObject):
wafer_id = stem.split("-")[0] if "-" in stem else stem wafer_id = stem.split("-")[0] if "-" in stem else stem
shape, size = resolve_shape_and_size(sensors, wafer_id) shape, size = resolve_shape_and_size(sensors, wafer_id)
self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
self._sensors = WaferLayout(sensors, shape=shape, size=size) self._sensors = WaferLayout(sensors, shape=shape, size=size)
self._active_clusters = getattr(self._sensors, 'clusters', []) self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters: if not self._active_clusters:
self._active_clusters = group_sensors_by_radius(self._sensors) self._active_clusters = group_sensors_by_radius(self._sensors)
self._player.load(frames) self._player.load(frames)
self._model.reset() self._model.reset()
self._stats_tracker.reset()
self._loaded_file = file_path self._loaded_file = file_path
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self._emit_current() self._emit_current()
# Populate stats tracker from all loaded frames for trend chart (P3.1)
for frame in self._player._frames:
self._stats_tracker.track(frame.seq, frame.values)
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit()
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
@@ -319,20 +339,28 @@ class SessionController(QObject):
"""Clear the loaded file, resetting the player and frame states.""" """Clear the loaded file, resetting the player and frame states."""
self._player.load([]) self._player.load([])
self._model.reset() self._model.reset()
self._stats_tracker.reset() self._ec_pairs = ()
self._loaded_file = "" self._loaded_file = ""
self.loadedFileChanged.emit() self.loadedFileChanged.emit()
self.sensorsChanged.emit() self.sensorsChanged.emit()
self.settingsChanged.emit()
# ---- comparison: DTW between two CSV files ---- # ---- comparison: DTW between two CSV files ----
@Slot(str, str) @Slot(str, str)
@slot_error_boundary @slot_error_boundary
def compareFiles(self, file_a: str, file_b: str) -> None: def compareFiles(self, file_a: str, file_b: str) -> None:
"""Run DTW comparison between two CSV files and emit result.""" """Run DTW comparison between two CSV files and emit result."""
from pathlib import Path
from pygui.backend.comparison import compare_runs from pygui.backend.comparison import compare_runs
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
if Path(file_a).resolve() == Path(file_b).resolve():
self.comparisonResult.emit({
"success": False,
"error": "Cannot compare a file to itself. Choose two different runs."
})
return
try: try:
if is_official_csv(file_a): if is_official_csv(file_a):
_, recs_a = read_official_csv(file_a) _, recs_a = read_official_csv(file_a)
@@ -350,28 +378,36 @@ class SessionController(QObject):
}) })
return return
# Use first 100 frames of each for comparison (avoid O(n²) blowup) self._compare_recs_a = recs_a
max_frames = min(100, len(recs_a), len(recs_b)) self._compare_recs_b = recs_b
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
for i in range(max_frames)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
for i in range(max_frames)]
result = compare_runs(series_a, series_b) len_a, len_b = len(recs_a), len(recs_b)
# DTW can only align frames both runs actually have; the longer
# run's extra tail is still returned for display, just unaligned.
overlap_frames = min(len_a, len_b)
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)]
time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
time_b = [round(recs_b[i].time, 3) for i in range(len_b)]
result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
self._compare_alignment_map = {i: j for i, j in result["warping_path"]}
# Mean temporal shift along the DTW path: positive means run B # Mean temporal shift along the DTW path: positive means run B
# reaches the same profile features later than run A. # reaches the same profile features later than run A.
path = result["warping_path"] path = result["warping_path"]
frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0 frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
frame_offset_seconds = round(
sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2
) if path else 0.0
# Max sensor deviation: walk the DTW-aligned frame pairs and take # Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
# the largest per-sensor abs difference across all sensors, not # the overlap both runs share) and take the largest per-sensor abs
# just the sensor[0] series used for alignment. # difference across all sensors, not just the sensor[0] series used
# for alignment.
num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0 num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
max_sensor_deviation = 0.0 max_sensor_deviation = 0.0
for i, j in zip(result["index_a"], result["index_b"]): for i, j in zip(result["index_a"], result["index_b"]):
if i >= max_frames or j >= max_frames:
continue
values_a = recs_a[i].values values_a = recs_a[i].values
values_b = recs_b[j].values values_b = recs_b[j].values
for s in range(min(num_sensors, len(values_a), len(values_b))): for s in range(min(num_sensors, len(values_a), len(values_b))):
@@ -382,9 +418,6 @@ class SessionController(QObject):
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the # Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
# same sensor-parsing branch as loadFile() (file_a is assumed representative # same sensor-parsing branch as loadFile() (file_a is assumed representative
# of both files' wafer type). # of both files' wafer type).
from pathlib import Path
from pygui.backend.data.data_records import is_official_csv, read_official_csv
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -404,8 +437,8 @@ class SessionController(QObject):
if sensors: if sensors:
wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id) wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
last_a = recs_a[max_frames - 1].values last_a = recs_a[overlap_frames - 1].values
last_b = recs_b[max_frames - 1].values last_b = recs_b[overlap_frames - 1].values
n = min(len(sensors), len(last_a), len(last_b)) n = min(len(sensors), len(last_a), len(last_b))
sensor_layout = [ sensor_layout = [
{ {
@@ -428,9 +461,14 @@ class SessionController(QObject):
# Lists, not tuples — tuples don't survive QVariant conversion # Lists, not tuples — tuples don't survive QVariant conversion
"warping_path": [list(p) for p in result["warping_path"][:50]], "warping_path": [list(p) for p in result["warping_path"][:50]],
"frame_offset": frame_offset, "frame_offset": frame_offset,
"frame_offset_seconds": frame_offset_seconds,
"max_sensor_deviation": max_sensor_deviation, "max_sensor_deviation": max_sensor_deviation,
"series_a": series_a, "series_a": series_a,
"series_b": series_b, "series_b": series_b,
"time_a": time_a,
"time_b": time_b,
"frame_count_a": len_a,
"frame_count_b": len_b,
"sensor_layout": sensor_layout, "sensor_layout": sensor_layout,
"sensor_diff": sensor_diff, "sensor_diff": sensor_diff,
"wafer_shape": wafer_shape, "wafer_shape": wafer_shape,
@@ -443,6 +481,25 @@ class SessionController(QObject):
"error": str(exc) "error": str(exc)
}) })
@Slot(int, result="QVariantList")
def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
if not self._compare_recs_a or not self._compare_recs_b:
return []
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
if idx_a not in self._compare_alignment_map:
# DTW path only covers indices up to the shorter run's length —
# past that, Run B has no data at this frame, not a clampable one.
return []
idx_b = self._compare_alignment_map[idx_a]
values_a = self._compare_recs_a[idx_a].values
values_b = self._compare_recs_b[idx_b].values
n = min(len(values_a), len(values_b))
return [round(values_b[i] - values_a[i], 3) for i in range(n)]
# ---- splitting: threshold-based segmentation ---- # ---- splitting: threshold-based segmentation ----
@Slot(str, float) @Slot(str, float)
@slot_error_boundary @slot_error_boundary
@@ -467,6 +524,10 @@ class SessionController(QObject):
avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records] avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
segments = segment_profile(avg_temps, threshold) segments = segment_profile(avg_temps, threshold)
# cache for exportSegment (per-segment Export in SplitDialog)
self._last_split_file = file_path
self._last_split_segments = segments
self.splitResult.emit({ self.splitResult.emit({
"success": True, "success": True,
"segments": [{ "segments": [{
@@ -483,6 +544,48 @@ class SessionController(QObject):
"error": str(exc) "error": str(exc)
}) })
@Slot(int)
@slot_error_boundary
def exportSegment(self, index: int) -> None:
"""Write one segment from the last split as a standalone CSV.
Output lands next to the source file as
``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
so read_official_csv still resolves the layout on re-import.
Emits segmentExported with success + path (or error).
"""
from pathlib import Path
from pygui.backend.data.csv_recorder import CsvRecorder
source = getattr(self, "_last_split_file", "")
segments = getattr(self, "_last_split_segments", [])
if not source or not (0 <= index < len(segments)):
self.segmentExported.emit({
"success": False,
"error": "No split result to export"
})
return
seg = segments[index]
src = Path(source)
dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
try:
ok = CsvRecorder.write_segment(
str(dest), str(src), seg.start_frame, seg.end_frame)
except Exception as exc:
log.warning("Segment export failed: %s", exc)
self.segmentExported.emit({"success": False, "error": str(exc)})
return
if ok:
self.segmentExported.emit({"success": True, "path": str(dest)})
else:
self.segmentExported.emit({
"success": False,
"error": "No frames in range"
})
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
def play(self) -> None: def play(self) -> None:
@@ -586,6 +689,7 @@ class SessionController(QObject):
self.sensorsChanged.emit() self.sensorsChanged.emit()
except Exception as e: except Exception as e:
log.warning("Could not load layout for %s: %s", family_code, e) log.warning("Could not load layout for %s: %s", family_code, e)
self._ec_pairs = ec_pairs_for_wafer_id(family_code)
self._active_clusters = getattr(self._sensors, 'clusters', []) self._active_clusters = getattr(self._sensors, 'clusters', [])
if not self._active_clusters: if not self._active_clusters:
@@ -616,10 +720,7 @@ class SessionController(QObject):
# Clear out any old data from the prev sessions # Clear out any old data from the prev sessions
self._model.reset() self._model.reset()
self._trend_buffer.clear() self._reset_live_trend()
self._trend_timestamps.clear()
self._trend_timer.start()
self._stats_tracker.reset()
self._last = None self._last = None
self._last_raw_frame = None self._last_raw_frame = None
self._received_count = 0 self._received_count = 0
@@ -661,7 +762,6 @@ class SessionController(QObject):
@slot_error_boundary @slot_error_boundary
def stopStream(self) -> None: def stopStream(self) -> None:
self._repaint_timer.stop() self._repaint_timer.stop()
self._trend_timer.stop()
self._received_count = 0 self._received_count = 0
self._error_count = 0 self._error_count = 0
self.liveStatsChanged.emit() self.liveStatsChanged.emit()
@@ -687,10 +787,8 @@ class SessionController(QObject):
@Slot(object) @Slot(object)
@slot_error_boundary @slot_error_boundary
def _on_live_frame(self, frame: Frame) -> None: def _on_live_frame(self, frame: Frame) -> None:
import json
self._received_count += 1 self._received_count += 1
self.liveStatsChanged.emit() self.liveStatsChanged.emit()
self._stats_tracker.track(frame.seq, frame.values)
# Main thread (queued). Record raw, process edited; mark dirty for repaint. # Main thread (queued). Record raw, process edited; mark dirty for repaint.
self._last_raw_frame = frame self._last_raw_frame = frame
edited = Frame(seq=frame.seq, time=frame.time, edited = Frame(seq=frame.seq, time=frame.time,
@@ -699,22 +797,11 @@ class SessionController(QObject):
if self._recorder.is_recording: if self._recorder.is_recording:
self._recorder.write(frame) # always record raw values, never edited self._recorder.write(frame) # always record raw values, never edited
self._dirty = True self._dirty = True
self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
if self._last and self._last.stats: if self._last and self._last.stats:
avg = self._last.stats.avg avg = self._last.stats.avg
now = time.monotonic() elapsed = time.monotonic() - self._stream_start_time
self._trend_buffer.append(avg) self.trendDelta.emit(json.dumps([[elapsed, avg]]))
self._trend_timestamps.append(now)
# drop entries older than 60 secs
cutoff = now -60
while self._trend_timestamps and self._trend_timestamps[0] < cutoff:
self._trend_timestamps.pop(0)
self._trend_buffer.pop(0)
def _flush_repaint(self) -> None: def _flush_repaint(self) -> None:
if not self._dirty: if not self._dirty:
@@ -770,6 +857,8 @@ class SessionController(QObject):
self._sensor_editor.clear() self._sensor_editor.clear()
self._reprocess_current() self._reprocess_current()
def _flush_trend(self) -> None: def _reset_live_trend(self) -> None:
import json """Mark a fresh stream start for elapsed-time trend deltas."""
self.trendData.emit(json.dumps(self._trend_buffer)) self._stream_start_time = time.monotonic()
self.trendReset.emit()
-2
View File
@@ -1,2 +0,0 @@
# ===== Crypto Sub-package =====
from pygui.backend.crypto.crypto_helper import * # noqa: F403
-14
View File
@@ -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",
]
+11
View File
@@ -1,3 +1,14 @@
# ===== Backend Data Constants ===== # ===== Backend Data Constants =====
from pathlib import Path
from PySide6.QtCore import QStandardPaths
DEFAULT_DATA_DIR_NAME = "isc_data" DEFAULT_DATA_DIR_NAME = "isc_data"
def default_data_dir() -> Path:
"""Documents-folder isc_data directory used as the default app data location."""
documents_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DocumentsLocation)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
+2 -2
View File
@@ -19,8 +19,8 @@ class CSVFileMetadata:
"""Return the first character of the wafer string, or empty if none""" """Return the first character of the wafer string, or empty if none"""
return self.wafer[0] if self.wafer else "" return self.wafer[0] if self.wafer else ""
def get_date(self) -> datetime: def get_date(self) -> datetime | None:
"""Parsifes the date string. Returns current time if parsing fails""" """Parsifes the date string. Returns None if parsing fails"""
date_format = "%Y-%m-%d %H:%M:%S" date_format = "%Y-%m-%d %H:%M:%S"
try: try:
return datetime.strptime(self.date, date_format) return datetime.strptime(self.date, date_format)
+45 -9
View File
@@ -1,4 +1,5 @@
"""Append live frames""" """Append live frames"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
@@ -12,14 +13,12 @@ from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder: class CsvRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None self._path: Optional[str] = None
@property @property
def is_recording(self) -> bool: def is_recording(self) -> bool:
return self._file_handle is not None return self._file_handle is not None
def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None: def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).parent.mkdir(parents=True, exist_ok=True)
file_handle = open(path, "w", encoding="utf-8", newline="") file_handle = open(path, "w", encoding="utf-8", newline="")
@@ -50,6 +49,12 @@ class CsvRecorder:
def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool: def write_segment(file_path: str, source_path: str, start_frame: int, end_frame: int) -> bool:
"""Copy a range of frames from source CSV into a new file. """Copy a range of frames from source CSV into a new file.
Preserves the source's header structure so the segment re-imports
through FileBrowser unchanged: official CSVs keep their sensor-name
row, Z-wafer CSVs keep every metadata line through the "data"
sentinel. Frame indices count only valid data rows, matching how
the readers in data_records.py number frames.
Args: Args:
file_path: Output CSV path. file_path: Output CSV path.
source_path: Source CSV path with full wafer data source_path: Source CSV path with full wafer data
@@ -57,11 +62,42 @@ class CsvRecorder:
end_frame: Last frame index (inclusive) end_frame: Last frame index (inclusive)
Returns: Returns:
True on success. True on success (at least one frame written).
""" """
import pandas as pd from pygui.backend.data.data_records import is_data_row, is_official_csv
df = pd.read_csv(source_path)
segment = df.iloc[start_frame:end_frame + 1]
segment.to_csv(file_path, index=False)
return True
if start_frame < 0 or end_frame < start_frame:
return False
with open(source_path, "r", encoding="utf-8") as f:
lines = f.readlines()
out: list[str] = []
written = 0
frame_idx = -1
in_data = is_official_csv(source_path)
for i, line in enumerate(lines):
stripped = line.rstrip().rstrip(",").strip()
if not in_data:
out.append(line)
if stripped and stripped.split(",")[0].lower() == "data":
in_data = True
continue
if i == 0:
# official format: row 0 is the sensor-name header
out.append(line)
continue
if not stripped or not is_data_row(stripped):
continue
frame_idx += 1
if start_frame <= frame_idx <= end_frame:
out.append(line if line.endswith("\n") else line + "\n")
written += 1
if written == 0:
return False
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8", newline="") as f:
f.writelines(out)
return True
+17 -5
View File
@@ -1,4 +1,5 @@
"""Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV.""" """Read the data section of a Z-wafer CSV into DataRecords, or the official headerless CSV."""
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
@@ -6,6 +7,20 @@ from pathlib import Path
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor from pygui.backend.wafer.zwafer_models import DataRecord, Sensor
def is_data_row(line: str) -> bool:
"""True if *line* is a single CSV row of numeric values (no headers/metadata)."""
cols = [c.strip() for c in line.split(",") if c.strip() != ""]
return bool(cols) and all(_is_float(c) for c in cols)
def _is_float(s: str) -> bool:
try:
float(s)
except ValueError:
return False
return True
def read_data_records(file_path: str) -> list[DataRecord]: def read_data_records(file_path: str) -> list[DataRecord]:
records: list[DataRecord] = [] records: list[DataRecord] = []
in_data = False in_data = False
@@ -18,13 +33,10 @@ def read_data_records(file_path: str) -> list[DataRecord]:
if line.split(",")[0].lower() == "data": if line.split(",")[0].lower() == "data":
in_data = True in_data = True
continue continue
if not is_data_row(line):
continue
cols = [c.strip() for c in line.split(",") if c.strip() != ""] cols = [c.strip() for c in line.split(",") if c.strip() != ""]
try:
nums = [float(c) for c in cols] nums = [float(c) for c in cols]
except ValueError:
continue
if not nums:
continue
records.append(DataRecord(time=nums[0], values=nums[1:])) records.append(DataRecord(time=nums[0], values=nums[1:]))
return records return records
+25 -13
View File
@@ -5,10 +5,10 @@ import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Property, QObject, QStandardPaths, Signal, Slot from PySide6.QtCore import Property, QObject, Signal, Slot
from PySide6.QtWidgets import QFileDialog, QMessageBox from PySide6.QtWidgets import QFileDialog, QMessageBox
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME from pygui.backend.data.constants import default_data_dir
from pygui.backend.data.csv_file_metadata import CSVFileMetadata from pygui.backend.data.csv_file_metadata import CSVFileMetadata
from pygui.backend.wafer.zwafer_parser import ZWaferParser from pygui.backend.wafer.zwafer_parser import ZWaferParser
@@ -28,7 +28,7 @@ class FileBrowser(QObject):
self._refresh_files(show_empty_message=False) self._refresh_files(show_empty_message=False)
# ===== Exposed Properties ===== # ===== Exposed Properties =====
@Property("QVariantList", notify=filesChanged) @Property("QVariantList", notify=filesChanged) # type: ignore[arg-type]
def files(self) -> list[dict[str, object]]: def files(self) -> list[dict[str, object]]:
return [dict(row) for row in self._files] return [dict(row) for row in self._files]
@@ -37,12 +37,22 @@ class FileBrowser(QObject):
return str(self._current_directory) return str(self._current_directory)
# ===== Directory Selection ===== # ===== Directory Selection =====
@Slot(str)
def setCurrentDirectory(self, path: str) -> None:
"""Point the browser at `path` without opening a native dialog.
Used to keep this directory in sync with DeviceController.saveDataDir
when the user picks a save folder elsewhere in the app.
"""
self._set_current_directory(Path(path))
self._refresh_files(show_empty_message=False)
@Slot() @Slot()
def chooseDirectory(self) -> None: def chooseDirectory(self) -> None:
selected_dir = QFileDialog.getExistingDirectory( selected_dir = QFileDialog.getExistingDirectory(
None, None,
"Select CSV Folder", "Select CSV Folder",
self.currentDirectory, str(self._current_directory),
) )
if not selected_dir: if not selected_dir:
return return
@@ -71,11 +81,10 @@ class FileBrowser(QObject):
self._show_error(f"CSV file was not found:\n{file_path}") self._show_error(f"CSV file was not found:\n{file_path}")
return return
# ponytail: row is absent when saving metadata for a CSV just read from
# the wafer (not in the browser listing) — still write the sidecar.
row = self._find_row(file_path) row = self._find_row(file_path)
if row is None: if row is not None:
self._show_error("The selected CSV row is no longer available.")
return
row["wafer"] = wafer row["wafer"] = wafer
row["date"] = date row["date"] = date
row["chamber"] = chamber row["chamber"] = chamber
@@ -119,6 +128,11 @@ class FileBrowser(QObject):
return return
for csv_path in sorted(self._current_directory.glob("*.csv")): for csv_path in sorted(self._current_directory.glob("*.csv")):
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
# SessionController.startRecording (see WaferMapTab's Record button),
# distinct from the "<serial>-<timestamp>.csv" convention used by
# DeviceController.parseAndSaveData for read-memory dumps.
is_recording = csv_path.stem.lower().startswith("live_")
try: try:
metadata = self._load_metadata(csv_path) metadata = self._load_metadata(csv_path)
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "") wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
@@ -146,6 +160,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), "") or metadata.master_type, "masterType": master_state.get(str(csv_path), "") or metadata.master_type,
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": wafer_type in {"A", "B", "C"}, "highlight": wafer_type in {"A", "B", "C"},
"isRecording": is_recording,
} }
) )
except Exception: except Exception:
@@ -169,6 +184,7 @@ class FileBrowser(QObject):
"masterType": master_state.get(str(csv_path), ""), "masterType": master_state.get(str(csv_path), ""),
"fileName": str(csv_path), "fileName": str(csv_path),
"highlight": False, "highlight": False,
"isRecording": is_recording,
} }
) )
@@ -229,11 +245,7 @@ class FileBrowser(QObject):
# ===== Internal Helpers ===== # ===== Internal Helpers =====
def _resolve_default_directory(self) -> Path: def _resolve_default_directory(self) -> Path:
documents_dir = QStandardPaths.writableLocation( return default_data_dir()
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
def _set_current_directory(self, directory: Path) -> None: def _set_current_directory(self, directory: Path) -> None:
normalized = Path(directory) normalized = Path(directory)
+2 -5
View File
@@ -13,8 +13,8 @@ class LocalSettings:
# Configuration settings # Configuration settings
self.chamber_id = "" self.chamber_id = ""
self.reverse_z_wafer = False self.reverse_z_wafer = False
self.master = {} # Dict[str, str] self.master: dict[str, str] = {}
self.wafer_data_size = {} # Dict[str, int] self.wafer_data_size: dict[str, int] = {}
self.debug = False self.debug = False
self.wafer_read_retries = 8 self.wafer_read_retries = 8
# Timeout in msec for reading from the wafer (default 2 min) # Timeout in msec for reading from the wafer (default 2 min)
@@ -32,9 +32,6 @@ class LocalSettings:
self.data_col_count = 0 self.data_col_count = 0
self.last_csv_path = "" self.last_csv_path = ""
# Session persistence
self.session_last_file = ""
# ===== File Path Helpers ===== # ===== File Path Helpers =====
@classmethod @classmethod
def _settings_path(cls, directory: str) -> Path: def _settings_path(cls, directory: str) -> Path:
+78 -82
View File
@@ -2,17 +2,26 @@ from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
from PySide6.QtCore import Property, QDateTime, QObject, QStandardPaths, Signal, Slot from PySide6.QtCore import Property, QDateTime, QObject, Signal, Slot
from pygui.backend.data.constants import DEFAULT_DATA_DIR_NAME
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z") MASTER_FAMILIES = ("A", "B", "C", "D", "E", "F", "P", "X", "Z")
class LocalSettingsModel(QObject): class LocalSettingsModel(QObject):
# Draft attrs are seeded via setattr() in __init__ from _new_defaults();
# declared here so mypy knows their types.
_chamber_id: str
_reverse_z_wafer: bool
_debug_mode: bool
_wafer_read_timeout: int
_wafer_detect_timeout: int
_wafer_retries: int
_masters: dict[str, str]
chamberIdChanged = Signal() chamberIdChanged = Signal()
reverseZWaferChanged = Signal() reverseZWaferChanged = Signal()
debugModeChanged = Signal() debugModeChanged = Signal()
@@ -26,18 +35,35 @@ class LocalSettingsModel(QObject):
saveStatusChanged = Signal() saveStatusChanged = Signal()
lastSavedAtChanged = Signal() lastSavedAtChanged = Signal()
def __init__(self, parent: QObject | None = None) -> None: # Draft attr (this class) <-> LocalSettings attr (the shared, persisted
# instance also held by DeviceController) <-> coercer applied on both
# load and revert/reset. One table drives load/save/revert/reset instead
# of four hand-written field-by-field copies.
_FIELD_MAP: tuple[tuple[str, str, Callable[[Any], Any]], ...] = (
("_chamber_id", "chamber_id", str),
("_reverse_z_wafer", "reverse_z_wafer", bool),
("_debug_mode", "debug", bool),
("_wafer_read_timeout", "wafer_read_timeout", int),
("_wafer_detect_timeout", "wafer_detect_timeout", int),
("_wafer_retries", "wafer_read_retries", int),
)
def __init__(
self,
settings: LocalSettings,
data_dir: Path | str,
parent: QObject | None = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self._data_dir = self._resolve_data_dir() # Shared with DeviceController — saving here must mutate this same
# instance, not a throwaway copy, or the device layer keeps using
# stale config until restart.
self._settings = settings
self._data_dir = Path(data_dir)
self._defaults = self._new_defaults() self._defaults = self._new_defaults()
self._chamber_id = self._defaults["chamberId"] for draft_attr, default_value in self._defaults.items():
self._reverse_z_wafer = self._defaults["reverseZWafer"] setattr(self, draft_attr, deepcopy(default_value))
self._debug_mode = self._defaults["debugMode"]
self._wafer_read_timeout = self._defaults["waferReadTimeout"]
self._wafer_detect_timeout = self._defaults["waferDetectTimeout"]
self._wafer_retries = self._defaults["waferRetries"]
self._masters = deepcopy(self._defaults["masters"])
self._is_dirty = False self._is_dirty = False
self._is_valid = True self._is_valid = True
@@ -47,34 +73,21 @@ class LocalSettingsModel(QObject):
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._recompute_derived() self._recompute_derived()
def _resolve_data_dir(self) -> Path:
documents_dir = QStandardPaths.writableLocation(
QStandardPaths.DocumentsLocation
)
base_dir = Path(documents_dir) if documents_dir else (Path.home() / "Documents")
return base_dir / DEFAULT_DATA_DIR_NAME
def _new_defaults(self) -> dict[str, Any]: def _new_defaults(self) -> dict[str, Any]:
return { return {
"chamberId": "2", "_chamber_id": "2",
"reverseZWafer": False, "_reverse_z_wafer": False,
"debugMode": False, "_debug_mode": False,
"waferReadTimeout": 120000, "_wafer_read_timeout": 120000,
"waferDetectTimeout": 5000, "_wafer_detect_timeout": 5000,
"waferRetries": 8, "_wafer_retries": 8,
"masters": {family: "" for family in MASTER_FAMILIES}, "_masters": {family: "" for family in MASTER_FAMILIES},
} }
def _snapshot(self) -> dict[str, Any]: def _snapshot(self) -> dict[str, Any]:
return { snap = {attr: getattr(self, attr) for attr, _, _ in self._FIELD_MAP}
"chamberId": self._chamber_id, snap["_masters"] = deepcopy(self._masters)
"reverseZWafer": self._reverse_z_wafer, return snap
"debugMode": self._debug_mode,
"waferReadTimeout": self._wafer_read_timeout,
"waferDetectTimeout": self._wafer_detect_timeout,
"waferRetries": self._wafer_retries,
"masters": deepcopy(self._masters),
}
def _set_is_dirty(self, value: bool) -> None: def _set_is_dirty(self, value: bool) -> None:
if self._is_dirty != value: if self._is_dirty != value:
@@ -137,22 +150,19 @@ class LocalSettingsModel(QObject):
self.waferRetriesChanged.emit() self.waferRetriesChanged.emit()
self.mastersChanged.emit() self.mastersChanged.emit()
def _to_local_settings(self) -> LocalSettings: def _normalized_masters(self, source: dict[str, str]) -> dict[str, str]:
settings = LocalSettings() masters = {family: "" for family in MASTER_FAMILIES}
settings.chamber_id = self._chamber_id for family, value in source.items():
settings.reverse_z_wafer = self._reverse_z_wafer normalized = str(family).strip().upper()
settings.debug = self._debug_mode if normalized in masters:
settings.wafer_read_timeout = self._wafer_read_timeout masters[normalized] = str(value).strip()
settings.wafer_detect_timeout = self._wafer_detect_timeout return masters
settings.wafer_read_retries = self._wafer_retries
settings.master = deepcopy(self._masters)
return settings
@Property(str, notify=chamberIdChanged) @Property(str, notify=chamberIdChanged)
def chamberId(self) -> str: def chamberId(self) -> str:
return self._chamber_id return self._chamber_id
@chamberId.setter @chamberId.setter # type: ignore[no-redef]
def chamberId(self, value: str) -> None: def chamberId(self, value: str) -> None:
next_value = str(value).strip() next_value = str(value).strip()
if self._chamber_id == next_value: if self._chamber_id == next_value:
@@ -165,7 +175,7 @@ class LocalSettingsModel(QObject):
def reverseZWafer(self) -> bool: def reverseZWafer(self) -> bool:
return self._reverse_z_wafer return self._reverse_z_wafer
@reverseZWafer.setter @reverseZWafer.setter # type: ignore[no-redef]
def reverseZWafer(self, value: bool) -> None: def reverseZWafer(self, value: bool) -> None:
if self._reverse_z_wafer == value: if self._reverse_z_wafer == value:
return return
@@ -177,7 +187,7 @@ class LocalSettingsModel(QObject):
def debugMode(self) -> bool: def debugMode(self) -> bool:
return self._debug_mode return self._debug_mode
@debugMode.setter @debugMode.setter # type: ignore[no-redef]
def debugMode(self, value: bool) -> None: def debugMode(self, value: bool) -> None:
if self._debug_mode == value: if self._debug_mode == value:
return return
@@ -189,7 +199,7 @@ class LocalSettingsModel(QObject):
def waferReadTimeout(self) -> int: def waferReadTimeout(self) -> int:
return self._wafer_read_timeout return self._wafer_read_timeout
@waferReadTimeout.setter @waferReadTimeout.setter # type: ignore[no-redef]
def waferReadTimeout(self, value: int) -> None: def waferReadTimeout(self, value: int) -> None:
if self._wafer_read_timeout == value: if self._wafer_read_timeout == value:
return return
@@ -201,7 +211,7 @@ class LocalSettingsModel(QObject):
def waferDetectTimeout(self) -> int: def waferDetectTimeout(self) -> int:
return self._wafer_detect_timeout return self._wafer_detect_timeout
@waferDetectTimeout.setter @waferDetectTimeout.setter # type: ignore[no-redef]
def waferDetectTimeout(self, value: int) -> None: def waferDetectTimeout(self, value: int) -> None:
if self._wafer_detect_timeout == value: if self._wafer_detect_timeout == value:
return return
@@ -213,7 +223,7 @@ class LocalSettingsModel(QObject):
def waferRetries(self) -> int: def waferRetries(self) -> int:
return self._wafer_retries return self._wafer_retries
@waferRetries.setter @waferRetries.setter # type: ignore[no-redef]
def waferRetries(self, value: int) -> None: def waferRetries(self, value: int) -> None:
if self._wafer_retries == value: if self._wafer_retries == value:
return return
@@ -251,22 +261,11 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def loadSettings(self) -> None: def loadSettings(self) -> None:
loaded = LocalSettings.read_settings(str(self._data_dir)) """Seed draft fields from the shared LocalSettings instance."""
self._chamber_id = str(loaded.chamber_id).strip() or str( for draft_attr, settings_attr, coerce in self._FIELD_MAP:
self._defaults["chamberId"] setattr(self, draft_attr, coerce(getattr(self._settings, settings_attr)))
) self._chamber_id = self._chamber_id.strip() or str(self._defaults["_chamber_id"])
self._reverse_z_wafer = bool(loaded.reverse_z_wafer) self._masters = self._normalized_masters(self._settings.master)
self._debug_mode = bool(loaded.debug)
self._wafer_read_timeout = int(loaded.wafer_read_timeout)
self._wafer_detect_timeout = int(loaded.wafer_detect_timeout)
self._wafer_retries = int(loaded.wafer_read_retries)
masters = {family: "" for family in MASTER_FAMILIES}
for family, value in loaded.master.items():
normalized = str(family).strip().upper()
if normalized in masters:
masters[normalized] = str(value).strip()
self._masters = masters
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._emit_all_changed() self._emit_all_changed()
@@ -275,13 +274,18 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def saveSettings(self) -> None: def saveSettings(self) -> None:
"""Write draft fields onto the shared LocalSettings instance and persist it."""
self._recompute_derived() self._recompute_derived()
if not self._is_valid: if not self._is_valid:
self._set_save_status("error: invalid settings") self._set_save_status("error: invalid settings")
return return
try: try:
LocalSettings.save_settings(str(self._data_dir), self._to_local_settings()) for draft_attr, settings_attr, _coerce in self._FIELD_MAP:
setattr(self._settings, settings_attr, getattr(self, draft_attr))
self._settings.master = deepcopy(self._masters)
LocalSettings.save_settings(str(self._data_dir), self._settings)
self._saved_snapshot = self._snapshot() self._saved_snapshot = self._snapshot()
self._recompute_derived() self._recompute_derived()
self._set_save_status("Saved") self._set_save_status("Saved")
@@ -293,13 +297,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def revertChanges(self) -> None: def revertChanges(self) -> None:
snap = self._saved_snapshot snap = self._saved_snapshot
self._chamber_id = str(snap["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(snap["reverseZWafer"]) setattr(self, draft_attr, coerce(snap[draft_attr]))
self._debug_mode = bool(snap["debugMode"]) self._masters = deepcopy(snap["_masters"])
self._wafer_read_timeout = int(snap["waferReadTimeout"])
self._wafer_detect_timeout = int(snap["waferDetectTimeout"])
self._wafer_retries = int(snap["waferRetries"])
self._masters = deepcopy(snap["masters"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() self._recompute_derived()
@@ -307,13 +307,9 @@ class LocalSettingsModel(QObject):
@Slot() @Slot()
def resetDefaults(self) -> None: def resetDefaults(self) -> None:
self._chamber_id = str(self._defaults["chamberId"]) for draft_attr, _settings_attr, coerce in self._FIELD_MAP:
self._reverse_z_wafer = bool(self._defaults["reverseZWafer"]) setattr(self, draft_attr, coerce(self._defaults[draft_attr]))
self._debug_mode = bool(self._defaults["debugMode"]) self._masters = deepcopy(self._defaults["_masters"])
self._wafer_read_timeout = int(self._defaults["waferReadTimeout"])
self._wafer_detect_timeout = int(self._defaults["waferDetectTimeout"])
self._wafer_retries = int(self._defaults["waferRetries"])
self._masters = deepcopy(self._defaults["masters"])
self._emit_all_changed() self._emit_all_changed()
self._recompute_derived() self._recompute_derived()
@@ -0,0 +1,126 @@
"""C#-compatible wafer license file parsing.
License files are AES-128-CBC encrypted `.bin` blobs named
``X00000-00.bin`` where ``X`` is the wafer family code, ``00000`` the
5-digit serial number, and ``00`` the 2-digit license level. The blob is
``ciphertext || iv`` (IV is the trailing 16 bytes). The decrypted text is
a fixed-offset record::
[0:6] serial e.g. "A00001"
[7:15] mfg date 8 hex chars
[16:18] level 2 decimal digits ("00" basic, "01"+ adds replay)
[19:27] license date 8 hex chars
The key is hardcoded to match the C# application exactly
"""
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from pygui.backend.crypto.crypto_helper import CryptoHelper
log = logging.getLogger(__name__)
LICENSE_KEY = b"ThisIsA16ByteKey"
LICENSE_FILENAME_RE = re.compile(r"^[A-Z]\d{5}-\d{2}\.bin$")
_MIN_TEXT_LEN = 27
# ===== License Record =====
@dataclass(frozen=True)
class License:
"""One parsed license file (all fields as stored in the file)."""
serial: str # "A00001"
level: str # "00".."03"
mfg_date_hex: str # 8 hex chars
lic_date_hex: str # 8 hex chars
@property
def level_int(self) -> int:
"""Level as int; -1 if unparseable."""
try:
return int(self.level, 10)
except ValueError:
return -1
@property
def mfg_date_decimal(self) -> str:
"""Mfg date shown as decimal (C# grid does Convert.ToInt64(hex, 16))."""
return _hex_to_decimal(self.mfg_date_hex)
@property
def lic_date_decimal(self) -> str:
"""License date shown as decimal, same conversion as the C# grid."""
return _hex_to_decimal(self.lic_date_hex)
def _hex_to_decimal(hex_str: str) -> str:
try:
return str(int(hex_str, 16))
except ValueError:
return ""
# ===== Blob Parsing =====
def split_data_iv(blob: bytes) -> tuple[bytes, bytes]:
"""Split a license blob into (ciphertext, iv) — IV is the last 16 bytes."""
if len(blob) < 32:
raise ValueError("License blob too short")
return blob[:-16], blob[-16:]
def parse_license_blob(blob: bytes) -> License | None:
"""Decrypt and parse one license blob. None if malformed."""
try:
data, iv = split_data_iv(blob)
message = CryptoHelper.decrypt_aes(data, LICENSE_KEY, iv)
text = message.decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
log.warning("Undecryptable license blob: %s", exc)
return None
if len(text) < _MIN_TEXT_LEN:
log.warning("License text too short (%d chars)", len(text))
return None
return License(
serial=text[0:6],
mfg_date_hex=text[7:15],
level=text[16:18],
lic_date_hex=text[19:27],
)
# ===== Directory Scan =====
def read_license_files(licenses_dir: str | Path) -> list[License]:
"""Parse every valid license .bin in the directory.
A file counts only if its name matches ``X00000-00.bin`` AND equals
``{serial}-{level}.bin`` from its own decrypted payload same
tamper check as the C# app.
"""
directory = Path(licenses_dir)
if not directory.is_dir():
return []
licenses: list[License] = []
for path in sorted(directory.glob("*.bin")):
if not LICENSE_FILENAME_RE.match(path.name):
continue
try:
blob = path.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", path, exc)
continue
lic = parse_license_blob(blob)
if lic is None:
continue
if path.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License filename mismatch: %s", path.name)
continue
licenses.append(lic)
return licenses
+146
View File
@@ -0,0 +1,146 @@
"""QML-facing license model: grid rows, Load License, replay trial marker."""
import logging
import shutil
import time
from pathlib import Path
from urllib.parse import unquote, urlparse
from PySide6.QtCore import Property, QObject, Signal, Slot
from pygui.backend.license.license_manager import (
LICENSE_FILENAME_RE,
License,
parse_license_blob,
read_license_files,
)
log = logging.getLogger(__name__)
TRIAL_DAYS = 30
def _to_local_path(path_or_url: str) -> Path:
"""Accept both plain paths and file:// URLs (QML FileDialog gives URLs)."""
if path_or_url.startswith("file:"):
return Path(unquote(urlparse(path_or_url).path))
return Path(path_or_url)
# ===== License Model =====
class LicenseModel(QObject):
"""Context property backing the About dialog license grid.
Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
``DataDir\\licenses``). The 30-day replay trial marker is
``<data_dir>/replay.lic``.
"""
licensesChanged = Signal()
def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
super().__init__(parent)
self._data_dir = Path(data_dir)
self._licenses_dir = self._data_dir / "licenses"
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
self._licenses: list[License] = []
self.refresh()
# ── Grid rows ─────────────────────────────────────────────────────
@Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
def licenses(self) -> list:
"""Rows for the About grid: serial, mfg date, level, license date."""
return [
{
"serial": lic.serial,
"mfgDate": lic.mfg_date_decimal,
"level": lic.level,
"licDate": lic.lic_date_decimal,
}
for lic in self._licenses
]
@Slot()
def refresh(self) -> None:
"""Re-scan the licenses directory."""
self._licenses = read_license_files(self._licenses_dir)
self.licensesChanged.emit()
# ── Load License ──────────────────────────────────────────────────
@Slot(str, result=bool)
def loadLicenseFile(self, path_or_url: str) -> bool:
"""Validate a picked .bin and copy it into the licenses directory."""
src = _to_local_path(path_or_url)
if not src.is_file():
log.warning("License file does not exist: %s", src)
return False
if not LICENSE_FILENAME_RE.match(src.name):
log.warning("License filename invalid: %s", src.name)
return False
try:
blob = src.read_bytes()
except OSError as exc:
log.warning("Cannot read license file %s: %s", src, exc)
return False
lic = parse_license_blob(blob)
if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
log.warning("License file failed validation: %s", src.name)
return False
try:
self._licenses_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, self._licenses_dir / src.name)
except OSError as exc:
log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
return False
self.refresh()
return True
# ── Wafer detect (informational) ──────────────────────────────────
@Slot(str, result=str)
def levelForWafer(self, serial: str) -> str:
"""License level for a wafer serial like "A00001"; "" if unlicensed."""
for lic in self._licenses:
if lic.serial == serial:
return lic.level
return ""
# ── Replay trial (30-day marker, C# parity) ───────────────────────
@property
def _trial_marker(self) -> Path:
return self._data_dir / "replay.lic"
@Slot(result=str)
def replayLicenseState(self) -> str:
""""permanent" if any license level >= 1, "temporary" if trial
marker exists, else "none" (trial not started)."""
if any(lic.level_int >= 1 for lic in self._licenses):
return "permanent"
if self._trial_marker.exists():
return "temporary"
return "none"
@Slot(result=bool)
def startReplayTrial(self) -> bool:
"""Create the trial marker (idempotent). False on I/O failure."""
try:
self._trial_marker.touch(exist_ok=True)
return True
except OSError as exc:
log.warning("Cannot create trial marker: %s", exc)
return False
@Slot(result=bool)
def replayTrialExpired(self) -> bool:
"""True once the marker is older than 30 days (or missing)."""
marker = self._trial_marker
if not marker.exists():
return True
age_days = (time.time() - marker.stat().st_mtime) / 86400.0
return age_days > TRIAL_DAYS
-19
View File
@@ -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",
]
+2 -2
View File
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
# Column 0 = row index, columns 1..N = sensor values # Column 0 = row index, columns 1..N = sensor values
return self._col_count + 1 return self._col_count + 1
def data(self, index: Any, role: int = ...) -> Any: def data(self, index: Any, role: int = ...) -> Any: # type: ignore[assignment]
if not index.isValid(): if not index.isValid():
return None return None
@@ -73,7 +73,7 @@ class TemperatureTableModel(QAbstractTableModel):
return None return None
def headerData( def headerData(
self, section: int, orientation: int, role: int = ... self, section: int, orientation: Qt.Orientation, role: int = ... # type: ignore[assignment]
) -> Any: ) -> Any:
if role != Qt.ItemDataRole.DisplayRole: if role != Qt.ItemDataRole.DisplayRole:
return None return None
+18 -1
View File
@@ -4,10 +4,23 @@ from pygui.backend.models.frame import Frame
from pygui.backend.wafer.zwafer_models import ZWaferData from pygui.backend.wafer.zwafer_models import ZWaferData
def frames_from_wafer_data(data: ZWaferData, records) -> list[Frame]: def frames_from_wafer_data(data: ZWaferData | None, records) -> list[Frame]:
"""Build Frames from parsed DataRecords (records = list[DataRecord].)""" """Build Frames from parsed DataRecords (records = list[DataRecord].)"""
return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)] return [Frame(seq=i, time=r.time, values=list(r.values)) for i, r in enumerate(records)]
def all_sensor_series(frames: list[Frame]) -> tuple[list[str], list[list[float]]]:
"""Per-sensor value series across every frame, for the whole-run Graph tab.
Sensor names are 1-based ("Sensor 1", ...), matching the C# original.
"""
if not frames:
return [], []
n_sensors = len(frames[0].values)
names = [f"Sensor {i + 1}" for i in range(n_sensors)]
series = [[frame.values[i] for frame in frames] for i in range(n_sensors)]
return names, series
class FramePlayer: class FramePlayer:
def __init__(self) -> None: def __init__(self) -> None:
self._frames: list[Frame] =[] self._frames: list[Frame] =[]
@@ -22,6 +35,10 @@ class FramePlayer:
def total(self) -> int: def total(self) -> int:
return len(self._frames) return len(self._frames)
@property
def frames(self) -> tuple[Frame, ...]:
return tuple(self._frames)
@property @property
def index(self) -> int: def index(self) -> int:
return self._i return self._i
+38
View File
@@ -14,6 +14,44 @@ class Stats:
sigma: float; three_sigma: float sigma: float; three_sigma: float
@dataclass(frozen=True)
class EdgeCenterStats:
min_edge_index: int; min_center_index: int
min_edge: float; min_center: float; min_delta: float
max_edge_index: int; max_center_index: int
max_edge: float; max_center: float; max_delta: float
def compute_edge_center(
values: list[float],
pairs: tuple[tuple[int, int], ...],
excluded: set[int],
) -> EdgeCenterStats | None:
"""Min/max |edge - center| over the family's pair table.
Pairs with an excluded (replaced) sensor, an index outside the frame, or a
NaN value are skipped. Returns None when no pair is computable.
"""
n = len(values)
best: list[tuple[float, int, int]] = []
for e, c in pairs:
if e in excluded or c in excluded or e >= n or c >= n:
continue
if math.isnan(values[e]) or math.isnan(values[c]):
continue
best.append((abs(values[e] - values[c]), e, c))
if not best:
return None
min_d, min_e, min_c = min(best)
max_d, max_e, max_c = max(best)
return EdgeCenterStats(
min_edge_index=min_e, min_center_index=min_c,
min_edge=values[min_e], min_center=values[min_c], min_delta=min_d,
max_edge_index=max_e, max_center_index=max_c,
max_edge=values[max_e], max_center=values[max_c], max_delta=max_d,
)
def compute_stats(values: list[float]) -> Stats: def compute_stats(values: list[float]) -> Stats:
clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)] clean = [(i, v) for i, v in enumerate(values) if not math.isnan(v)]
if not clean: if not clean:
@@ -28,6 +28,11 @@ class SensorEditor:
def has_overrides(self) -> bool: def has_overrides(self) -> bool:
return bool(self._replacements or self._offsets) return bool(self._replacements or self._offsets)
def replaced_indices(self) -> set[int]:
"""Sensors whose value is a replacement (offsets don't count — an
offset sensor still reads from its physical location)."""
return set(self._replacements)
def active_indices(self) -> list[int]: def active_indices(self) -> list[int]:
"""Return sorted sensor indices that have any override.""" """Return sorted sensor indices that have any override."""
return sorted(set(self._replacements) | set(self._offsets)) return sorted(set(self._replacements) | set(self._offsets))
@@ -1,11 +0,0 @@
# ===== Visualization Sub-package =====
from pygui.backend.visualization.graph_quick_item import GraphQuickItem
from pygui.backend.visualization.graph_view import GraphView
from pygui.backend.visualization.rbf_heatmap import interpolate_field
from pygui.backend.visualization.trend_chart_item import TrendChartItem
from pygui.backend.visualization.wafer_map_item import WaferMapItem
__all__ = [
"GraphQuickItem", "GraphView", "TrendChartItem", "WaferMapItem",
"interpolate_field",
]
@@ -0,0 +1,52 @@
"""Pure coordinate-mapping math shared by the QQuickPaintedItem chart items
(GraphQuickItem, TrendChartItem). No QPainter or Qt-widget dependency, so
it's testable without a GUI — unlike the paint() methods that call it.
"""
from __future__ import annotations
def value_to_pixel(
value: float,
value_min: float,
value_max: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map a value onto a pixel range, inverted so larger values sit at
smaller pixel coordinates (screen y grows downward).
Also used for evenly-spaced tick indices: pass the tick index as
`value` and `(tick_count - 1)` as `value_max` with `value_min=0`.
"""
value_range = value_max - value_min
if value_range < 1e-9:
return pixel_start + pixel_span / 2
fraction = 1.0 - (value - value_min) / value_range
return pixel_start + fraction * pixel_span
def index_to_pixel(index: float, count: int, pixel_start: float, pixel_span: float) -> float:
"""Map a 0-based sample index onto a pixel range, left to right."""
if count <= 1:
return pixel_start + pixel_span / 2
fraction = index / (count - 1)
return pixel_start + fraction * pixel_span
def elapsed_to_pixel(
elapsed: float,
window_start: float,
window_end: float,
pixel_start: float,
pixel_span: float,
) -> float:
"""Map an elapsed-seconds value onto a pixel range, left to right.
Unlike `value_to_pixel`, this is not inverted: larger elapsed values sit
at larger pixel x-coordinates, matching how time flows left-to-right.
"""
window_span = window_end - window_start
if window_span < 1e-9:
return pixel_start + pixel_span
fraction = (elapsed - window_start) / window_span
return pixel_start + fraction * pixel_span
@@ -21,15 +21,60 @@ Usage from QML::
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Property, QRectF, Qt, Signal import math
from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
def viewport_stats(
series: list[list[float]],
names: list[str],
start: int,
end: int,
) -> tuple[float, float, float, str, str]:
"""Aggregate min/max/avg across every sensor's points in `[start, end]`.
Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
every visible point from every series, plus which sensor achieved each
extreme. Non-numeric values are skipped. No points in range -> zeros and
empty sensor names.
"""
vmin = math.inf
vmax = -math.inf
total = 0.0
count = 0
min_sensor = ""
max_sensor = ""
for i, s in enumerate(series):
name = names[i] if i < len(names) else ""
lo = max(0, start)
hi = min(len(s) - 1, end)
for j in range(lo, hi + 1):
try:
v = float(s[j])
except (TypeError, ValueError):
continue
if v < vmin:
vmin = v
min_sensor = name
if v > vmax:
vmax = v
max_sensor = name
total += v
count += 1
if count == 0:
return (0.0, 0.0, 0.0, "", "")
return (vmin, vmax, total / count, min_sensor, max_sensor)
@QmlElement @QmlElement
class GraphQuickItem(QQuickPaintedItem): class GraphQuickItem(QQuickPaintedItem):
"""Painted line chart; driven by series data passed via QML property bindings.""" """Painted line chart; driven by series data passed via QML property bindings."""
@@ -40,6 +85,8 @@ class GraphQuickItem(QQuickPaintedItem):
xLabelChanged = Signal() xLabelChanged = Signal()
yLabelChanged = Signal() yLabelChanged = Signal()
colorsChanged = Signal() colorsChanged = Signal()
viewportChanged = Signal()
viewStatsChanged = Signal()
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -72,16 +119,37 @@ class GraphQuickItem(QQuickPaintedItem):
self._max_y: float = 150.0 self._max_y: float = 150.0
self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50} self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 30, "bottom": 50}
# Viewport (replay chart zoom/pan): defaults reproduce "whole series",
# so the Graph tab (which never sets these) is unaffected. See
# docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
self._view_start: int = 0
self._view_end: int = -1
self._show_min_max_markers: bool = False
self._view_min: float = 0.0
self._view_max: float = 0.0
self._view_avg: float = 0.0
self._view_min_sensor: str = ""
self._view_max_sensor: str = ""
# ── Qt properties (QML-bindable) ────────────────────────────────────────── # ── Qt properties (QML-bindable) ──────────────────────────────────────────
@Property("QVariantList", notify=seriesDataChanged) @Property("QVariantList", notify=seriesDataChanged)
def seriesData(self) -> list: def seriesData(self) -> list:
return self._series_data return self._series_data
@seriesData.setter @seriesData.setter # type: ignore[no-redef]
def seriesData(self, val: list) -> None: def seriesData(self, val: list) -> None:
self._series_data = [list(s) for s in (val or [])] self._series_data = [list(s) for s in (val or [])]
# New data resets the viewport to the full range -- matches
# PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
# before RecalculateAxesScale(). Without this, a stale viewport left
# over from a previous (longer) series can point entirely past the
# new data -- every per-series slice becomes empty and the chart
# silently renders nothing instead of the new data.
self._view_start = 0
self._view_end = -1
self._auto_range() self._auto_range()
self._recompute_view_stats()
self.seriesDataChanged.emit() self.seriesDataChanged.emit()
self.update() self.update()
@@ -89,17 +157,138 @@ class GraphQuickItem(QQuickPaintedItem):
def sensorNames(self) -> list: def sensorNames(self) -> list:
return self._sensor_names return self._sensor_names
@sensorNames.setter @sensorNames.setter # type: ignore[no-redef]
def sensorNames(self, val: list) -> None: def sensorNames(self, val: list) -> None:
self._sensor_names = list(val or []) self._sensor_names = list(val or [])
self._recompute_view_stats()
self.sensorNamesChanged.emit() self.sensorNamesChanged.emit()
self.update() self.update()
# ── Viewport properties (replay chart zoom/pan) ────────────────────────
@Property(int, notify=viewportChanged)
def viewStartIndex(self) -> int:
return self._view_start
@viewStartIndex.setter # type: ignore[no-redef]
def viewStartIndex(self, val: int) -> None:
v = max(0, int(val))
if v == self._view_start:
return
self._view_start = v
self._on_viewport_changed()
@Property(int, notify=viewportChanged)
def viewEndIndex(self) -> int:
return self._view_end
@viewEndIndex.setter # type: ignore[no-redef]
def viewEndIndex(self, val: int) -> None:
v = int(val)
if v == self._view_end:
return
self._view_end = v
self._on_viewport_changed()
@Property(bool, notify=viewportChanged)
def showMinMaxMarkers(self) -> bool:
return self._show_min_max_markers
@showMinMaxMarkers.setter # type: ignore[no-redef]
def showMinMaxMarkers(self, val: bool) -> None:
v = bool(val)
if v == self._show_min_max_markers:
return
self._show_min_max_markers = v
self.viewportChanged.emit()
self.update()
@Property(float, notify=viewStatsChanged)
def viewMin(self) -> float:
return self._view_min
@Property(float, notify=viewStatsChanged)
def viewMax(self) -> float:
return self._view_max
@Property(float, notify=viewStatsChanged)
def viewAvg(self) -> float:
return self._view_avg
@Property(str, notify=viewStatsChanged)
def viewMinSensor(self) -> str:
return self._view_min_sensor
@Property(str, notify=viewStatsChanged)
def viewMaxSensor(self) -> str:
return self._view_max_sensor
# ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
# with fractions derived from wheel/drag pixel deltas) ───────────────
@Slot(float, float)
def zoomAtFraction(self, frac: float, factor: float) -> None:
"""Zoom the viewport around a fractional X position within it.
`frac` in [0, 1] is where within the current viewport to zoom around;
`factor < 1` narrows the window (zoom in), `factor > 1` widens it
(zoom out). Clamped to data bounds and a minimum 1-index window.
"""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
f = max(0.0, min(1.0, frac))
center = start + f * width
new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
new_start = center - f * new_width
new_end = new_start + new_width
self._set_viewport_clamped(new_start, new_end, max_len)
@Slot(float)
def panByFraction(self, delta_frac: float) -> None:
"""Shift the viewport by `delta_frac * window_width` indices."""
max_len = max((len(s) for s in self._series_data), default=0)
if max_len < 2:
return
start = float(self._view_start)
end = float(self._resolved_view_end())
width = max(1.0, end - start)
shift = delta_frac * width
self._set_viewport_clamped(start + shift, end + shift, max_len)
@Slot()
def resetZoom(self) -> None:
"""Restore the full-range viewport sentinel (0, -1)."""
self._set_viewport(0, -1)
def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
if new_start < 0.0:
new_end -= new_start
new_start = 0.0
if new_end > max_len - 1:
new_start -= new_end - (max_len - 1)
new_end = float(max_len - 1)
new_start = max(0.0, new_start)
self._set_viewport(int(round(new_start)), int(round(new_end)))
def _set_viewport(self, start: int, end: int) -> None:
"""Set the viewport bounds directly (not through the QML-facing
property setters, so this stays a plain attribute write for mypy)."""
new_start = max(0, start)
if new_start == self._view_start and end == self._view_end:
return
self._view_start = new_start
self._view_end = end
self._on_viewport_changed()
@Property(str, notify=titleChanged) @Property(str, notify=titleChanged)
def title(self) -> str: def title(self) -> str:
return self._title return self._title
@title.setter @title.setter # type: ignore[no-redef]
def title(self, val: str) -> None: def title(self, val: str) -> None:
self._title = str(val or "") self._title = str(val or "")
self.titleChanged.emit() self.titleChanged.emit()
@@ -109,7 +298,7 @@ class GraphQuickItem(QQuickPaintedItem):
def xLabel(self) -> str: def xLabel(self) -> str:
return self._x_label return self._x_label
@xLabel.setter @xLabel.setter # type: ignore[no-redef]
def xLabel(self, val: str) -> None: def xLabel(self, val: str) -> None:
self._x_label = str(val or "") self._x_label = str(val or "")
self.xLabelChanged.emit() self.xLabelChanged.emit()
@@ -119,7 +308,7 @@ class GraphQuickItem(QQuickPaintedItem):
def yLabel(self) -> str: def yLabel(self) -> str:
return self._y_label return self._y_label
@yLabel.setter @yLabel.setter # type: ignore[no-redef]
def yLabel(self, val: str) -> None: def yLabel(self, val: str) -> None:
self._y_label = str(val or "") self._y_label = str(val or "")
self.yLabelChanged.emit() self.yLabelChanged.emit()
@@ -129,7 +318,7 @@ class GraphQuickItem(QQuickPaintedItem):
def showLegend(self) -> bool: def showLegend(self) -> bool:
return self._show_legend return self._show_legend
@showLegend.setter @showLegend.setter # type: ignore[no-redef]
def showLegend(self, val: bool) -> None: def showLegend(self, val: bool) -> None:
self._show_legend = bool(val) self._show_legend = bool(val)
self.colorsChanged.emit() self.colorsChanged.emit()
@@ -141,7 +330,7 @@ class GraphQuickItem(QQuickPaintedItem):
def backgroundColor(self) -> QColor: def backgroundColor(self) -> QColor:
return self._bg_color return self._bg_color
@backgroundColor.setter @backgroundColor.setter # type: ignore[no-redef]
def backgroundColor(self, c: QColor) -> None: def backgroundColor(self, c: QColor) -> None:
self._bg_color = c self._bg_color = c
self.update() self.update()
@@ -150,7 +339,7 @@ class GraphQuickItem(QQuickPaintedItem):
def gridColor(self) -> QColor: def gridColor(self) -> QColor:
return self._grid_color return self._grid_color
@gridColor.setter @gridColor.setter # type: ignore[no-redef]
def gridColor(self, c: QColor) -> None: def gridColor(self, c: QColor) -> None:
self._grid_color = c self._grid_color = c
self.update() self.update()
@@ -159,7 +348,7 @@ class GraphQuickItem(QQuickPaintedItem):
def axisColor(self) -> QColor: def axisColor(self) -> QColor:
return self._axis_color return self._axis_color
@axisColor.setter @axisColor.setter # type: ignore[no-redef]
def axisColor(self, c: QColor) -> None: def axisColor(self, c: QColor) -> None:
self._axis_color = c self._axis_color = c
self.update() self.update()
@@ -168,7 +357,7 @@ class GraphQuickItem(QQuickPaintedItem):
def textColor(self) -> QColor: def textColor(self) -> QColor:
return self._text_color return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: def textColor(self, c: QColor) -> None:
self._text_color = c self._text_color = c
self.update() self.update()
@@ -178,7 +367,7 @@ class GraphQuickItem(QQuickPaintedItem):
"""Return hex strings of the current series color palette.""" """Return hex strings of the current series color palette."""
return [c.name() for c in self._series_colors] return [c.name() for c in self._series_colors]
@seriesColors.setter @seriesColors.setter # type: ignore[no-redef]
def seriesColors(self, hex_list: list) -> None: def seriesColors(self, hex_list: list) -> None:
colors = [] colors = []
for h in (hex_list or []): for h in (hex_list or []):
@@ -192,13 +381,38 @@ class GraphQuickItem(QQuickPaintedItem):
# ── internal ────────────────────────────────────────────────────────────── # ── internal ──────────────────────────────────────────────────────────────
def _resolved_view_end(self) -> int:
"""`viewEndIndex` with the -1 ("last index") sentinel resolved."""
max_len = max((len(s) for s in self._series_data), default=0)
if self._view_end < 0 or self._view_end > max_len - 1:
return max_len - 1
return self._view_end
def _on_viewport_changed(self) -> None:
self._auto_range()
self._recompute_view_stats()
self.viewportChanged.emit()
self.update()
def _recompute_view_stats(self) -> None:
end = self._resolved_view_end()
vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
self._series_data, self._sensor_names, self._view_start, end
)
self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
self.viewStatsChanged.emit()
def _auto_range(self) -> None: def _auto_range(self) -> None:
"""Set Y range to cover all data with 10% padding.""" """Set Y range to cover the current viewport's data with 10% padding."""
start = max(0, self._view_start)
end = self._resolved_view_end()
all_vals: list[float] = [] all_vals: list[float] = []
for series in self._series_data: for series in self._series_data:
for v in series: hi = min(len(series) - 1, end)
for i in range(start, hi + 1):
try: try:
all_vals.append(float(v)) all_vals.append(float(series[i]))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if not all_vals: if not all_vals:
@@ -275,8 +489,7 @@ class GraphQuickItem(QQuickPaintedItem):
for i in range(n_y_ticks): for i in range(n_y_ticks):
y_val = self._min_y + i * y_step y_val = self._min_y + i * y_step
y_frac = 1.0 - (i / (n_y_ticks - 1)) if n_y_ticks > 1 else 0.5 y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)
y_px = plot_top + y_frac * plot_h
# Grid line # Grid line
painter.setPen(grid_pen) painter.setPen(grid_pen)
@@ -318,19 +531,23 @@ class GraphQuickItem(QQuickPaintedItem):
self._x_label, self._x_label,
) )
# --- X-axis ticks --- # --- Viewport slice (replay chart zoom/pan; full range by default,
num_points = max(len(s) for s in self._series_data) if self._series_data else 0 # so unset viewport reproduces the pre-viewport rendering) ---
if num_points > 1: view_start = max(0, self._view_start)
n_x_ticks = min(num_points, max(2, int(plot_w // 80))) view_end = self._resolved_view_end()
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1) view_len = max(1, view_end - view_start + 1)
# --- X-axis ticks (labelled in original, non-sliced index space) ---
if view_len > 1:
n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
for i in range(n_x_ticks): for i in range(n_x_ticks):
idx = int(round(i * x_tick_step)) rel_idx = int(round(i * x_tick_step))
x_frac = idx / (num_points - 1) x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
x_px = plot_left + x_frac * plot_w
painter.setPen(axis_pen) painter.setPen(axis_pen)
painter.drawText( painter.drawText(
int(x_px - 12), int(plot_top + plot_h + 14), int(x_px - 12), int(plot_top + plot_h + 14),
str(idx + 1), str(view_start + rel_idx + 1),
) )
# --- Axes boundary --- # --- Axes boundary ---
@@ -339,30 +556,36 @@ class GraphQuickItem(QQuickPaintedItem):
painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h)) painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))
# --- Figure out max series length --- # --- Draw each series (sliced to the current viewport) ---
max_len = max(len(s) for s in self._series_data) if self._series_data else 0 min_pt: tuple[float, float] | None = None
if max_len < 1: max_pt: tuple[float, float] | None = None
return min_seen = math.inf
max_seen = -math.inf
# --- Draw each series ---
for si, series in enumerate(self._series_data): for si, series in enumerate(self._series_data):
if len(series) < 1: hi = min(len(series) - 1, view_end)
sliced = series[view_start:hi + 1] if view_start <= hi else []
if not sliced:
continue continue
color = self._series_colors[si % len(self._series_colors)] color = self._series_colors[si % len(self._series_colors)]
pen = QPen(color, 2) pen = QPen(color, 2)
painter.setPen(pen) painter.setPen(pen)
pts: list[tuple[float, float]] = [] pts: list[tuple[float, float]] = []
for i, val in enumerate(series): for i, val in enumerate(sliced):
try: try:
v = float(val) v = float(val)
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
x_frac = i / (max_len - 1) if max_len > 1 else 0.5 x_px = index_to_pixel(i, view_len, plot_left, plot_w)
y_frac = 1.0 - ((v - self._min_y) / y_range) if y_range > 0 else 0.5 y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
x_px = plot_left + x_frac * plot_w
y_px = plot_top + y_frac * plot_h
pts.append((x_px, y_px)) pts.append((x_px, y_px))
if self._show_min_max_markers:
if v < min_seen:
min_seen = v
min_pt = (x_px, y_px)
if v > max_seen:
max_seen = v
max_pt = (x_px, y_px)
if len(pts) < 2: if len(pts) < 2:
if len(pts) == 1: if len(pts) == 1:
@@ -375,6 +598,19 @@ class GraphQuickItem(QQuickPaintedItem):
int(pts[i + 1][0]), int(pts[i + 1][1]), int(pts[i + 1][0]), int(pts[i + 1][1]),
) )
# --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
if self._show_min_max_markers:
marker_font = QFont()
marker_font.setPixelSize(14)
marker_font.setBold(True)
painter.setFont(marker_font)
if max_pt is not None:
painter.setPen(QPen(QColor("#FF5757")))
painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "")
if min_pt is not None:
painter.setPen(QPen(QColor("#42A5F5")))
painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "")
# --- Legend --- # --- Legend ---
if self._show_legend and self._sensor_names: if self._show_legend and self._sensor_names:
legend_font = QFont() legend_font = QFont()
@@ -23,6 +23,9 @@ class GraphView(QObject):
each sensor as a separate line series. each sensor as a separate line series.
""" """
# Set lazily in updateTrend (guarded by hasattr); declared for mypy.
_trend_line: Any
# ---- signals ---- # ---- signals ----
dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list} dataReady = Signal(object) # {"success": bool, "sensory_names": list, "series": list}
@@ -227,7 +230,7 @@ class GraphView(QObject):
index_a, index_b = path[i] index_a, index_b = path[i]
if index_a < len(a) and index_b < len(b): if index_a < len(a) and index_b < len(b):
line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1, line = pg.InfiniteLine(pos=index_a, angle=90, pen=pg.mkPen("#666666", width=1,
style=Qt.DashLine)) style=Qt.PenStyle.DashLine))
self._plot_widget.addItem(line) self._plot_widget.addItem(line)
self._plot_widget.setTitle("DTW Comparison") self._plot_widget.setTitle("DTW Comparison")
+50 -6
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from scipy.interpolate import RBFInterpolator from scipy.interpolate import RBFInterpolator
from scipy.ndimage import zoom as _ndi_zoom
try: try:
import cupy as _cupy # type: ignore import cupy as _cupy # type: ignore
@@ -17,6 +18,17 @@ except Exception:
_KERNEL = "thin_plate_spline" _KERNEL = "thin_plate_spline"
_SMOOTHING = 0.0 _SMOOTHING = 0.0
# Sensor density (≤244 points) carries no more spatial information than this;
# evaluating on a bigger grid than the widget's pixel size just burns CPU that
# a GPU-bilinear QImage upscale reproduces visually. See alpha-release-polish-plan.md §2.2.
GRID_RES = 100
# Bilinear-scaling the raw 100x100 field straight to widget size shows visible
# facets on real (higher-contrast) sensor data. A cheap cubic pre-smooth of the
# field (RBF grid is the expensive part; resampling a small array is not) removes
# the facets before the final QImage upscale does the rest.
REFINE_FACTOR = 2
def interpolate_field( def interpolate_field(
xs: np.ndarray, xs: np.ndarray,
@@ -28,26 +40,30 @@ def interpolate_field(
extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm extent: tuple[float, float, float, float], # (xmin, xmax, ymin, ymax) in mm
round_clip: bool = False, round_clip: bool = False,
) -> np.ndarray: ) -> np.ndarray:
"""Return a (height, width) float64 array of interpolated values. """Return a float64 array of interpolated values, capped at GRID_RES per side.
Args: Args:
xs, ys: sensor positions in mm (1-D arrays, length N) xs, ys: sensor positions in mm (1-D arrays, length N)
vs: sensor values (length N) vs: sensor values (length N)
width/height: output grid dimensions in pixels width/height: desired output dimensions in pixels; the grid is solved at
min(dim, GRID_RES) and the caller upscales the resulting image
extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys extent: (xmin, xmax, ymin, ymax) in the same mm space as xs/ys
round_clip: if True, pixels outside the inscribed ellipse become NaN round_clip: if True, pixels outside the inscribed ellipse become NaN
""" """
coords = np.column_stack([xs, ys]) coords = np.column_stack([xs, ys])
rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING) rbf = RBFInterpolator(coords, vs, kernel=_KERNEL, smoothing=_SMOOTHING)
grid_w = min(width, GRID_RES)
grid_h = min(height, GRID_RES)
xmin, xmax, ymin, ymax = extent xmin, xmax, ymin, ymax = extent
gx = np.linspace(xmin, xmax, width) gx = np.linspace(xmin, xmax, grid_w)
gy = np.linspace(ymin, ymax, height) gy = np.linspace(ymin, ymax, grid_h)
grid_x, grid_y = np.meshgrid(gx, gy) grid_x, grid_y = np.meshgrid(gx, gy)
flat = np.column_stack([grid_x.ravel(), grid_y.ravel()]) flat = np.column_stack([grid_x.ravel(), grid_y.ravel()])
# RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later # RBFInterpolator always runs on CPU; CuPy only accelerates other ops if added later
field = rbf(flat).reshape(height, width) field = rbf(flat).reshape(grid_h, grid_w)
if round_clip: if round_clip:
cx = (xmin + xmax) / 2 cx = (xmin + xmax) / 2
@@ -57,5 +73,33 @@ def interpolate_field(
dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2 dist = ((grid_x - cx) / rx) ** 2 + ((grid_y - cy) / ry) ** 2
field = np.where(dist <= 1.0, field, np.nan) field = np.where(dist <= 1.0, field, np.nan)
return field.astype(np.float64) result: np.ndarray = field.astype(np.float64)
return result
def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
"""Cubic-upsample a coarse (NaN-free) field by `factor`.
Callers that need a circular boundary should pass an *unclipped* field
(round_clip=False) and mask with ellipse_alpha() afterwards cubic
resampling across a NaN/zero-filled hole rings at the boundary, and any
mask derived from the coarse grid keeps its staircase when upscaled.
"""
refined: np.ndarray = _ndi_zoom(field, factor, order=3)
return refined
def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray:
"""Anti-aliased coverage mask (0..1) of the ellipse inscribed in the grid.
Computed analytically at the target resolution unlike upsampling a
binary clip mask from the coarse RBF grid, this cannot show grid steps.
`feather_px` is the half-width of the linear edge ramp, in grid pixels.
"""
y = (np.arange(height) + 0.5) / height * 2.0 - 1.0
x = (np.arange(width) + 0.5) / width * 2.0 - 1.0
yy, xx = np.meshgrid(y, x, indexing="ij")
r = np.sqrt(xx * xx + yy * yy)
f = feather_px * 2.0 / min(width, height)
return np.clip((1.0 - r) / f + 0.5, 0.0, 1.0)
@@ -1,12 +1,15 @@
"""QQuickPaintedItem trend chart — running-average temperature over time. """QQuickPaintedItem trend chart — running-average temperature over time.
Renders a single polyline series (per-frame average temperatures) using QPainter. Renders a single polyline series (per-frame average temperatures) over a fixed
Mirrors the @QmlElement registration pattern used by `WaferMapItem` so it can 0-200°C Y axis and an elapsed-seconds X axis, using a bounded 60-second
be embedded directly in QML via `import ISC.Wafer` and used as `TrendChartItem { }`. rolling window. Mirrors the @QmlElement registration pattern used by
`WaferMapItem` so it can be embedded directly in QML via `import ISC.Wafer`
and used as `TrendChartItem { }`.
The series is driven by the existing `streamController.trendData` signal, which The series is driven by `streamController.trendDelta` (one new
emits a JSON-encoded list of floats from both review-mode replay and live-mode `[elapsed_s, value]` point per live frame) and cleared on
streaming. `streamController.trendReset` (new stream started). See
docs/adr/0001-trend-chart-axes-standardization.md.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,17 +22,23 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPolygon
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.chart_geometry import elapsed_to_pixel, value_to_pixel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
WINDOW_SECONDS = 60.0
Y_MIN = 0.0
Y_MAX = 200.0
Y_TICKS = (0, 40, 80, 120, 160, 200)
@QmlElement @QmlElement
class TrendChartItem(QQuickPaintedItem): class TrendChartItem(QQuickPaintedItem):
"""Painted trend chart; driven by a list of floats via the `data` property.""" """Painted trend chart; driven by incremental (elapsed_s, value) points."""
dataChanged = Signal()
hasDataChanged = Signal() hasDataChanged = Signal()
lineColorChanged = Signal() lineColorChanged = Signal()
gridColorChanged = Signal() gridColorChanged = Signal()
@@ -42,41 +51,35 @@ class TrendChartItem(QQuickPaintedItem):
self.setAntialiasing(True) self.setAntialiasing(True)
self.setFillColor(QColor("transparent")) self.setFillColor(QColor("transparent"))
self._data: list[float] = [] self._points: list[tuple[float, float]] = []
self._padding: int = 8 self._padding: int = 8
# Separate from `_padding` (breathing room around the plot area): these
# reserve space for the axis labels themselves, which are wider than
# `_padding` alone — that mismatch is what clipped y-axis text before.
self._margin_left: int = 48
self._margin_bottom: int = 30
# Top tick label is drawn centered on the plot's top edge (y_px - 7),
# so the plot needs headroom or the label kisses the card border.
self._margin_top: int = 10
self._line_color = QColor("#5B9DF5") self._line_color = QColor("#5B9DF5")
self._grid_color = QColor("#2A3441") self._grid_color = QColor("#2A3441")
self._text_color = QColor("#CBD5E1") self._text_color = QColor("#CBD5E1")
# ── Qt properties ───────────────────────────────────────────────────── # ── Qt properties ─────────────────────────────────────────────────────
@Property("QVariantList", notify=dataChanged)
def data(self) -> list[float]:
return self._data
@data.setter
def data(self, val) -> None:
coerced = self._coerce_floats(val)
if coerced == self._data:
return
self._data = coerced
self.dataChanged.emit()
self.hasDataChanged.emit()
self.update()
@Property(bool, notify=hasDataChanged) @Property(bool, notify=hasDataChanged)
def hasData(self) -> bool: def hasData(self) -> bool:
"""True when the data list has at least one point. """True when at least one point is buffered.
QML can bind to this safely (QVariantList `.length` is not bindable). QML can bind to this safely (a plain list length is not bindable).
""" """
return len(self._data) > 0 return len(self._points) > 0
@Property(QColor, notify=lineColorChanged) @Property(QColor, notify=lineColorChanged)
def lineColor(self) -> QColor: def lineColor(self) -> QColor:
return self._line_color return self._line_color
@lineColor.setter @lineColor.setter # type: ignore[no-redef]
def lineColor(self, c: QColor) -> None: def lineColor(self, c: QColor) -> None:
if c == self._line_color: if c == self._line_color:
return return
@@ -88,7 +91,7 @@ class TrendChartItem(QQuickPaintedItem):
def gridColor(self) -> QColor: def gridColor(self) -> QColor:
return self._grid_color return self._grid_color
@gridColor.setter @gridColor.setter # type: ignore[no-redef]
def gridColor(self, c: QColor) -> None: def gridColor(self, c: QColor) -> None:
if c == self._grid_color: if c == self._grid_color:
return return
@@ -100,7 +103,7 @@ class TrendChartItem(QQuickPaintedItem):
def textColor(self) -> QColor: def textColor(self) -> QColor:
return self._text_color return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: def textColor(self, c: QColor) -> None:
if c == self._text_color: if c == self._text_color:
return return
@@ -112,7 +115,7 @@ class TrendChartItem(QQuickPaintedItem):
def padding(self) -> int: def padding(self) -> int:
return self._padding return self._padding
@padding.setter @padding.setter # type: ignore[no-redef]
def padding(self, val: int) -> None: def padding(self, val: int) -> None:
v = max(0, int(val)) v = max(0, int(val))
if v == self._padding: if v == self._padding:
@@ -121,24 +124,48 @@ class TrendChartItem(QQuickPaintedItem):
self.paddingChanged.emit() self.paddingChanged.emit()
self.update() self.update()
# ── Convenience slot for QML: accept a JSON string from a Signal(str) ── # ── slots for QML: streamController.trendDelta / trendReset ────────────
@Slot(str) @Slot(str)
def setDataFromJson(self, avgs_json: str) -> None: def appendDelta(self, delta_json: str) -> None:
"""Slot for QML Connections handler: parse JSON array and update data. """Append new (elapsed_s, value) points and prune the 60s window.
Mirrors `streamController.trendData` (which emits JSON-encoded floats). Mirrors `streamController.trendDelta`, which emits one new pair per
live frame as `[[elapsed_s, value], ...]`.
""" """
try: try:
parsed = json.loads(avgs_json) if avgs_json else [] parsed = json.loads(delta_json) if delta_json else []
except (json.JSONDecodeError, TypeError) as exc: except (json.JSONDecodeError, TypeError) as exc:
log.error("TrendChartItem: failed to parse trend JSON: %s", exc) log.error("TrendChartItem: failed to parse trend delta JSON: %s", exc)
return return
coerced = self._coerce_floats(parsed)
if coerced == self._data: had_data = len(self._points) > 0
added = False
for pair in parsed:
try:
elapsed, value = float(pair[0]), float(pair[1])
except (TypeError, ValueError, IndexError):
continue
self._points.append((elapsed, value))
added = True
if not added:
return return
self._data = coerced
self.dataChanged.emit() latest_elapsed = self._points[-1][0]
cutoff = latest_elapsed - WINDOW_SECONDS
self._points = [p for p in self._points if p[0] >= cutoff]
if not had_data:
self.hasDataChanged.emit()
self.update()
@Slot()
def clearTrend(self) -> None:
"""Clear all buffered points (new stream started)."""
if not self._points:
return
self._points = []
self.hasDataChanged.emit() self.hasDataChanged.emit()
self.update() self.update()
@@ -153,57 +180,39 @@ class TrendChartItem(QQuickPaintedItem):
if w <= 0 or h <= 0: if w <= 0 or h <= 0:
return return
plot_rect = QRectF(self._padding, self._padding, left = self._padding + self._margin_left
max(1, w - 2 * self._padding), top = self._padding + self._margin_top
max(1, h - 2 * self._padding)) bottom_inset = self._padding + self._margin_bottom
plot_rect = QRectF(left, top,
max(1, w - left - self._padding),
max(1, h - top - bottom_inset))
self._draw_grid(painter, plot_rect) self._draw_grid(painter, plot_rect)
if len(self._data) < 2: if len(self._points) < 2:
return return
y_min, y_max = self._y_range() window_end = self._points[-1][0]
self._draw_axes_labels(painter, plot_rect, y_min, y_max) window_start = max(0.0, window_end - WINDOW_SECONDS)
self._draw_line(painter, plot_rect, y_min, y_max) self._draw_axes_labels(painter, plot_rect, window_start, window_end)
self._draw_line(painter, plot_rect, window_start, window_end)
# ── internals ───────────────────────────────────────────────────────── # ── internals ─────────────────────────────────────────────────────────
def _coerce_floats(self, val) -> list[float]: def _x_to_px(self, elapsed: float, window_start: float, window_end: float, plot_rect: QRectF) -> float:
if not val: return elapsed_to_pixel(elapsed, window_start, window_end, plot_rect.left(), plot_rect.width())
return []
out: list[float] = []
for v in val:
try:
out.append(float(v))
except (TypeError, ValueError):
continue
return out
def _y_range(self) -> tuple[float, float]: def _y_to_px(self, value: float, plot_rect: QRectF) -> float:
lo = min(self._data) px = value_to_pixel(value, Y_MIN, Y_MAX, plot_rect.top(), plot_rect.height())
hi = max(self._data) # Clamp: a sensor reading outside 0-200°C shouldn't paint outside the widget.
if hi - lo < 1e-9: return max(plot_rect.top(), min(plot_rect.bottom(), px))
return lo - 5.0, hi + 5.0
buf = (hi - lo) * 0.1
return lo - buf, hi + buf
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1:
return plot_rect.left()
return plot_rect.left() + (i / (n - 1)) * plot_rect.width()
def _y_to_px(self, v: float, y_min: float, y_max: float, plot_rect: QRectF) -> float:
if y_max - y_min < 1e-9:
return plot_rect.center().y()
t = (v - y_min) / (y_max - y_min)
return plot_rect.bottom() - t * plot_rect.height()
def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None: def _draw_grid(self, painter: QPainter, plot_rect: QRectF) -> None:
pen = QPen(self._grid_color) pen = QPen(self._grid_color)
pen.setWidthF(1.0) pen.setWidthF(1.0)
pen.setCosmetic(True) pen.setCosmetic(True)
painter.setPen(pen) painter.setPen(pen)
rows = 4 rows = len(Y_TICKS) - 1
for i in range(rows + 1): for i in range(rows + 1):
y = plot_rect.top() + (i / rows) * plot_rect.height() y = plot_rect.top() + (i / rows) * plot_rect.height()
painter.drawLine(int(plot_rect.left()), int(y), painter.drawLine(int(plot_rect.left()), int(y),
@@ -213,31 +222,69 @@ class TrendChartItem(QQuickPaintedItem):
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
painter.setPen(self._text_color) painter.setPen(self._text_color)
font = QFont(painter.font()) font = QFont(painter.font())
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
painter.setFont(font) painter.setFont(font)
rows = 4
for i in range(rows + 1): title_w = 14 # leftmost sliver reserved for the rotated "Temp" title
t = i / rows label_w = self._margin_left - 4 - title_w # 4px gutter before the plot area
y_val = y_max - t * (y_max - y_min) for tick in reversed(Y_TICKS):
y_px = plot_rect.top() + t * plot_rect.height() y_px = self._y_to_px(tick, plot_rect)
label = f"{y_val:.1f}" painter.drawText(QRectF(title_w, y_px - 7, label_w, 14),
painter.drawText(QRectF(0, y_px - 7, self._padding, 14),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
label) str(tick))
# X-axis: elapsed seconds within the current rolling window.
cols = 4
y_px = plot_rect.bottom() + 4
for i in range(cols + 1):
t_val = window_start + (i / cols) * (window_end - window_start)
x_px = self._x_to_px(t_val, window_start, window_end, plot_rect)
label = f"{t_val:.0f}"
if i == 0:
rect = QRectF(x_px, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignLeft
elif i == cols:
# Right-anchor inside the plot so the label never clips off-widget
rect = QRectF(x_px - 40, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignRight
else:
rect = QRectF(x_px - 20, y_px, 40, 14)
align = Qt.AlignmentFlag.AlignHCenter
painter.drawText(rect, align | Qt.AlignmentFlag.AlignTop, label)
self._draw_axis_titles(painter, plot_rect, title_w, y_px + 18)
def _draw_axis_titles(
self,
painter: QPainter,
plot_rect: QRectF,
title_w: float,
x_label_bottom: float,
) -> None:
# Y axis: "Temp", rotated upright, centered along the plot's height.
painter.save()
painter.translate(title_w / 2, plot_rect.center().y())
painter.rotate(-90)
painter.drawText(QRectF(-plot_rect.height() / 2, -7, plot_rect.height(), 14),
Qt.AlignmentFlag.AlignCenter, "Temp (°C)")
painter.restore()
# X axis: "Time", centered under the elapsed-seconds tick row.
painter.drawText(QRectF(plot_rect.left(), x_label_bottom, plot_rect.width(), 14),
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop, "Time")
def _draw_line( def _draw_line(
self, self,
painter: QPainter, painter: QPainter,
plot_rect: QRectF, plot_rect: QRectF,
y_min: float, window_start: float,
y_max: float, window_end: float,
) -> None: ) -> None:
n = len(self._data)
pen = QPen(self._line_color) pen = QPen(self._line_color)
pen.setWidthF(2.0) pen.setWidthF(2.0)
pen.setCosmetic(True) pen.setCosmetic(True)
@@ -246,15 +293,16 @@ class TrendChartItem(QQuickPaintedItem):
painter.setPen(pen) painter.setPen(pen)
poly = QPolygon() poly = QPolygon()
for i, v in enumerate(self._data): for elapsed, value in self._points:
px = self._x_to_px(i, n, plot_rect) px = self._x_to_px(elapsed, window_start, window_end, plot_rect)
py = self._y_to_px(v, y_min, y_max, plot_rect) py = self._y_to_px(value, plot_rect)
poly.append(QPoint(int(px), int(py))) poly.append(QPoint(int(px), int(py)))
painter.drawPolyline(poly) painter.drawPolyline(poly)
# Trailing dot at the last data point # Trailing dot at the last data point
last_x = int(self._x_to_px(n - 1, n, plot_rect)) last_elapsed, last_value = self._points[-1]
last_y = int(self._y_to_px(self._data[-1], y_min, y_max, plot_rect)) last_x = int(self._x_to_px(last_elapsed, window_start, window_end, plot_rect))
last_y = int(self._y_to_px(last_value, plot_rect))
painter.setBrush(self._line_color) painter.setBrush(self._line_color)
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
radius = 3 radius = 3
+304 -83
View File
@@ -14,7 +14,7 @@ import logging
import math import math
import numpy as np import numpy as np
from PySide6.QtCore import Property, QPoint, Qt, Signal, Slot from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
from PySide6.QtGui import ( from PySide6.QtGui import (
QBrush, QBrush,
QColor, QColor,
@@ -27,11 +27,69 @@ from PySide6.QtGui import (
from PySide6.QtQml import QmlElement from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem from PySide6.QtQuick import QQuickPaintedItem
from pygui.backend.visualization.rbf_heatmap import interpolate_field from pygui.backend.models.frame_stats import compute_stats
from pygui.backend.visualization.rbf_heatmap import (
ellipse_alpha,
interpolate_field,
refine_field,
)
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
"""Parse a customer thickness CSV into [x, y, t2] points (mm from center).
Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
Returns (points, error) error is "" on success.
"""
import csv
points: list[list[float]] = []
slot: str | None = None
try:
with open(file_path, newline="") as fh:
reader = csv.reader(fh)
header = next(reader, None)
if header is None:
return [], "Unable to read header row from CSV file."
if header and header[-1] == "": # trailing comma
header = header[:-1]
nfields = len(header)
if nfields != 8:
return [], "Incorrect field count in CSV header row."
for lno, row in enumerate(reader, start=2):
if row and row[-1] == "":
row = row[:-1]
if len(row) != nfields:
return [], f"Incorrect field count in CSV row {lno}"
if slot is None:
slot = row[3]
elif row[3] != slot:
break
try:
t2, x, y = float(row[4]), float(row[6]), float(row[7])
except ValueError:
return [], f"Malformed value in CSV row {lno}"
points.append([x, y, t2])
except OSError as exc:
return [], f"Unable to read CSV file: {exc}"
if not points:
return [], "No data found in CSV file."
return points, ""
def readout_text(values: list[float]) -> str:
"""Full READOUT stats line for the export footer; '' when no data."""
if not values:
return ""
s = compute_stats(values)
return (f"Sensors: {len(values)} Min: {s.min:.2f}°C (#{s.min_index + 1}) "
f"Max: {s.max:.2f}°C (#{s.max_index + 1}) Diff: {s.diff:.2f} "
f"Avg: {s.avg:.2f} σ: {s.sigma:.2f} 3σ: {s.three_sigma:.2f}")
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1 QML_IMPORT_MAJOR_VERSION = 1
@@ -47,11 +105,14 @@ class WaferMapItem(QQuickPaintedItem):
marginChanged = Signal() marginChanged = Signal()
blendChanged = Signal() blendChanged = Signal()
showLabelsChanged = Signal() showLabelsChanged = Signal()
showExtremesChanged = Signal()
colorsChanged = Signal() colorsChanged = Signal()
shapeChanged = Signal() shapeChanged = Signal()
sizeChanged = Signal() sizeChanged = Signal()
thicknessChanged = Signal() thicknessChanged = Signal()
showThicknessChanged = Signal() showThicknessChanged = Signal()
hoveredIndexChanged = Signal()
hoverScaleChanged = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -63,11 +124,16 @@ class WaferMapItem(QQuickPaintedItem):
self._margin: float = 1.0 self._margin: float = 1.0
self._blend: float = 0.0 self._blend: float = 0.0
self._show_labels: bool = True self._show_labels: bool = True
self._show_extremes: bool = True
self._shape: str = "round" self._shape: str = "round"
self._size: float = 300.0 self._size: float = 300.0
self._thickness_data: list[float] = [] self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
self._show_thickness: bool = False self._show_thickness: bool = False
self._thickness_heatmap:QImage | None = None self._thickness_heatmap:QImage | None = None
# Hover highlight: index of the marker under the pointer (-1 = none)
# and a 1.0->~1.5 grow factor driven by a QML Behavior for a smooth animation.
self._hovered_index: int = -1
self._hover_scale: float = 1.0
# Dark-theme color defaults (match Theme.qml tokens) # Dark-theme color defaults (match Theme.qml tokens)
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder) self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
@@ -81,6 +147,12 @@ class WaferMapItem(QQuickPaintedItem):
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py) self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
self._marker_r: int = 4 self._marker_r: int = 4
self._heatmap: QImage | None = None self._heatmap: QImage | None = None
# Size the markers/heatmap were last computed against. On first activation
# (e.g. a freshly-created Loader item) width()/height() can still be 0/stale
# when `sensors` is first set, computing degenerate marker positions that
# never get retried. paint() re-checks this every frame so geometry that
# settles after property assignment still gets picked up.
self._last_draw_size: int = -1
self.widthChanged.connect(self._on_resize) self.widthChanged.connect(self._on_resize)
self.heightChanged.connect(self._on_resize) self.heightChanged.connect(self._on_resize)
@@ -91,7 +163,7 @@ class WaferMapItem(QQuickPaintedItem):
def sensors(self) -> list: def sensors(self) -> list:
return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors] return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]
@sensors.setter @sensors.setter # type: ignore[no-redef]
def sensors(self, val: list) -> None: def sensors(self, val: list) -> None:
self._sensors = [ self._sensors = [
Sensor( Sensor(
@@ -111,7 +183,7 @@ class WaferMapItem(QQuickPaintedItem):
def values(self) -> list: def values(self) -> list:
return self._values return self._values
@values.setter @values.setter # type: ignore[no-redef]
def values(self, val: list) -> None: def values(self, val: list) -> None:
self._values = list(val or []) self._values = list(val or [])
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -122,7 +194,7 @@ class WaferMapItem(QQuickPaintedItem):
def bands(self) -> list: def bands(self) -> list:
return self._bands return self._bands
@bands.setter @bands.setter # type: ignore[no-redef]
def bands(self, val: list) -> None: def bands(self, val: list) -> None:
self._bands = list(val or []) self._bands = list(val or [])
self.bandsChanged.emit() self.bandsChanged.emit()
@@ -132,7 +204,7 @@ class WaferMapItem(QQuickPaintedItem):
def target(self) -> float: def target(self) -> float:
return self._target return self._target
@target.setter @target.setter # type: ignore[no-redef]
def target(self, val: float) -> None: def target(self, val: float) -> None:
self._target = float(val) self._target = float(val)
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -143,7 +215,7 @@ class WaferMapItem(QQuickPaintedItem):
def margin(self) -> float: def margin(self) -> float:
return self._margin return self._margin
@margin.setter @margin.setter # type: ignore[no-redef]
def margin(self, val: float) -> None: def margin(self, val: float) -> None:
self._margin = float(val) self._margin = float(val)
self._rebuild_heatmap() self._rebuild_heatmap()
@@ -154,7 +226,7 @@ class WaferMapItem(QQuickPaintedItem):
def blend(self) -> float: def blend(self) -> float:
return self._blend return self._blend
@blend.setter @blend.setter # type: ignore[no-redef]
def blend(self, val: float) -> None: def blend(self, val: float) -> None:
self._blend = max(0.0, min(1.0, float(val))) self._blend = max(0.0, min(1.0, float(val)))
if self._blend > 0 and self._heatmap is None: if self._blend > 0 and self._heatmap is None:
@@ -166,17 +238,27 @@ class WaferMapItem(QQuickPaintedItem):
def showLabels(self) -> bool: def showLabels(self) -> bool:
return self._show_labels return self._show_labels
@showLabels.setter @showLabels.setter # type: ignore[no-redef]
def showLabels(self, val: bool) -> None: def showLabels(self, val: bool) -> None:
self._show_labels = bool(val) self._show_labels = bool(val)
self.showLabelsChanged.emit() self.showLabelsChanged.emit()
self.update() self.update()
@Property(bool, notify=showExtremesChanged)
def showExtremes(self) -> bool:
return self._show_extremes
@showExtremes.setter # type: ignore[no-redef]
def showExtremes(self, val: bool) -> None:
self._show_extremes = bool(val)
self.showExtremesChanged.emit()
self.update()
@Property(str, notify=shapeChanged) @Property(str, notify=shapeChanged)
def shape(self) -> str: def shape(self) -> str:
return self._shape return self._shape
@shape.setter @shape.setter # type: ignore[no-redef]
def shape(self, val: str) -> None: def shape(self, val: str) -> None:
self._shape = str(val).lower() self._shape = str(val).lower()
self._rebuild() self._rebuild()
@@ -186,7 +268,7 @@ class WaferMapItem(QQuickPaintedItem):
def size(self) -> float: def size(self) -> float:
return self._size return self._size
@size.setter @size.setter # type: ignore[no-redef]
def size(self, val: float) -> None: def size(self, val: float) -> None:
self._size = float(val) self._size = float(val)
self._rebuild() self._rebuild()
@@ -194,54 +276,95 @@ class WaferMapItem(QQuickPaintedItem):
@Property("QVariantList", notify=thicknessChanged) @Property("QVariantList", notify=thicknessChanged)
def thicknessData(self) -> list: def thicknessData(self) -> list:
"""Thickness measurement points as [x_mm, y_mm, t2] triples."""
return self._thickness_data return self._thickness_data
@thicknessData.setter @thicknessData.setter # type: ignore[no-redef]
def thicknessData(self, val:list) -> None: def thicknessData(self, val:list) -> None:
self._thickness_data = list(val or []) self._thickness_data = [list(p) for p in (val or [])]
self._rebuild_thickness() self._rebuild_thickness()
self.thicknessChanged.emit() self.thicknessChanged.emit()
self.update()
@Property(bool, notify=thicknessChanged)
def hasThickness(self) -> bool:
return bool(self._thickness_data)
@Slot(str, result=str)
def loadThickness(self, file_path: str) -> str:
"""Load a customer thickness CSV; returns error message, '' on success."""
points, err = parse_thickness_csv(file_path)
if err:
return err
self._thickness_data = points
self._rebuild_thickness()
self.thicknessChanged.emit()
self.update()
return ""
self.update self.update
@Property(bool, notify=showThicknessChanged) @Property(bool, notify=showThicknessChanged)
def showThickness(self) -> bool: def showThickness(self) -> bool:
return self._show_thickness return self._show_thickness
@showThickness.setter @showThickness.setter # type: ignore[no-redef]
def showThickness(self, val: bool) -> None: def showThickness(self, val: bool) -> None:
self._show_thickness = bool(val) self._show_thickness = bool(val)
self.showThicknessChanged.emit() self.showThicknessChanged.emit()
self.update() self.update()
@Property(int, notify=hoveredIndexChanged)
def hoveredIndex(self) -> int:
return self._hovered_index
@hoveredIndex.setter # type: ignore[no-redef]
def hoveredIndex(self, val: int) -> None:
val = int(val)
if val == self._hovered_index:
return
self._hovered_index = val
self.hoveredIndexChanged.emit()
self.update()
@Property(float, notify=hoverScaleChanged)
def hoverScale(self) -> float:
return self._hover_scale
@hoverScale.setter # type: ignore[no-redef]
def hoverScale(self, val: float) -> None:
self._hover_scale = float(val)
self.hoverScaleChanged.emit()
self.update()
# Colour properties — QML can bind these to Theme tokens # Colour properties — QML can bind these to Theme tokens
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def ringColor(self) -> QColor: return self._ring_color def ringColor(self) -> QColor: return self._ring_color
@ringColor.setter @ringColor.setter # type: ignore[no-redef]
def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update() def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def axisColor(self) -> QColor: return self._axis_color def axisColor(self) -> QColor: return self._axis_color
@axisColor.setter @axisColor.setter # type: ignore[no-redef]
def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update() def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def lowColor(self) -> QColor: return self._low_color def lowColor(self) -> QColor: return self._low_color
@lowColor.setter @lowColor.setter # type: ignore[no-redef]
def lowColor(self, c: QColor) -> None: self._low_color = c; self.update() def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def inRangeColor(self) -> QColor: return self._in_range_color def inRangeColor(self) -> QColor: return self._in_range_color
@inRangeColor.setter @inRangeColor.setter # type: ignore[no-redef]
def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update() def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def highColor(self) -> QColor: return self._high_color def highColor(self) -> QColor: return self._high_color
@highColor.setter @highColor.setter # type: ignore[no-redef]
def highColor(self, c: QColor) -> None: self._high_color = c; self.update() def highColor(self, c: QColor) -> None: self._high_color = c; self.update()
@Property(QColor, notify=colorsChanged) @Property(QColor, notify=colorsChanged)
def textColor(self) -> QColor: return self._text_color def textColor(self) -> QColor: return self._text_color
@textColor.setter @textColor.setter # type: ignore[no-redef]
def textColor(self, c: QColor) -> None: self._text_color = c; self.update() def textColor(self, c: QColor) -> None: self._text_color = c; self.update()
# ── slots ───────────────────────────────────────────────────────────── # ── slots ─────────────────────────────────────────────────────────────
@@ -255,70 +378,125 @@ class WaferMapItem(QQuickPaintedItem):
return idx return idx
return -1 return -1
# TODO P6.1: build batch export on top of this single-frame export
# THINKING: export_image() already does the hard part (grabToImage → PNG);
# batch export is just a loop over file_browser.files that loads each CSV
# through the existing wafer-map pipeline and calls this per file into a
# chosen output dir, plus an optional summary CSV of (file, min/max/mean).
# No new rendering code needed — see docs/pending/alpha-release-polish-plan.md §6.1.
@Slot(str, result=bool) @Slot(str, result=bool)
def export_image(self, file_path: str) -> bool: @Slot(str, str, result=bool)
def export_image(self, file_path: str, extra: str = "") -> bool:
"""Export the current wafer map rendering to a PNG file """Export the current wafer map rendering to a PNG file
Args: Args:
file_pat: Absolute path for the output PNG. file_path: Absolute path for the output PNG.
extra: Optional second footer line (live stream metrics).
Returns: Returns:
True on success, False on failure True on success, False on failure
""" """
if not file_path: if not file_path or not self._values:
# No file loaded / no stream played yet — only the empty template
# would render, which isn't a meaningful export.
return False return False
try: try:
result = self.grabToImage() # Render synchronously via paint() into our own image —
img = result.image() # grabToImage() resolves on a later render frame, so its result
img.save(file_path, "PNG") # is null when read immediately (the old silent failure).
return True w, h = int(self.width()), int(self.height())
if w <= 0 or h <= 0:
return False
lines = [ln for ln in (readout_text(self._values), extra) if ln]
footer_h = 28 * len(lines)
img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
img.fill(0) # transparent background
painter = QPainter(img)
self.paint(painter)
if lines:
painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
painter.setPen(QColor("#CBD5E1"))
for i, line in enumerate(lines):
painter.drawText(QRectF(0, h + 28 * i, w, 28),
Qt.AlignmentFlag.AlignCenter, line)
painter.end()
return bool(img.save(file_path))
except Exception as e: except Exception as e:
log.error("Export failed: %s", e) log.error("Export failed: %s", e)
return False return False
# ── internal ───────────────────────────────────────────────────────── # ── internal ─────────────────────────────────────────────────────────
def _paint_extremes(self, painter: QPainter) -> None:
"""Ring the hottest (high color) and coldest (low color) markers."""
if not self._show_extremes or not self._values or not self._markers:
return
s = compute_stats(self._values)
ring_r = self._marker_r + 5
painter.setBrush(Qt.BrushStyle.NoBrush)
for idx, color in ((s.max_index, self._high_color),
(s.min_index, self._low_color)):
if idx not in self._markers:
continue
px, py = self._markers[idx]
painter.setPen(QPen(color, 2))
painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)
def _rebuild_thickness(self) -> None: def _rebuild_thickness(self) -> None:
"""Interpolate thickness data into a gray/orange heatmap QImage.""" """Interpolate thickness points into a gray/orange heatmap QImage."""
if not self._sensors or not self._thickness_data: if not self._thickness_data:
self._thickness_heatmap = None self._thickness_heatmap = None
return return
ds = self._draw_size() ds = self._draw_size()
r_mm = self._wafer_radius_mm() r_mm = self._wafer_radius_mm()
xs = np.array([s.x for s in self._sensors]) pts = np.array(self._thickness_data, dtype=float)
ys = np.array([s.y for s in self._sensors]) xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
vs = np.array(self._thickness_data[:len(self._sensors)], dtype=float)
if len(vs) < len(self._sensors):
self._thickness_heatmap = None
return
try: try:
# No round_clip — see _rebuild_heatmap for why the mask is analytic.
field = interpolate_field( field = interpolate_field(
xs, ys, vs, xs, ys, vs,
width=ds, height=ds, width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm), extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
) )
except Exception: except Exception:
self._thickness_heatmap = None self._thickness_heatmap = None
return return
# Gray/orange colormap: map field range 0→1 to gray→orange field = refine_field(field)
vmin, vmax = np.nanmin(field), np.nanmax(field) # ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
alpha = np.ones(field.shape)
# Color-range bounds from inside the wafer only — the unclipped field
# extrapolates wildly toward the square corners, which alpha hides but
# a corner-driven vmin/vmax would flatten the visible gradient.
inside = field[alpha >= 0.5]
vmin, vmax = inside.min(), inside.max()
span = vmax - vmin or 1.0 span = vmax - vmin or 1.0
# Gray/orange colormap: map field range 0→1 to gray→orange
t = np.clip((field - vmin) / span, 0.0, 1.0) t = np.clip((field - vmin) / span, 0.0, 1.0)
gh, gw = field.shape
# Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0) # Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
rgb = np.zeros((ds, ds, 3), dtype=np.float32) rgb = np.zeros((gh, gw, 3), dtype=np.float32)
rgb[:, :, 0] = 0.5 + 0.5 * t rgb[:, :, 0] = 0.5 + 0.5 * t
rgb[:, :, 1] = 0.5 + 0.15 * t rgb[:, :, 1] = 0.5 + 0.15 * t
rgb[:, :, 2] = 0.5 - 0.5 * t rgb[:, :, 2] = 0.5 - 0.5 * t
rgb = np.nan_to_num(rgb, nan=0.0) rgb = np.nan_to_num(rgb, nan=0.0)
rgba = np.zeros((ds, ds, 4), dtype=np.uint8) rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
rgba[:, :, :3] = (rgb * 255).astype(np.uint8) rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
rgba[:, :, 3] = np.where(np.isfinite(field), 180, 0).astype(np.uint8) rgba[:, :, 3] = (alpha * 180).astype(np.uint8)
self._thickness_heatmap = ( grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy() self._thickness_heatmap = grid_img.scaled(
ds, ds,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
) )
def _on_resize(self) -> None: def _on_resize(self) -> None:
@@ -342,6 +520,13 @@ class WaferMapItem(QQuickPaintedItem):
r = max(math.hypot(s.x, s.y) for s in self._sensors) r = max(math.hypot(s.x, s.y) for s in self._sensors)
return r * 1.05 return r * 1.05
# TODO P6.2: reuse this for the edge-to-center delta metric
# THINKING: radial_metrics.py (new) needs the same "which sensors are near
# the edge vs. near the center" bucketing this function already computes
# for ring-line drawing — do NOT re-derive ring boundaries separately, this
# already handles round vs. square wafer shapes correctly (see family_spec.py
# square-wafer work). Outermost group ≈ edge sensors, innermost ≈ center.
# See docs/pending/alpha-release-polish-plan.md §6.2.
def _sensor_ring_radii_mm(self) -> list[float]: def _sensor_ring_radii_mm(self) -> list[float]:
"""Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary.""" """Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
if not self._sensors: if not self._sensors:
@@ -391,19 +576,34 @@ class WaferMapItem(QQuickPaintedItem):
self._heatmap = None self._heatmap = None
return return
try: try:
# No round_clip: refine the smooth unclipped field, then mask with an
# analytic anti-aliased ellipse (a coarse-grid clip mask upscales jagged).
field = interpolate_field( field = interpolate_field(
xs, ys, vs, xs, ys, vs,
width=ds, height=ds, width=ds, height=ds,
extent=(-r_mm, r_mm, -r_mm, r_mm), extent=(-r_mm, r_mm, -r_mm, r_mm),
round_clip=(self._shape == "round"),
) )
except Exception: except Exception:
self._heatmap = None self._heatmap = None
return return
self._heatmap = self._field_to_qimage(field, ds) field = refine_field(field)
# ponytail: RBF overshoots around outlier sensors, inventing values no
# sensor produced (e.g. negative diffs when all diffs are positive).
# Clamp to the real data range so color only reflects measured values.
field = np.clip(field, vs.min(), vs.max())
if self._shape == "round":
alpha = ellipse_alpha(*field.shape)
else:
alpha = np.ones(field.shape)
grid_img = self._field_to_qimage(field, alpha)
self._heatmap = grid_img.scaled(
ds, ds,
Qt.AspectRatioMode.IgnoreAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
def _field_to_qimage(self, field: np.ndarray, ds: int) -> QImage: def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
"""Apply a band-aware tri-color gradient → RGBA QImage.""" """Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
lo_b = self._target - self._margin lo_b = self._target - self._margin
hi_b = self._target + self._margin hi_b = self._target + self._margin
span = hi_b - lo_b or 1.0 span = hi_b - lo_b or 1.0
@@ -428,19 +628,28 @@ class WaferMapItem(QQuickPaintedItem):
lo_c * (1 - t_lo) + mid_c * t_lo, lo_c * (1 - t_lo) + mid_c * t_lo,
mid_c * (1 - t_hi) + hi_c * t_hi, mid_c * (1 - t_hi) + hi_c * t_hi,
) )
# Outside the wafer circle `field` is NaN → NaN propagates into rgb. Alpha
# masks those pixels anyway, but zero them so the uint8 cast is well-defined.
rgb = np.nan_to_num(rgb, nan=0.0) rgb = np.nan_to_num(rgb, nan=0.0)
rgba = np.zeros((ds, ds, 4), dtype=np.uint8) gh, gw = field.shape
rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
rgba[:, :, :3] = (rgb * 255).astype(np.uint8) rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
rgba[:, :, 3] = np.where(np.isfinite(field), 210, 0).astype(np.uint8) # alpha is a continuous 0..1 coverage mask (not a hard isfinite cutoff) so the
# round-clip boundary anti-aliases instead of showing coarse-grid steps.
rgba[:, :, 3] = (alpha * 210).astype(np.uint8)
return QImage(rgba.tobytes(), ds, ds, QImage.Format.Format_RGBA8888).copy() return QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
# ── paint ───────────────────────────────────────────────────────────── # ── paint ─────────────────────────────────────────────────────────────
def paint(self, painter: QPainter) -> None: def paint(self, painter: QPainter) -> None:
ds = self._draw_size() ds = self._draw_size()
if ds != self._last_draw_size:
# Geometry settled (or changed) since markers/heatmap were last built
# against a stale size — recompute now, at actual paint time.
self._last_draw_size = ds
self._compute_markers()
self._rebuild_heatmap()
self._rebuild_thickness()
r_px = int(ds / 2 - 4) r_px = int(ds / 2 - 4)
cx, cy = self._center() cx, cy = self._center()
@@ -461,6 +670,7 @@ class WaferMapItem(QQuickPaintedItem):
painter.setOpacity(1.0) painter.setOpacity(1.0)
self._paint_markers(painter) self._paint_markers(painter)
self._paint_extremes(painter)
def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None: def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
ds = self._draw_size() ds = self._draw_size()
@@ -524,11 +734,11 @@ class WaferMapItem(QQuickPaintedItem):
font_scale = 0.85 font_scale = 0.85
id_font = QFont() id_font = QFont()
id_font.setPointSize(max(4, int(r * font_scale))) id_font.setPointSize(max(9, int(r * font_scale)))
id_font.setBold(True) id_font.setBold(True)
temp_font = QFont() temp_font = QFont()
temp_font.setPointSize(max(3, int((r - 1) * font_scale))) temp_font.setPointSize(max(9, int(r * font_scale)))
band_color = { band_color = {
"in_range": self._in_range_color, "in_range": self._in_range_color,
@@ -536,28 +746,6 @@ class WaferMapItem(QQuickPaintedItem):
"low": self._low_color, "low": self._low_color,
} }
for i, s in enumerate(self._sensors):
if i not in self._markers:
continue
px, py = self._markers[i]
color = band_color.get(
self._bands[i] if i < len(self._bands) else "in_range",
self._in_range_color,
)
# Filled circle with thin dark outline for contrast over heatmap
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
painter.setBrush(QBrush(color))
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
if self._show_labels:
has_temp = i < len(self._values)
# Fetch text alignment side and offsets
side = getattr(s, "side", "right").lower()
ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 0.0) * r
# Pre-compute metrics using current scaled fonts
painter.setFont(id_font) painter.setFont(id_font)
id_fm = painter.fontMetrics() id_fm = painter.fontMetrics()
id_line_h = id_fm.height() id_line_h = id_fm.height()
@@ -568,6 +756,39 @@ class WaferMapItem(QQuickPaintedItem):
temp_line_h = temp_fm.height() temp_line_h = temp_fm.height()
temp_ascent = temp_fm.ascent() temp_ascent = temp_fm.ascent()
for i, s in enumerate(self._sensors):
if i not in self._markers:
continue
px, py = self._markers[i]
color = band_color.get(
self._bands[i] if i < len(self._bands) else "in_range",
self._in_range_color,
)
is_hovered = i == self._hovered_index
marker_r = int(round(r * self._hover_scale)) if is_hovered else r
if is_hovered:
# Soft glow ring behind the marker, growing with hoverScale.
glow_r = int(round(marker_r * 1.9))
glow_color = QColor(color)
glow_color.setAlpha(70)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QBrush(glow_color))
painter.drawEllipse(px - glow_r, py - glow_r, 2 * glow_r, 2 * glow_r)
# Filled circle with thin dark outline for contrast over heatmap
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
painter.setBrush(QBrush(color))
painter.drawEllipse(px - marker_r, py - marker_r, 2 * marker_r, 2 * marker_r)
if self._show_labels:
has_temp = i < len(self._values)
# Fetch text alignment side and offsets
side = getattr(s, "side", "right").lower()
ox = getattr(s, "offset_x", 0.0) * r
oy = getattr(s, "offset_y", 0.0) * r
id_text = s.label id_text = s.label
temp_text = f"{self._values[i]:.2f}" if has_temp else "" temp_text = f"{self._values[i]:.2f}" if has_temp else ""
@@ -598,11 +819,11 @@ class WaferMapItem(QQuickPaintedItem):
painter.setPen(QPen(self._text_color)) painter.setPen(QPen(self._text_color))
y1 = ly + id_ascent y1 = ly + id_ascent
if side in ("top", "bottom"): if side in ("top", "bottom"):
painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) painter.drawText(lx + (text_w - id_w) // 2, y1, id_text) # type: ignore[arg-type]
elif side == "left": elif side == "left":
painter.drawText(lx + (text_w - id_w), y1, id_text) painter.drawText(lx + (text_w - id_w), y1, id_text) # type: ignore[arg-type]
else: else:
painter.drawText(lx, y1, id_text) painter.drawText(lx, y1, id_text) # type: ignore[arg-type]
# Draw Temperature (second line) # Draw Temperature (second line)
if has_temp: if has_temp:
@@ -610,8 +831,8 @@ class WaferMapItem(QQuickPaintedItem):
painter.setPen(QPen(color)) painter.setPen(QPen(color))
y2 = ly + id_line_h + temp_ascent y2 = ly + id_line_h + temp_ascent
if side in ("top", "bottom"): if side in ("top", "bottom"):
painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text) # type: ignore[arg-type]
elif side == "left": elif side == "left":
painter.drawText(lx + (text_w - temp_w), y2, temp_text) painter.drawText(lx + (text_w - temp_w), y2, temp_text) # type: ignore[arg-type]
else: else:
painter.drawText(lx, y2, temp_text) painter.drawText(lx, y2, temp_text) # type: ignore[arg-type]
-12
View File
@@ -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",
]
+31 -1
View File
@@ -66,9 +66,39 @@ def _family_name(raw_name: str) -> str:
def _load_yaml(path: Path) -> dict: def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as f: with path.open(encoding="utf-8") as f:
return yaml.safe_load(f) loaded: dict = yaml.safe_load(f)
return loaded
# Edge→center sensor pair tables, ported verbatim from the C# original
# (Form1.cs ec_map_*). Indices are 0-origin. Domain data, not derivable from
# ring geometry — see docs/adr/0002-edge-center-pair-tables.md.
_EC_MAPS: dict[str, tuple[tuple[int, int], ...]] = {
"AEP": tuple((e, 40 + e // 2) for e in range(16)),
"BCD": tuple((e, 28) for e in range(12)),
"F": ((0, 18), (1, 19), (2, 19), (3, 19), (4, 20), (5, 20),
(6, 20), (7, 21), (8, 21), (9, 21), (10, 18), (11, 18)),
"X": ((5, 76), (0, 76), (39, 76), (6, 77), (11, 77), (16, 77),
(17, 78), (22, 78), (27, 78), (28, 79), (33, 79), (38, 79)),
"Z": tuple((e, 0) for e in range(49, 65)),
}
def ec_pairs_for_wafer_id(wafer_id: str) -> tuple[tuple[int, int], ...]:
"""Edge-center pairs for a wafer id's family letter; empty if unknown.
Unknown families deliberately get no pairs (C# fell through to Z).
"""
prefix = wafer_id[0].upper() if wafer_id else ""
for families, key in (("AEP", "AEP"), ("BCD", "BCD"), ("F", "F"),
("X", "X"), ("Z", "Z")):
if prefix and prefix in families:
return _EC_MAPS[key]
return ()
# P6.3 (LayoutSelector) dropped 2026-07-10 — the C# "layout" buttons only
# exported coordinate spreadsheets, decided obsolete. See MIGRATION.md.
def available_families() -> list[str]: def available_families() -> list[str]:
return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")] return [_family_name(_load_yaml(p)["name"]) for p in _LAYOUTS_DIR.glob("*.yaml")]
+1 -1
View File
@@ -115,7 +115,7 @@ class ZWaferParser:
y_coords: Optional[list], y_coords: Optional[list],
) -> None: ) -> None:
"""Build sensor list from layout arrays.""" """Build sensor list from layout arrays."""
if not all([labels, x_coords, y_coords]): if not (labels and x_coords and y_coords):
raise ValueError("Sensor layout section is incomplete or missing.") raise ValueError("Sensor layout section is incomplete or missing.")
if len(labels) != len(x_coords) or len(labels) != len(y_coords): if len(labels) != len(x_coords) or len(labels) != len(y_coords):
+70 -1
View File
@@ -10,6 +10,7 @@ Mirrors the C# Form1.cs binary parsing pipeline:
import logging import logging
import os import os
from datetime import datetime
from typing import Optional from typing import Optional
from pygui.backend.wafer.family_spec import sensor_count_for from pygui.backend.wafer.family_spec import sensor_count_for
@@ -106,6 +107,23 @@ def _convert_aep(binary_bits: list[int]) -> float:
return result return result
def _convert_debug_temp(hex_str: str) -> float:
"""Convert a 4-char hex string to a debug/cold-junction temperature.
Mirrors C#'s ConvertBinaryArrayToDebugTemp: bits 4-14 scaled by
2**(i-8), sign bit at bit 0. No family-code branching this formula
applies regardless of wafer family, unlike _convert_hex_to_temp.
"""
bits = _hex_to_binary(hex_str)
value = 0.0
for i in range(4, 15):
if bits[i]:
value += 2.0 ** (i - 8)
if bits[0]:
value = -value
return round(value, 2)
def _convert_hex_to_temp(hex_str: str, family_code: str) -> float: def _convert_hex_to_temp(hex_str: str, family_code: str) -> float:
"""Convert a singi hale 4-char hex string to a float temperature.""" """Convert a singi hale 4-char hex string to a float temperature."""
bits = _hex_to_binary(hex_str) bits = _hex_to_binary(hex_str)
@@ -224,6 +242,15 @@ def convert_to_temperatures(
return temp_value return temp_value
def convert_to_debug_temperatures(hex_data: list[list[str]]) -> list[list[str]]:
"""Convert debug/cold-junction hex values to temperature strings.
Same shape contract as convert_to_temperatures, but uses the
family-independent debug formula (_convert_debug_temp).
"""
return [[str(_convert_debug_temp(h)) for h in row] for row in hex_data]
def remove_trailing_zeros(data: list[list[str]]) -> None: def remove_trailing_zeros(data: list[list[str]]) -> None:
"""Remove rows of all-zero values from the end of data (in-place). """Remove rows of all-zero values from the end of data (in-place).
@@ -266,7 +293,6 @@ def save_to_csv(
try: try:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
# Build filename: P00001-20260505_133045.csv # Build filename: P00001-20260505_133045.csv
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{serial_number}-{timestamp}.csv" filename = f"{serial_number}-{timestamp}.csv"
@@ -294,3 +320,46 @@ def save_to_csv(
except Exception as exc: except Exception as exc:
log.error("CSV save failed: %s", exc) log.error("CSV save failed: %s", exc)
return None return None
def save_debug_csv(
temp_data: list[list[str]],
debug_data: list[list[str]],
output_dir: str,
) -> Optional[str]:
"""Save sensor + debug temperatures side-by-side to debug_info.csv.
Mirrors C#'s writeDebugCSV: alternating Sensor{j+1},Debug{j+1} columns,
truncated to the shorter of the two row/column counts. Fixed filename
(not timestamped), unlike save_to_csv.
Args:
temp_data: 2D array of sensor temperature strings (D1, decoded via
convert_to_temperatures).
debug_data: 2D array of debug temperature strings (F1, decoded via
convert_to_debug_temperatures).
output_dir: Directory to save the CSV file.
Returns:
Full file path on success, None on empty input or write failure.
"""
if not temp_data or not debug_data:
log.error("Debug CSV save failed: temp or debug data is empty")
return None
try:
os.makedirs(output_dir, exist_ok=True)
num_cols = min(len(temp_data[0]), len(debug_data[0]))
num_rows = min(len(temp_data), len(debug_data))
filepath = os.path.join(output_dir, "debug_info.csv")
with open(filepath, "w", encoding="utf-8") as f:
headers = [f"Sensor{j + 1},Debug{j + 1}" for j in range(num_cols)]
f.write(",".join(headers) + "\n")
for i in range(num_rows):
row = [f"{temp_data[i][j]},{debug_data[i][j]}" for j in range(num_cols)]
f.write(",".join(row) + "\n")
log.info("Saved debug CSV: %d rows × %d cols to %s", num_rows, num_cols, filepath)
return filepath
except Exception as exc:
log.error("Debug CSV save failed: %s", exc)
return None
+4
View File
@@ -65,6 +65,10 @@ class SerialPort:
) )
if timeout is not None: if timeout is not None:
kwargs["timeout"] = timeout kwargs["timeout"] = timeout
# Write timeout must never be None (= block forever): on macOS,
# pseudo-ports like Bluetooth-Incoming-Port accept the open and then
# hang the write, which froze detect in "Detecting..." permanently.
kwargs["write_timeout"] = timeout if timeout is not None else 2.0
try: try:
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs) return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
+5 -5
View File
@@ -45,7 +45,7 @@ class StreamReader:
Reads up to 256 bytes in one shot. If nothing is immediately Reads up to 256 bytes in one shot. If nothing is immediately
available, yields briefly to avoid a busy spin. available, yields briefly to avoid a busy spin.
""" """
chunk = self._transport.read(max(min_bytes, 256)) chunk: bytes = self._transport.read(max(min_bytes, 256))
if not chunk: if not chunk:
time.sleep(0.005) time.sleep(0.005)
return chunk return chunk
@@ -106,7 +106,7 @@ class StreamReader:
except Exception: except Exception:
pass pass
def _run_binary(self, initial_bytes: bytes) -> None: def _run_binary(self, initial_bytes: bytes | bytearray) -> None:
log.info("StreamReader: starting binary stream parsing") log.info("StreamReader: starting binary stream parsing")
buf = bytearray(initial_bytes) buf = bytearray(initial_bytes)
resync_attempts = 0 resync_attempts = 0
@@ -149,7 +149,7 @@ class StreamReader:
payload = buf[7:7 + payload_len] payload = buf[7:7 + payload_len]
try: try:
frame = self._parse(payload, seq) frame = self._parse(payload, seq) # type: ignore[arg-type]
self._on_frame(frame) self._on_frame(frame)
except Exception as exc: except Exception as exc:
self.error_count += 1 self.error_count += 1
@@ -238,7 +238,7 @@ class StreamReader:
for hex_val in valid_hex_words: for hex_val in valid_hex_words:
try: try:
swapped = hex_val[2:4] + hex_val[0:2] swapped = hex_val[2:4] + hex_val[0:2]
t = _convert_hex_to_temp(swapped, self._family_code) t = _convert_hex_to_temp(swapped, self._family_code) # type: ignore[arg-type]
values.append(t) values.append(t)
except Exception: except Exception:
values.append(0.0) values.append(0.0)
@@ -262,7 +262,7 @@ class StreamReader:
line = line_bytes.decode('utf-8', errors='ignore').strip() line = line_bytes.decode('utf-8', errors='ignore').strip()
if line: if line:
try: try:
frame = self._parse(line, seq) frame = self._parse(line, seq) # type: ignore[arg-type]
if frame: if frame:
self._on_frame(frame) self._on_frame(frame)
seq += 1 seq += 1
+11
View File
@@ -0,0 +1,11 @@
Wafer ID=A12
Acquisition Date=03/26/2025
Label,1,2,3
X (mm),0,10,-10
Y (mm),10,-10,-10
data
0.0,150.0,149.5,151.6
0.5,150.2,149.4,151.7
1.0,150.1,149.6,151.5
1.5,150.3,149.7,151.4
2.0,150.4,149.8,151.3
1 Wafer ID=A12
2 Acquisition Date=03/26/2025
3 Label,1,2,3
4 X (mm),0,10,-10
5 Y (mm),10,-10,-10
6 data
7 0.0,150.0,149.5,151.6
8 0.5,150.2,149.4,151.7
9 1.0,150.1,149.6,151.5
10 1.5,150.3,149.7,151.4
11 2.0,150.4,149.8,151.3
+52
View File
@@ -0,0 +1,52 @@
import pytest
from pygui.backend.visualization.chart_geometry import (
elapsed_to_pixel,
index_to_pixel,
value_to_pixel,
)
def test_value_to_pixel_min_maps_to_bottom():
assert value_to_pixel(0.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(210.0)
def test_value_to_pixel_max_maps_to_top():
assert value_to_pixel(100.0, 0.0, 100.0, pixel_start=10.0, pixel_span=200.0) == pytest.approx(10.0)
def test_value_to_pixel_midpoint():
assert value_to_pixel(50.0, 0.0, 100.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_value_to_pixel_degenerate_range_centers():
assert value_to_pixel(5.0, 5.0, 5.0, pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_index_to_pixel_first_and_last():
assert index_to_pixel(0, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(0.0)
assert index_to_pixel(4, 5, pixel_start=0.0, pixel_span=100.0) == pytest.approx(100.0)
def test_index_to_pixel_single_point_centers():
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
def test_elapsed_to_pixel_window_start_maps_to_left():
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
def test_elapsed_to_pixel_window_end_maps_to_right():
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
def test_elapsed_to_pixel_midpoint():
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_elapsed_to_pixel_degenerate_window_pins_right():
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
+1 -1
View File
@@ -1,6 +1,6 @@
"""Tests for src/pygui/backend/cluster_average.py.""" """Tests for src/pygui/backend/cluster_average.py."""
import math import math
import pytest
from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
from pygui.backend.wafer.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
-1
View File
@@ -1,6 +1,5 @@
"""Tests for src/pygui/backend/comparison.py.""" """Tests for src/pygui/backend/comparison.py."""
import numpy as np
from pygui.backend.comparison import compare_runs from pygui.backend.comparison import compare_runs
+67
View File
@@ -38,3 +38,70 @@ def test_recorded_data_rows_are_readable(tmp_path):
assert records[0].values == [149.0, 148.0] assert records[0].values == [149.0, 148.0]
assert records[1].time == 0.5 assert records[1].time == 0.5
assert records[1].values == [149.5, 148.5] assert records[1].values == [149.5, 148.5]
# ===== write_segment (P3.3) =====
def _make_zwafer_csv(tmp_path, rows=5):
sensors = [Sensor("1", 0.0, 1.0), Sensor("2", 1.0, 0.0)]
path = tmp_path / "src.csv"
rec = CsvRecorder()
rec.start(str(path), sensors, serial="A12")
for i in range(rows):
rec.write(Frame(seq=i, time=i * 0.5, values=[100.0 + i, 200.0 + i]))
rec.stop()
return path
def test_write_segment_zwafer_slice(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=5)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 3) is True
records = read_data_records(str(dest))
assert len(records) == 3
assert records[0].values == [101.0, 201.0]
assert records[-1].values == [103.0, 203.0]
# header survives → still parses as a wafer CSV
data, _ = ZWaferParser().parse(str(dest))
assert data is not None
assert [s.label for s in data.sensors] == ["1", "2"]
def test_write_segment_full_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=4)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 0, 3) is True
assert len(read_data_records(str(dest))) == 4
def test_write_segment_out_of_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 10, 20) is False
assert not dest.exists()
def test_write_segment_invalid_range(tmp_path):
src = _make_zwafer_csv(tmp_path, rows=3)
dest = tmp_path / "seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 2, 1) is False
assert CsvRecorder.write_segment(str(dest), str(src), -1, 1) is False
def test_write_segment_official_csv(tmp_path):
# official format: row 0 = sensor names, rows 1+ = values only
src = tmp_path / "A00001-x.csv"
src.write_text(
"Sensor1,Sensor2\n"
"10.0,20.0\n"
"11.0,21.0\n"
"12.0,22.0\n",
encoding="utf-8",
)
dest = tmp_path / "A00001-x_seg.csv"
assert CsvRecorder.write_segment(str(dest), str(src), 1, 2) is True
lines = dest.read_text(encoding="utf-8").splitlines()
assert lines[0] == "Sensor1,Sensor2"
assert lines[1] == "11.0,21.0"
assert lines[2] == "12.0,22.0"
assert len(lines) == 3
+101 -3
View File
@@ -1,13 +1,17 @@
"""Tests for serialcomm/data_parser.py binary parsing pipeline.""" """Tests for serialcomm/data_parser.py binary parsing pipeline."""
import pytest import pytest
from pygui.serialcomm.data_parser import ( from pygui.serialcomm.data_parser import (
_convert_debug_temp,
_convert_hex_to_temp,
convert_to_debug_temperatures,
convert_to_temperatures,
csv_column_count, csv_column_count,
parse_binary_data, parse_binary_data,
convert_to_temperatures,
remove_trailing_zeros, remove_trailing_zeros,
save_debug_csv,
save_to_csv, save_to_csv,
_convert_hex_to_temp,
) )
# ── csv_column_count ────────────────────────────────────────────────────────── # ── csv_column_count ──────────────────────────────────────────────────────────
@@ -271,7 +275,7 @@ class TestSaveToCsv:
result = save_to_csv(data, "P", "P00001", str(tmp_path)) result = save_to_csv(data, "P", "P00001", str(tmp_path))
assert result is not None assert result is not None
headers = open(result).readline().strip().split(",") headers = open(result).readline().strip().split(",")
assert len(headers) == 60, f"data width 60 should win over display 48" assert len(headers) == 60, "data width 60 should win over display 48"
assert headers[0] == "Sensor1" assert headers[0] == "Sensor1"
assert headers[-1] == "Sensor60" assert headers[-1] == "Sensor60"
@@ -320,3 +324,97 @@ class TestSaveToCsv:
assert len(headers) == 244, f"expected 244 cols, got {len(headers)}" assert len(headers) == 244, f"expected 244 cols, got {len(headers)}"
assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}" assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}"
assert data_row[243] == "243.0", "last column value must be preserved" assert data_row[243] == "243.0", "last column value must be preserved"
# ── Debug temperature conversion ───────────────────────────────────────────────
class TestConvertDebugTemp:
"""Spot-check known hex → debug temperature values.
Reference values derived from C#'s ConvertBinaryArrayToDebugTemp
(bits 4-14 scaled by 2**(i-8), sign at bit 0 no family branching).
"""
def test_zero_hex_gives_zero(self):
assert _convert_debug_temp("0000") == 0.0
def test_bit8_gives_one(self):
# bit index 8 set (value 0x0080) → contributes 2**(8-8) = 1.0
assert _convert_debug_temp("0080") == pytest.approx(1.0, abs=0.01)
def test_sign_bit_negates(self):
# sign bit (bit 0) + bit 8 → -1.0
assert _convert_debug_temp("8080") == pytest.approx(-1.0, abs=0.01)
def test_no_family_branching(self):
# Debug conversion takes no family code — same result regardless
assert _convert_debug_temp("0080") == _convert_debug_temp("0080")
class TestConvertToDebugTemperatures:
def test_returns_same_shape(self):
hex_data = [["0080", "0000"], ["8080", "0000"]]
result = convert_to_debug_temperatures(hex_data)
assert len(result) == 2
assert len(result[0]) == 2
def test_values_are_strings(self):
result = convert_to_debug_temperatures([["0080"]])
assert isinstance(result[0][0], str)
def test_converts_known_value(self):
result = convert_to_debug_temperatures([["0080"]])
assert float(result[0][0]) == pytest.approx(1.0, abs=0.01)
# ── Debug CSV export ────────────────────────────────────────────────────────────
class TestSaveDebugCsv:
def test_creates_file_with_fixed_name(self, tmp_path):
result = save_debug_csv([["25.0"]], [["1.0"]], str(tmp_path))
assert result is not None
assert result.endswith("debug_info.csv")
def test_header_alternates_sensor_and_debug(self, tmp_path):
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
assert result is not None
header = open(result).readline().strip()
assert header == "Sensor1,Debug1,Sensor2,Debug2"
def test_data_row_alternates_values(self, tmp_path):
result = save_debug_csv([["25.0", "24.5"]], [["1.0", "2.0"]], str(tmp_path))
assert result is not None
lines = open(result).readlines()
assert lines[1].strip() == "25.0,1.0,24.5,2.0"
def test_truncates_to_shorter_row_count(self, tmp_path):
temp_data = [["25.0"], ["24.0"], ["23.0"]]
debug_data = [["1.0"], ["2.0"]]
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
assert result is not None
lines = open(result).readlines()
assert len(lines) == 3 # 1 header + 2 data rows (shorter of the two)
def test_truncates_to_shorter_column_count(self, tmp_path):
temp_data = [["25.0", "24.0", "23.0"]]
debug_data = [["1.0", "2.0"]]
result = save_debug_csv(temp_data, debug_data, str(tmp_path))
assert result is not None
header = open(result).readline().strip()
assert header == "Sensor1,Debug1,Sensor2,Debug2"
def test_returns_none_on_empty_temp_data(self, tmp_path):
assert save_debug_csv([], [["1.0"]], str(tmp_path)) is None
def test_returns_none_on_empty_debug_data(self, tmp_path):
assert save_debug_csv([["25.0"]], [], str(tmp_path)) is None
def test_creates_output_dir_if_missing(self, tmp_path):
nested = str(tmp_path / "a" / "b")
result = save_debug_csv([["25.0"]], [["1.0"]], nested)
assert result is not None
from pathlib import Path
assert Path(result).exists()
+75 -5
View File
@@ -1,10 +1,13 @@
import pytest
import logging import logging
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from pygui.backend.controllers.device_controller import DeviceController from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.data.local_settings import LocalSettings from pygui.backend.data.local_settings import LocalSettings
from pygui.serialcomm.serial_port import WaferInfo from pygui.serialcomm.serial_port import WaferInfo
@pytest.fixture @pytest.fixture
def mock_settings(): def mock_settings():
s = LocalSettings() s = LocalSettings()
@@ -16,8 +19,8 @@ def mock_settings():
@pytest.fixture @pytest.fixture
def controller(mock_settings): def controller(mock_settings):
import tempfile
import shutil import shutil
import tempfile
temp_dir = tempfile.mkdtemp() temp_dir = tempfile.mkdtemp()
yield DeviceController(mock_settings, temp_dir) yield DeviceController(mock_settings, temp_dir)
shutil.rmtree(temp_dir) shutil.rmtree(temp_dir)
@@ -130,6 +133,64 @@ def test_read_debug_handler(controller):
assert controller.connectionStatus == "Connected" assert controller.connectionStatus == "Connected"
assert not controller.operationInProgress assert not controller.operationInProgress
def _make_p_family_bytes(value: int = 0x0100) -> bytes:
"""Synthetic single-block P-family binary: 244 valid words + 12 overhead."""
data = bytearray()
for i in range(256):
word = value if i < 244 else 0
data += word.to_bytes(2, byteorder="little")
return bytes(data)
def test_debug_worker_decodes_and_saves_csv(controller, tmp_path):
controller._last_wafer_info = {"familyCode": "P"}
controller._save_data_dir = str(tmp_path)
controller._service.read_wafer_data = MagicMock(
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
)
captured = {}
controller._debugFinished.connect(captured.update)
controller._debug_worker("COM1")
assert captured.get("success") is True
assert captured.get("sensor_bytes") == 512
assert captured.get("debug_bytes") == 512
assert "csv_path" in captured
from pathlib import Path
assert Path(captured["csv_path"]).exists()
assert Path(captured["csv_path"]).name == "debug_info.csv"
def test_debug_worker_reports_error_when_csv_save_fails(controller, tmp_path):
controller._last_wafer_info = {"familyCode": "P"}
# Point save dir somewhere save_debug_csv cannot write to, so it returns None.
controller._save_data_dir = str(tmp_path / "debug_info.csv") # a file, not a dir
(tmp_path / "debug_info.csv").write_text("occupied")
controller._service.read_wafer_data = MagicMock(
side_effect=[_make_p_family_bytes(), _make_p_family_bytes()]
)
captured = {}
controller._debugFinished.connect(captured.update)
controller._debug_worker("COM1")
assert captured.get("success") is not True
assert "error" in captured
def test_debug_handler_logs_csv_path(controller):
controller._handle_debug_finished({
"success": True,
"sensor_bytes": 512,
"debug_bytes": 512,
"csv_path": "/tmp/debug_info.csv",
})
assert "/tmp/debug_info.csv" in controller.activityLog
def test_parse_and_save_data_and_get_chart_data(controller): def test_parse_and_save_data_and_get_chart_data(controller):
res = controller.getChartData() res = controller.getChartData()
assert res == {"success": False} assert res == {"success": False}
@@ -159,8 +220,8 @@ def test_logging_handler(controller):
assert any("Hello QML Activity Log" in line for line in controller._activity_log) assert any("Hello QML Activity Log" in line for line in controller._activity_log)
def test_fresh_session_on_startup(mock_settings): def test_fresh_session_on_startup(mock_settings):
import tempfile
import shutil import shutil
import tempfile
# Configure mock settings with some data to simulate previous session # Configure mock settings with some data to simulate previous session
mock_settings.activity_log = ["[12:00:00] Old message"] mock_settings.activity_log = ["[12:00:00] Old message"]
@@ -191,7 +252,7 @@ def test_detect_wafer_clears_old_session(controller):
# We mock detect_all_ports to not run long or fail # We mock detect_all_ports to not run long or fail
controller._service.detect_all_ports = MagicMock(return_value=(None, None)) controller._service.detect_all_ports = MagicMock(return_value=(None, None))
with patch("threading.Thread") as mock_thread: with patch("threading.Thread"):
controller.detectWafer() controller.detectWafer()
# Verify it cleared all session variables and logs immediately at start of detect # Verify it cleared all session variables and logs immediately at start of detect
@@ -227,6 +288,15 @@ def test_device_service_port_caching(controller):
# Clear cache and scan again should invoke subprocess # Clear cache and scan again should invoke subprocess
service.clear_port_scan_cache() service.clear_port_scan_cache()
ports3 = service.enumerate_ports() service.enumerate_ports()
assert mock_run.call_count == 2 assert mock_run.call_count == 2
def test_export_dir_created_under_save_data_dir(controller):
from pathlib import Path
result = controller.exportDir()
assert result == str(Path(controller.saveDataDir) / "Export")
assert Path(result).is_dir()
+8
View File
@@ -0,0 +1,8 @@
# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2,
# docs/pending/license-gating-plan.md). Two cases:
# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync()
# emits readResult {"error": ...} and spawns no thread.
# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService).
# THINKING: guard is a trust boundary (plan §2.1); these fail if someone
# reorders the busy/license checks or renames the "serialNumber" key.
# Depends on P1.2 + P2.1 being implemented.
+43
View File
@@ -0,0 +1,43 @@
from pathlib import Path
from pygui.backend.data.file_browser import FileBrowser
def _write_csv(path, wafer_id="P00001"):
path.write_text(
f"Wafer ID={wafer_id}\n"
"Acquisition Date=03/26/2025\n"
"Label,1,2,3\n"
"X (mm),0,10,-10\n"
"Y (mm),10,-10,-10\n"
"data\n"
"0.0,149.0,148.5,150.6\n"
)
def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
"""Files named live_<serial>_<timestamp>.csv (SessionController.startRecording)
must be distinguishable in the browser from read-memory dumps."""
_write_csv(tmp_path / "live_P00001_20260706_143022.csv")
_write_csv(tmp_path / "P00002-20260706_143500.csv")
browser = FileBrowser()
browser._set_current_directory(tmp_path)
browser.refreshFiles()
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path):
"""setCurrentDirectory (no native dialog) is how the Status tab's save-dir
picker keeps the Source panel pointed at the same folder."""
_write_csv(tmp_path / "P00003-20260706_143500.csv")
browser = FileBrowser()
browser.setCurrentDirectory(str(tmp_path))
assert browser.currentDirectory == str(tmp_path)
assert len(browser.files) == 1
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
+23 -1
View File
@@ -1,5 +1,6 @@
from pygui.backend.models.frame import Frame from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import FramePlayer from pygui.backend.models.frame_player import FramePlayer, all_sensor_series
def frames(n): def frames(n):
return [Frame(seq=i, time=float(i), values=[149.0 + i])for i in range(n)] return [Frame(seq=i, time=float(i), values=[149.0 + i])for i in range(n)]
@@ -28,3 +29,24 @@ def test_at_end():
assert not p.at_end assert not p.at_end
p.seek(1) p.seek(1)
assert p.at_end assert p.at_end
def test_frames_property_exposes_loaded_frames():
p = FramePlayer(); p.load(frames(3))
assert p.frames == tuple(frames(3))
# ---- all_sensor_series (Graph tab whole-run assembly) ----
def test_all_sensor_series_empty_frames():
assert all_sensor_series([]) == ([], [])
def test_all_sensor_series_names_and_columns():
fs = [
Frame(seq=0, time=0.0, values=[150.0, 148.0]),
Frame(seq=1, time=1.0, values=[151.0, 147.5]),
Frame(seq=2, time=2.0, values=[152.0, 147.0]),
]
names, series = all_sensor_series(fs)
assert names == ["Sensor 1", "Sensor 2"]
assert series == [[150.0, 151.0, 152.0], [148.0, 147.5, 147.0]]
+54 -1
View File
@@ -1,6 +1,13 @@
import math import math
import pytest import pytest
from pygui.backend.models.frame_stats import compute_stats, Stats
from pygui.backend.models.frame_stats import (
EdgeCenterStats,
Stats,
compute_edge_center,
compute_stats,
)
def test_basic_stat(): def test_basic_stat():
@@ -20,3 +27,49 @@ def test_empty_values_returns_zeros():
def test_ignores_nan(): def test_ignores_nan():
s = compute_stats([149.0, float("nan"), 151.0]) s = compute_stats([149.0, float("nan"), 151.0])
assert s.min == 149.0 and s.min_index == 0
assert s.max == 151.0 and s.max_index == 2
assert s.diff == pytest.approx(2.0)
assert s.avg == pytest.approx(150.0)
assert s.sigma == pytest.approx(1.0)
assert s.three_sigma == pytest.approx(3.0)
# ---- edge-center delta (ADR-0002) ----
PAIRS = ((0, 4), (1, 4), (2, 5), (3, 5))
def test_edge_center_min_max_pairs():
# e0 e1 e2 e3 c4 c5
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded=set())
# deltas: |150-149|=1.0, |148-149|=1.0, |151-149.5|=1.5, |149.4-149.5|=0.1
assert ec == EdgeCenterStats(
min_edge_index=3, min_center_index=5, min_edge=149.4, min_center=149.5,
min_delta=pytest.approx(0.1),
max_edge_index=2, max_center_index=5, max_edge=151.0, max_center=149.5,
max_delta=pytest.approx(1.5),
)
def test_edge_center_skips_excluded_sensors():
values = [150.0, 148.0, 151.0, 149.4, 149.0, 149.5]
ec = compute_edge_center(values, PAIRS, excluded={2, 3}) # both c5 pairs out
assert ec is not None
assert ec.max_delta == pytest.approx(1.0)
# excluding a center sensor kills all its pairs
assert compute_edge_center(values, PAIRS, excluded={4, 5}) is None
def test_edge_center_skips_nan_and_out_of_range():
values = [150.0, math.nan, 149.0] # only pair (0, 2) is computable
ec = compute_edge_center(values, ((0, 2), (1, 2), (0, 9)), excluded=set())
assert ec is not None
assert ec.min_delta == ec.max_delta == pytest.approx(1.0)
assert (ec.min_edge_index, ec.min_center_index) == (0, 2)
def test_edge_center_none_when_no_pairs():
assert compute_edge_center([1.0, 2.0], (), excluded=set()) is None
assert compute_edge_center([], PAIRS, excluded=set()) is None
+289
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More