Compare commits

..

5 Commits

Author SHA1 Message Date
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
59 changed files with 1653 additions and 1508 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/
+18 -4
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,19 +64,21 @@ 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
The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests. The project uses `uv` for dependency management and tooling, `Ruff` for linting and formatting, `Mypy` for static type checking, and `pytest` for unit tests.
A `Makefile` is provided to simplify common development and verification tasks: A `Makefile` is provided to simplify common development and verification tasks:
| Command | Description | | Command | Description |
|---------|-------------| | --------- | ------------- |
| `make install` | Sync dependencies and set up the local `.venv` using `uv` | | `make install` | Sync dependencies and set up the local `.venv` using `uv` |
| `make run` | Launch the ISenseCloud application | | `make run` | Launch the ISenseCloud application |
| `make test` | Run the `pytest` test suite | | `make test` | Run the `pytest` test suite |
@@ -83,6 +90,7 @@ A `Makefile` is provided to simplify common development and verification tasks:
### Ruff Configuration ### Ruff Configuration
Ruff is configured in `pyproject.toml` to enforce: Ruff is configured in `pyproject.toml` to enforce:
- **E/W**: Pycodestyle errors and warnings - **E/W**: Pycodestyle errors and warnings
- **F**: Pyflakes linter rules - **F**: Pyflakes linter rules
- **I**: Import sorting (isort parity) - **I**: Import sorting (isort parity)
@@ -174,11 +182,13 @@ Window {
- If the app does not start, verify the virtual environment is active and dependencies are installed: - If the app does not start, verify the virtual environment is active and dependencies are installed:
*Using `uv`:* *Using `uv`:*
```bash ```bash
uv sync uv sync
``` ```
*Using `pip`:* *Using `pip`:*
```bash ```bash
source .venv/bin/activate # or .venv\Scripts\activate on Windows source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -r requirements.txt pip install -r requirements.txt
@@ -186,18 +196,21 @@ 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)
+17 -349
View File
@@ -17,20 +17,6 @@ Rectangle {
border.color: Theme.outerFrameBorder border.color: Theme.outerFrameBorder
border.width: Theme.borderStrong border.width: Theme.borderStrong
// ===== Global ToolTip Styling =====
ToolTip.toolTip.background: Rectangle {
color: Theme.cardBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.radiusXs
}
ToolTip.toolTip.contentItem: Text {
text: ToolTip.toolTip.text
color: Theme.headingColor
font.pixelSize: Theme.fontXs
font.family: Theme.uiFontFamily
}
// ===== View State ===== // ===== View State =====
property int selectedTabIndex: 0 property int selectedTabIndex: 0
onSelectedTabIndexChanged: { onSelectedTabIndexChanged: {
@@ -420,43 +406,6 @@ Rectangle {
} }
} }
component PanelCheckBox: CheckBox {
id: toggle
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
IconImage {
anchors.centerIn: parent
source: "Tabs/icons/check.svg"
width: 12; height: 12
sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
ColumnLayout { ColumnLayout {
id: runCompCol id: runCompCol
anchors.fill: parent anchors.fill: parent
@@ -498,8 +447,10 @@ Rectangle {
color: Theme.bodyColor color: Theme.bodyColor
anchors.centerIn: parent anchors.centerIn: parent
} }
ToolTip.visible: resetRunBtn.hovered AppToolTip {
ToolTip.text: "Reset comparison" visible: resetRunBtn.hovered
text: "Reset comparison"
}
} }
} }
@@ -562,11 +513,13 @@ Rectangle {
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
readonly property string gateError: !!dt ? dt.familyGateError : "" readonly property string gateError: !!dt ? dt.familyGateError : ""
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === "" enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
ToolTip.visible: (sameFile || masterMissing || gateError !== "") && hovered AppToolTip {
ToolTip.text: masterMissing visible: (compareRunBtn.sameFile || compareRunBtn.masterMissing || compareRunBtn.gateError !== "") && compareRunBtn.hovered
? "No master file registered for this wafer type (Settings → Master Files)" text: compareRunBtn.masterMissing
: gateError !== "" ? gateError ? "No master file registered for this wafer type (Settings → Master Files)"
: "Choose two different runs to compare" : compareRunBtn.gateError !== "" ? compareRunBtn.gateError
: "Choose two different runs to compare"
}
onClicked: { onClicked: {
dt.comparing = true dt.comparing = true
dt.clearResults() dt.clearResults()
@@ -617,100 +570,9 @@ Rectangle {
currentIndex: root.selectedTabIndex === 0 ? 0 : 1 currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions + Log Actions ────────── // ── Status tab: Hardware Actions + Log Actions ──────────
ColumnLayout { StatusActionsPanel {
spacing: Theme.sideRailSpacing Layout.fillWidth: true
Layout.fillHeight: true
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
id: hwActionsCol
anchors.fill: parent
anchors.margins: 14
spacing: 6
Label {
text: "HARDWARE ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "DETECT WAFER"
iconSource: "../icons/detect.svg"
Layout.fillWidth: true
enabled: !deviceController.operationInProgress
onClicked: root._doDetect()
}
RailActionButton {
label: "READ MEMORY"
iconSource: "../icons/read.svg"
Layout.fillWidth: true
enabled: root.waferDetected
onClicked: deviceController.readMemoryAsync()
}
RailActionButton {
label: "ERASE MEMORY"
iconSource: "../icons/erase.svg"
Layout.fillWidth: true
destructive: true
enabled: root.waferDetected
onClicked: deviceController.eraseMemory(
deviceController.selectedPort)
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: logActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
ColumnLayout {
id: logActionsCol
anchors.fill: parent
anchors.margins: 14
spacing: 6
Label {
text: "LOG ACTIONS"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
}
RailActionButton {
label: "REFRESH SESSION"
iconSource: "../icons/refresh.svg"
Layout.fillWidth: true
onClicked: deviceController.clearSession()
}
RailActionButton {
label: "CLEAR LOG"
iconSource: "../icons/x.svg"
Layout.fillWidth: true
onClicked: deviceController.clearActivityLog()
}
}
}
Item { Layout.fillHeight: true }
} }
// ── Map tab: Source file browser ────────────── // ── Map tab: Source file browser ──────────────
@@ -728,208 +590,14 @@ Rectangle {
} }
// ── BOX 3: Hardware Status Footer ────────────────────────── // ── BOX 3: Hardware Status Footer ──────────────────────────
// Not relevant on the Data tab — Run Comparison takes its place above. ConnectionFooter {
Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterConnection
visible: root.selectedTabIndex !== 1
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
clip: true
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 6
// Row 1: Title
Label {
text: "CONNECTION FLOW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
}
// Row 2: Port / descriptive text
Label {
Layout.fillWidth: true
text: {
if (deviceController.connectionStatus === "Connected")
return deviceController.selectedPort || "Connected"
if (deviceController.selectedPort)
return deviceController.selectedPort
return "No port selected"
}
color: Theme.bodyColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
elide: Text.ElideRight
}
Item { Layout.fillHeight: true }
// Row 3: Mode chip left, status badge bottom-right
RowLayout {
spacing: 6
Layout.fillWidth: true
Rectangle {
width: modeChipLabel.implicitWidth + 12
height: 18
radius: 4
color: Theme.fieldBackground
border.color: Theme.sideBorder
border.width: 1
Label {
id: modeChipLabel
anchors.centerIn: parent
text: streamController.mode
? streamController.mode.toUpperCase() : "REVIEW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.8
}
}
Item { Layout.fillWidth: true }
Rectangle {
height: 18
width: badgeLabel.implicitWidth + 14
radius: 9
color: {
var s = deviceController.connectionStatus
if (s === "Connected") return Theme.statusSuccessColor
if (s === "Disconnected") return Theme.statusErrorColor
return Theme.statusWarningColor
}
Label {
id: badgeLabel
anchors.centerIn: parent
text: deviceController.connectionStatus || "Disconnected"
color: Theme.statusBadgeText
font.pixelSize: Theme.fontXs
font.bold: true
}
}
}
}
} }
// ── BOX 4: Utility Buttons ───────────────────────────────── // ── BOX 4: Utility Buttons ─────────────────────────────────
Rectangle { UtilityFooter {
id: utilFooter
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: Theme.sideFooterUtility
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
RowLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 4
Button {
id: settingsBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "Tabs/icons/settings.svg"
onClicked: settingsPopup.open()
background: Rectangle {
radius: 8
color: settingsBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "Tabs/icons/settings.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "SETTINGS"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
Rectangle {
width: 1
height: 24
color: Theme.sideBorder
}
Button {
id: aboutBtn
flat: true
Layout.fillWidth: true
Layout.fillHeight: true
icon.source: "Tabs/icons/about.svg"
onClicked: aboutDialog.open()
background: Rectangle {
radius: 8
color: aboutBtn.hovered
? Theme.sideActiveBackground : "transparent"
}
contentItem: Row {
spacing: 6
anchors.centerIn: parent
IconImage {
anchors.verticalCenter: parent.verticalCenter
width: 16
height: 16
source: "Tabs/icons/about.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.headingColor
opacity: 0.75
}
Label {
anchors.verticalCenter: parent.verticalCenter
text: "ABOUT"
color: Theme.headingColor
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 1.2
opacity: 0.8
}
}
}
}
} }
} }
} }
+35 -758
View File
@@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Controls.impl import QtQuick.Controls.impl
import QtQuick.Layouts import QtQuick.Layouts
import ISC import ISC
import ISC.Tabs.components
import ISC.Wafer import ISC.Wafer
// ===== Data Tab (Compare Content Only) ===== // ===== Data Tab (Compare Content Only) =====
@@ -184,8 +185,8 @@ Item {
root.compareWaferShape = result.wafer_shape || "round" root.compareWaferShape = result.wafer_shape || "round"
root.compareWaferSize = result.wafer_size || 300.0 root.compareWaferSize = result.wafer_size || 300.0
root.errorMessage = "" root.errorMessage = ""
scrubber.value = Math.floor(root.seriesLen / 2) chartCard.frameIndex = Math.floor(root.seriesLen / 2)
scrubber.forceActiveFocus() chartCard.focusScrubber()
if (root.frameCountA !== root.frameCountB) { if (root.frameCountA !== root.frameCountB) {
var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0 var timeA = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0 var timeB = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
@@ -204,250 +205,6 @@ Item {
} }
} }
// ── Reusable pieces ────────────────────────────────────────────
component SectionTitle: Label {
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
font.letterSpacing: 1.5
font.bold: true
color: Theme.sideMutedText
}
component PanelBox: Rectangle {
color: Theme.sidePanelBackground
border.color: Theme.sideBorder
border.width: 1
radius: Theme.sidePanelRadius
}
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
// indicator + check icon) instead of the platform-default CheckBox look.
component PanelCheckBox: CheckBox {
id: toggle
indicator: Rectangle {
implicitWidth: 18
implicitHeight: 18
x: toggle.leftPadding
y: parent.height / 2 - height / 2
radius: Theme.radiusXs
color: toggle.checked ? Theme.primaryAccent : "transparent"
border.width: Theme.borderThin
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
IconImage {
anchors.centerIn: parent
source: "icons/check.svg"
width: 12; height: 12
sourceSize.width: 12
sourceSize.height: 12
color: Theme.panelBackground
opacity: toggle.checked ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
}
}
contentItem: Text {
text: toggle.text
color: Theme.headingColor
font.family: Theme.uiFontFamily
verticalAlignment: Text.AlignVCenter
leftPadding: toggle.indicator.width + toggle.spacing
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
}
}
// Slider styled to match MapTab's right-rail heatmap slider (circular
// accent handle that grows slightly on press).
component PanelSlider: Slider {
id: panelSlider
handle: Rectangle {
x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width)
y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2
implicitWidth: 18
implicitHeight: 18
radius: 9
color: Theme.primaryAccent
scale: panelSlider.pressed ? 1.15 : 1.0
Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } }
}
}
// Mini stat box under the alignment scrubber (styled to match DTW ReadoutCard)
component ScrubStat: Rectangle {
id: scrubStat
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 52
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: scrubStat.label.toUpperCase()
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
Label {
text: scrubStat.value
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: scrubStat.valueColor
}
}
}
// Editable stat box for scrubber readout (styled to match DTW ReadoutCard)
component EditableScrubStat: Rectangle {
id: edStat
property string label
property real value
property string unit
property string extraText: ""
property bool editable: true
signal valueEdited(real newValue)
Layout.fillWidth: true
implicitHeight: edStatRow.implicitHeight + 16
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: (edStat.editable && inputField.activeFocus) ? Theme.fieldBorderFocus : Theme.cardBorder
border.width: (edStat.editable && inputField.activeFocus) ? Theme.borderStrong : Theme.borderThin
RowLayout {
id: edStatRow
anchors.fill: parent
anchors.margins: 8
spacing: 6
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Label {
text: edStat.label.toUpperCase()
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
RowLayout {
spacing: 4
TextField {
id: inputField
readOnly: !edStat.editable
hoverEnabled: edStat.editable
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
verticalAlignment: TextInput.AlignVCenter
horizontalAlignment: TextInput.AlignHCenter
background: Rectangle {
visible: edStat.editable
color: Theme.fieldBackground
radius: Theme.radiusXs
border.color: inputField.activeFocus ? Theme.fieldBorderFocus : (inputField.hovered ? Theme.fieldBorderFocus : Theme.fieldBorder)
border.width: 1
}
padding: 0
leftPadding: edStat.editable ? 6 : 0
rightPadding: edStat.editable ? 6 : 0
topPadding: edStat.editable ? 5 : 0
bottomPadding: edStat.editable ? 5 : 0
selectByMouse: edStat.editable
inputMethodHints: edStat.label === "Time" ? Qt.ImhFormattedNumbersOnly : Qt.ImhDigitsOnly
Layout.preferredWidth: edStat.editable ? Math.max(36, contentWidth + 12) : contentWidth
Layout.preferredHeight: edStat.editable ? 32 : implicitHeight
text: edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
onEditingFinished: {
var val = parseFloat(text)
if (!isNaN(val)) {
edStat.valueEdited(val)
}
text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
scrubber.forceActiveFocus()
}
Connections {
target: edStat
function onValueChanged() {
if (!inputField.activeFocus) {
inputField.text = edStat.value.toFixed(edStat.label === "Time" ? 1 : 0)
}
}
}
}
Label {
text: edStat.unit
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: edStat.editable ? Theme.headingColor : Theme.sideMutedText
Layout.alignment: Qt.AlignVCenter
}
}
}
Label {
visible: edStat.extraText !== ""
text: edStat.extraText
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: Theme.headingColor
Layout.alignment: Qt.AlignVCenter
}
}
}
// Large labeled value card for the DTW readout panel
component ReadoutCard: Rectangle {
id: readoutCard
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 52
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: readoutCard.label
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
Label {
text: readoutCard.value
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: readoutCard.valueColor
}
}
}
// ── Main Layout ─────────────────────────────────────────────── // ── Main Layout ───────────────────────────────────────────────
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
@@ -506,533 +263,53 @@ Item {
spacing: 12 spacing: 12
// Wafer Map Overlap View card // Wafer Map Overlap View card
PanelBox { OverlapMapCard {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
Layout.minimumHeight: 220 Layout.minimumHeight: 220
sensorLayout: root.compareSensorLayout
ColumnLayout { diffValues: streamController.getSensorDiffAt(Math.round(chartCard.frameIndex))
anchors.fill: parent diffBands: root.diffBands
anchors.margins: 12 waferShape: root.compareWaferShape
spacing: 8 waferSize: root.compareWaferSize
overlapEnabled: sidePanel.overlapEnabled
RowLayout { blendAmount: sidePanel.blendAmount
Layout.fillWidth: true
SectionTitle {
text: "WAFER OVERLAP"
Layout.fillWidth: true
}
}
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true
WaferMapItem {
id: overlapMapItem
anchors.fill: parent
visible: root.compareSensorLayout.length > 0 && enableOverlapCheck.checked
sensors: root.compareSensorLayout
values: streamController.getSensorDiffAt(scrubReadout.scrubIdx)
bands: root.diffBands(overlapMapItem.values)
shape: root.compareWaferShape
size: root.compareWaferSize
target: 0
margin: 1.0
blend: blendSlider.value / 100
showLabels: true
ringColor: Theme.waferRingColor
axisColor: Theme.waferAxisColor
lowColor: Theme.sensorLow
inRangeColor: Theme.sensorInRange
highColor: Theme.sensorHigh
textColor: Theme.headingColor
}
ColumnLayout {
anchors.centerIn: parent
visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked
spacing: 4
IconImage {
Layout.alignment: Qt.AlignHCenter
source: "icons/map.svg"
width: 24; height: 24
sourceSize.width: 24
sourceSize.height: 24
color: Theme.sideMutedText
}
Label {
Layout.alignment: Qt.AlignHCenter
text: !enableOverlapCheck.checked ? "Overlap map disabled"
: "No sensor map available"
color: Theme.bodyColor
font.pixelSize: Theme.fontXs
}
}
}
}
} }
// Chart card with alignment scrubber // Chart card with alignment scrubber
PanelBox { ComparisonChartCard {
id: chartCard
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: chartCol.implicitHeight + 24 trendDataA: root.trendDataA
trendDataB: root.trendDataB
ColumnLayout { seriesLen: root.seriesLen
id: chartCol hasSeries: root.hasSeries
anchors.fill: parent comparing: root.comparing
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
SectionTitle {
text: "CURVE OVERLAY"
Layout.fillWidth: true
}
Row {
spacing: 16
visible: root.hasSeries
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.primaryAccent; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run A"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
Row {
spacing: 4
Rectangle { width: 12; height: 3; radius: 1.5; color: Theme.themeSkill; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Run B"; font.pixelSize: Theme.fontXs; color: Theme.bodyColor }
}
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 200
color: Theme.fieldBackground
border.color: Theme.cardBorder
border.width: 1
radius: Theme.radiusSm
clip: true
Canvas {
id: overlayCanvas
anchors.fill: parent
visible: root.hasSeries
readonly property real padLeft: 50
readonly property real padRight: 16
readonly property real padTop: 24
readonly property real padBottom: 36
// Nice tick step (1/2/5 × 10^k) — same scheme as TrendChartItem,
// so labels land on round numbers instead of raw data min/max.
function niceStep(rawStep) {
var mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
return [1, 2, 5, 10].map(function (s) { return s * mag })
.find(function (s) { return s >= rawStep })
}
function drawGrid(ctx, minV, maxV, yStep, dataLen) {
var plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom
var range = (maxV - minV) || 1
ctx.strokeStyle = Theme.chartGridLine
ctx.lineWidth = 1
ctx.fillStyle = Theme.chartAxisText
ctx.font = "10px " + Theme.uiFontFamily
ctx.textAlign = "right"
var ySteps = Math.max(1, Math.round(range / yStep))
var yDec = Math.max(0, -Math.floor(Math.log10(yStep)))
for (var yi = 0; yi <= ySteps; yi++) {
var yFrac = yi / ySteps
var yPx = padTop + plotH * yFrac
var yVal = maxV - yFrac * range
ctx.beginPath()
ctx.moveTo(padLeft, yPx)
ctx.lineTo(padLeft + plotW, yPx)
ctx.stroke()
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
}
ctx.textAlign = "center"
var xMax = dataLen - 1
if (xMax > 0) {
var xStep = niceStep(xMax / 5 || 1)
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
var xPx = padLeft + (xIdx / xMax) * plotW
ctx.fillText(xIdx.toString(), xPx, height - 20)
}
}
ctx.save()
ctx.translate(14, padTop + plotH / 2)
ctx.rotate(-Math.PI / 2)
ctx.textAlign = "center"
ctx.font = "12px " + Theme.uiFontFamily
ctx.fillText("Celcius (°C)", 0, 0)
ctx.restore()
ctx.textAlign = "center"
ctx.font = "12px " + Theme.uiFontFamily
ctx.fillText("Frame", padLeft + plotW / 2, height - 4)
}
function drawSeries(ctx, series, minV, range, color, dataLen) {
// x is mapped against the shared frame domain (dataLen), not the
// series' own length — otherwise a shorter run's line stretches to
// fill the whole plot instead of stopping where its data ends.
if (series.length < 2) return
var plotW = width - padLeft - padRight
var plotH = height - padTop - padBottom
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
for (var i = 0; i < series.length; i++) {
var x = padLeft + (i / (dataLen - 1)) * plotW
var y = padTop + plotH - ((series[i] - minV) / range) * plotH
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
function drawScrubMarker(ctx) {
if (root.seriesLen < 2) return
var plotW = width - padLeft - padRight
var x = padLeft + (scrubber.value / (root.seriesLen - 1)) * plotW
ctx.strokeStyle = Theme.primaryAccent
ctx.lineWidth = 1.5
ctx.setLineDash([3, 3])
ctx.beginPath()
ctx.moveTo(x, padTop)
ctx.lineTo(x, height - padBottom)
ctx.stroke()
ctx.setLineDash([])
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
if (!root.hasSeries) return
var all = root.trendDataA.concat(root.trendDataB)
var minV = Math.min.apply(null, all)
var maxV = Math.max.apply(null, all)
var padding = (maxV - minV) * 0.05
minV -= padding
maxV += padding
// Snap bounds to nice-step multiples -> stable round-number axis
var yStep = niceStep((maxV - minV) / 5 || 1)
minV = Math.floor(minV / yStep) * yStep
maxV = Math.ceil(maxV / yStep) * yStep
var range = (maxV - minV) || 1
drawGrid(ctx, minV, maxV, yStep, root.seriesLen)
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen)
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen)
drawScrubMarker(ctx)
}
Connections {
target: root
function onTrendDataAChanged() { overlayCanvas.requestPaint() }
function onTrendDataBChanged() { overlayCanvas.requestPaint() }
}
Connections {
target: scrubber
function onValueChanged() { overlayCanvas.requestPaint() }
}
}
// Empty state
ColumnLayout {
anchors.centerIn: parent
visible: !root.hasSeries
spacing: 8
IconImage {
Layout.alignment: Qt.AlignHCenter
visible: !root.comparing
source: "icons/bar-chart.svg"
width: 32; height: 32
sourceSize.width: 32
sourceSize.height: 32
color: Theme.sideMutedText
}
Label {
Layout.alignment: Qt.AlignHCenter
text: root.comparing ? "Running DTW comparison…" : "Select two runs and run comparison"
color: Theme.bodyColor
font.pixelSize: Theme.fontSm
}
Label {
Layout.alignment: Qt.AlignHCenter
visible: !root.comparing
text: "Click a run box on the right, then select a file from the left panel."
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
}
}
}
// ── Alignment Timeline Scrubber ────────────
ColumnLayout {
Layout.fillWidth: true
spacing: 4
visible: root.hasSeries
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
SectionTitle {
text: "TIMELINE SCRUBBER"
Layout.fillWidth: true
Layout.topMargin: 8
}
Slider {
id: scrubber
Layout.fillWidth: true
leftPadding: overlayCanvas.padLeft
rightPadding: overlayCanvas.padRight
from: 0
to: Math.max(1, root.seriesLen - 1)
stepSize: 1
value: 0
}
}
}
} }
} }
// --- Right Column: run selection, overlap settings, readout --- // --- Right Column: run selection, overlap settings, readout ---
ColumnLayout { ComparisonSidePanel {
Layout.fillWidth: false id: sidePanel
Layout.fillWidth: false
Layout.preferredWidth: 280 Layout.preferredWidth: 280
Layout.minimumWidth: 280 Layout.minimumWidth: 280
Layout.maximumWidth: 280 Layout.maximumWidth: 280
Layout.fillHeight: true Layout.fillHeight: true
spacing: 12 warpingDistance: root.warpingDistance
frameOffset: root.frameOffset
// Bento 3: DTW Comparison Readout frameOffsetSeconds: root.frameOffsetSeconds
PanelBox { maxSensorDeviation: root.maxSensorDeviation
Layout.fillWidth: true seriesLen: root.seriesLen
Layout.preferredHeight: readoutCol.implicitHeight + 24 hasSeries: root.hasSeries
trendDataA: root.trendDataA
ColumnLayout { trendDataB: root.trendDataB
id: readoutCol timeDataA: root.timeDataA
anchors.fill: parent timeDataB: root.timeDataB
anchors.margins: 12 frameForTime: root.frameForTime
spacing: 8 frameIndex: chartCard.frameIndex
onFrameIndexRequested: (idx) => chartCard.frameIndex = idx
SectionTitle { text: "DTW READOUT" } onScrubberFocusRequested: chartCard.focusScrubber()
ReadoutCard {
label: "DTW ALIGNMENT DISTANCE"
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
valueColor: root.warpingDistance >= 0
? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
: Theme.sideMutedText
}
ReadoutCard {
label: "TEMPORAL FRAME OFFSET"
value: root.warpingDistance >= 0
? ((root.frameOffset >= 0 ? "+" : "") + root.frameOffset + " frames / "
+ (root.frameOffsetSeconds >= 0 ? "+" : "") + root.frameOffsetSeconds.toFixed(1) + "s")
: "--"
valueColor: root.warpingDistance >= 0 ? Theme.headingColor : Theme.sideMutedText
}
ReadoutCard {
label: "MAX SENSOR DEVIATION"
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
valueColor: root.maxSensorDeviation >= 0
? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
: Theme.sideMutedText
}
}
}
// Bento 2: Overlap Map Settings
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: overlapCol.implicitHeight + 24
ColumnLayout {
id: overlapCol
anchors.fill: parent
anchors.margins: 12
spacing: 8
SectionTitle { text: "OVERLAP SETTINGS" }
PanelCheckBox {
id: enableOverlapCheck
Layout.fillWidth: true
checked: true
text: "Enable Overlap Map"
font.pixelSize: Theme.fontSm
}
ColumnLayout {
Layout.fillWidth: true
spacing: 6
visible: enableOverlapCheck.checked
Rectangle {
Layout.fillWidth: true
height: 1
color: Theme.cardBorder
}
RowLayout {
Layout.fillWidth: true
spacing: 6
Label {
text: "Blend"
font.pixelSize: Theme.fontSm
color: Theme.bodyColor
Layout.preferredWidth: 40
}
PanelSlider {
id: blendSlider
Layout.fillWidth: true
from: 0; to: 100; value: 50
stepSize: 1
}
Label {
text: Math.round(blendSlider.value) + "%"
font.pixelSize: Theme.fontSm
color: Theme.sideMutedText
Layout.preferredWidth: 30
}
}
// Diff color legend
Row {
Layout.fillWidth: true
spacing: 10
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorLow; anchors.verticalCenter: parent.verticalCenter }
Label { text: "B cooler"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
}
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorInRange; anchors.verticalCenter: parent.verticalCenter }
Label { text: "Match"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
}
Row {
spacing: 3
Rectangle { width: 8; height: 8; radius: 4; color: Theme.sensorHigh; anchors.verticalCenter: parent.verticalCenter }
Label { text: "B hotter"; font.pixelSize: Theme.fontSm; color: Theme.sideMutedText }
}
}
}
}
}
// Bento: Frame / Time Controls
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: frameTimeCol.implicitHeight + 32
ColumnLayout {
id: frameTimeCol
anchors.fill: parent
anchors.margins: 16
spacing: 20
SectionTitle { text: "FRAME / TIME" }
EditableScrubStat {
label: "Frame"
value: scrubReadout.scrubIdx
unit: "/ " + Math.max(0, root.seriesLen - 1) + (Math.max(0, root.seriesLen - 1) <= 1 ? " frame" : " frames")
editable: root.hasSeries
onValueEdited: (newValue) => {
var maxVal = Math.max(0, root.seriesLen - 1)
var val = Math.max(0, Math.min(newValue, maxVal))
scrubber.value = val
}
}
EditableScrubStat {
label: "Time"
value: scrubReadout.scrubTime
unit: "/ " + scrubReadout.maxTime.toFixed(1) + "s"
editable: root.hasSeries
onValueEdited: (newValue) => {
var closestIdx = root.frameForTime(newValue)
scrubber.value = closestIdx
}
}
}
}
// Bento: Scrub Readout
PanelBox {
Layout.fillWidth: true
Layout.preferredHeight: scrubReadout.implicitHeight + 24
ColumnLayout {
id: scrubReadout
anchors.fill: parent
anchors.margins: 12
spacing: 8
readonly property int scrubIdx: Math.round(scrubber.value)
readonly property bool hasA: scrubIdx < root.trendDataA.length
readonly property bool hasB: scrubIdx < root.trendDataB.length
readonly property real tempA: hasA ? root.trendDataA[scrubIdx] : 0
readonly property real tempB: hasB ? root.trendDataB[scrubIdx] : 0
readonly property real scrubTime: {
var t = scrubIdx < root.timeDataA.length ? root.timeDataA[scrubIdx]
: (scrubIdx < root.timeDataB.length ? root.timeDataB[scrubIdx] : 0)
return t
}
readonly property real maxTime: {
var ta = root.timeDataA.length > 0 ? root.timeDataA[root.timeDataA.length - 1] : 0
var tb = root.timeDataB.length > 0 ? root.timeDataB[root.timeDataB.length - 1] : 0
return Math.max(ta, tb)
}
SectionTitle { text: "SCRUBBER READOUT" }
ScrubStat {
label: "Temp (Run A)"
value: scrubReadout.hasA ? scrubReadout.tempA.toFixed(1) + "°C" : "none"
valueColor: Theme.primaryAccent
}
ScrubStat {
label: "Temp (Run B)"
value: scrubReadout.hasB ? scrubReadout.tempB.toFixed(1) + "°C" : "none"
valueColor: Theme.themeSkill
}
ScrubStat {
label: "Difference"
value: (scrubReadout.hasA && scrubReadout.hasB)
? Math.abs(scrubReadout.tempA - scrubReadout.tempB).toFixed(1) + "°C" : "none"
}
}
}
Item { Layout.fillHeight: true }
} }
} }
} }
+1
View File
@@ -4,6 +4,7 @@ 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
+5 -3
View File
@@ -98,9 +98,11 @@ Item {
implicitHeight: 34 implicitHeight: 34
// Disabled controls swallow no events at all, so hover for the // Disabled controls swallow no events at all, so hover for the
// "why is this greyed out" tooltip needs its own MouseArea. // "why is this greyed out" tooltip needs its own MouseArea.
ToolTip.visible: disabledHover.containsMouse && !enabled AppToolTip {
ToolTip.text: "Connect a wafer to enable Live mode" visible: disabledHover.containsMouse && !liveTab.enabled
ToolTip.delay: 300 text: "Connect a wafer to enable Live mode"
delay: 300
}
MouseArea { MouseArea {
id: disabledHover id: disabledHover
anchors.fill: parent anchors.fill: parent
@@ -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,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 } }
}
}
@@ -481,8 +481,11 @@ ColumnLayout {
to: 1 to: 1
value: 0 value: 0
Layout.fillWidth: true Layout.fillWidth: true
ToolTip.visible: hovered
ToolTip.text: Math.round(value * 100) + "%" AppToolTip {
visible: heatmapSlider.hovered
text: Math.round(heatmapSlider.value * 100) + "%"
}
handle: Rectangle { handle: Rectangle {
x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width) x: heatmapSlider.leftPadding + heatmapSlider.visualPosition * (heatmapSlider.availableWidth - width)
@@ -0,0 +1,39 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ISC
// Small labeled value tile — DTW readout panel, scrubber readout, and any
// other compact label/value stat display share this one shape.
Rectangle {
id: readoutStat
property string label
property string value
property color valueColor: Theme.headingColor
Layout.fillWidth: true
implicitHeight: 52
radius: Theme.radiusSm
color: Theme.subtleSectionBackground
border.color: Theme.cardBorder
border.width: 1
ColumnLayout {
anchors.fill: parent
anchors.margins: 8
spacing: 2
Label {
text: readoutStat.label.toUpperCase()
font.pixelSize: Theme.fontXs
font.bold: true
font.letterSpacing: 0.2
color: Theme.sideMutedText
}
Label {
text: readoutStat.value
font.pixelSize: Theme.fontLg
font.bold: true
font.family: Theme.codeFontFamily
color: readoutStat.valueColor
}
}
}
@@ -0,0 +1,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
}
@@ -186,7 +186,7 @@ Dialog {
implicitHeight: 32 implicitHeight: 32
onClicked: root.close() onClicked: root.close()
contentItem: IconImage { contentItem: IconImage {
source: "icons/x.svg" source: "../icons/x.svg"
width: 14; height: 14 width: 14; height: 14
sourceSize.width: 14 sourceSize.width: 14
sourceSize.height: 14 sourceSize.height: 14
+2 -32
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
}
} }
} }
} }
@@ -0,0 +1,104 @@
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)
}
}
}
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,112 @@
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
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: utilFooter.parent.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: utilFooter.parent.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
}
}
}
}
}
+15 -1
View File
@@ -6,5 +6,19 @@ 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 StreamStatsDialog 1.0 StreamStatsDialog.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
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
-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
+20 -16
View File
@@ -9,12 +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 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 =====
@@ -32,8 +42,15 @@ 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)
@@ -54,8 +71,6 @@ def main() -> int:
# read/erase time (P2.1); a bound Callable[[str], str] avoids a # read/erase time (P2.1); a bound Callable[[str], str] avoids a
# LicenseModel import in the controller and keeps it testable with a # LicenseModel import in the controller and keeps it testable with a
# lambda. See docs/pending/license-gating-plan.md §1.1. # lambda. See docs/pending/license-gating-plan.md §1.1.
data_dir = str(settings_model._data_dir)
raw_settings = LocalSettings.read_settings(data_dir)
device_controller = DeviceController(raw_settings, data_dir) device_controller = DeviceController(raw_settings, data_dir)
engine.rootContext().setContextProperty("deviceController", device_controller) engine.rootContext().setContextProperty("deviceController", device_controller)
@@ -64,20 +79,9 @@ def main() -> int:
engine.rootContext().setContextProperty("licenseModel", license_model) 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,7 +13,6 @@ 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
@@ -26,11 +25,6 @@ from pygui.serialcomm.data_parser import (
) )
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 +38,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}
@@ -83,6 +78,7 @@ class DeviceController(QObject):
# → denied = safe default. Called by P2.1. # → denied = safe default. Called by P2.1.
# See docs/pending/license-gating-plan.md §1.2. # 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
@@ -218,6 +214,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."""
@@ -282,6 +289,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
@@ -372,6 +380,7 @@ class DeviceController(QObject):
wafer_dict = self._wafer_info_to_dict(info) wafer_dict = self._wafer_info_to_dict(info)
self._selected_port = port or "" 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._set_connection_status("Connected")
self.detectResult.emit(wafer_dict) self.detectResult.emit(wafer_dict)
self.selectedPortChanged.emit() self.selectedPortChanged.emit()
@@ -381,6 +390,7 @@ class DeviceController(QObject):
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() self.selectedPortChanged.emit()
finally: finally:
@@ -14,7 +14,7 @@ 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 FramePlayer, frames_from_wafer_data
from pygui.backend.models.replay_stats import ReplayStatsTracker from pygui.backend.models.replay_stats import ReplayStatsTracker
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.zwafer_models import Sensor from pygui.backend.wafer.zwafer_models import Sensor
@@ -37,7 +37,6 @@ class SessionController(QObject):
loadedFileChanged = Signal() loadedFileChanged = Signal()
loadFileError = Signal(str) 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 trendData = Signal(str) # JSON array of floats
@@ -50,8 +49,7 @@ class SessionController(QObject):
_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()
@@ -59,7 +57,7 @@ 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._trend_buffer: list[float] = [] # rolling window
self._trend_timestamps: list[float] = [] # monotonic timestamps self._trend_timestamps: list[float] = [] # monotonic timestamps
@@ -96,21 +94,6 @@ class SessionController(QObject):
self._compare_recs_b: list = [] self._compare_recs_b: list = []
self._compare_alignment_map: dict[int, int] = {} self._compare_alignment_map: dict[int, int] = {}
# Restore persisted state if available
self._load_settings(settings or {})
# ---- persisted settings ----
def _load_settings(self, settings: dict) -> None:
"""Restore session state from persisted settings dict."""
# Do NOT auto-restore the last session file — always start fresh.
def collect_settings(self) -> dict:
"""Return a dict of session state to persist."""
return {
"session_last_file": self._loaded_file,
}
# ---- properties QML binds to ---- # ---- properties QML binds to ----
@Property(str, notify=modeChanged) @Property(str, notify=modeChanged)
def mode(self) -> str: return self._mode def mode(self) -> str: return self._mode
@@ -150,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 [
@@ -175,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:
@@ -212,7 +195,7 @@ class SessionController(QObject):
@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("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()
@@ -221,7 +204,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
@@ -319,7 +302,6 @@ class SessionController(QObject):
for frame in self._player._frames: for frame in self._player._frames:
self._stats_tracker.track(frame.seq, frame.values) self._stats_tracker.track(frame.seq, frame.values)
self.trendData.emit(json.dumps(self._stats_tracker.get_trend())) self.trendData.emit(json.dumps(self._stats_tracker.get_trend()))
self.settingsChanged.emit()
@Slot() @Slot()
@slot_error_boundary @slot_error_boundary
@@ -331,7 +313,6 @@ class SessionController(QObject):
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)
@@ -475,7 +456,7 @@ class SessionController(QObject):
"""Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment.""" """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: if not self._compare_recs_a or not self._compare_recs_b:
return [] return []
idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1) idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
if idx_a not in self._compare_alignment_map: if idx_a not in self._compare_alignment_map:
# DTW path only covers indices up to the shorter run's length — # DTW path only covers indices up to the shorter run's length —
@@ -485,7 +466,7 @@ class SessionController(QObject):
values_a = self._compare_recs_a[idx_a].values values_a = self._compare_recs_a[idx_a].values
values_b = self._compare_recs_b[idx_b].values values_b = self._compare_recs_b[idx_b].values
n = min(len(values_a), len(values_b)) n = min(len(values_a), len(values_b))
return [round(values_b[i] - values_a[i], 3) for i in range(n)] return [round(values_b[i] - values_a[i], 3) for i in range(n)]
-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)
+8 -20
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,17 +13,15 @@ from pygui.backend.wafer.zwafer_models import Sensor
class CsvRecorder: class CsvRecorder:
def __init__(self) -> None: def __init__(self) -> None:
self._file_handle: Optional[TextIO] = None self._file_handle: Optional[TextIO] = None
self._oath: Optional[str] = None self._path: Optional[str] = None
@property @property
def is_recording(self) -> bool: def is_recording(self) -> bool:
return self._file_handle is not None return self._file_handle is not None
def start(self, path: str, sensors: list[Sensor], serial: str = "") -> None:
def start(self, path: str, sensors:list[Sensor], serial: str = "") -> None:
Path(path).parent.mkdir(parents=True, exist_ok=True) Path(path).parent.mkdir(parents=True, exist_ok=True)
file_handle = open(path, "w", encoding="utf-8", newline ="") file_handle = open(path, "w", encoding="utf-8", newline="")
file_handle.write(f"Wafer ID={serial}\n") file_handle.write(f"Wafer ID={serial}\n")
file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n") file_handle.write(f"Acquisition Date={datetime.now():%m/%d/%Y}\n")
file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n") file_handle.write("Label," + ",".join(s.label for s in sensors) + "\n")
@@ -32,7 +31,7 @@ class CsvRecorder:
file_handle.flush() file_handle.flush()
self._file_handle, self._path = file_handle, path self._file_handle, self._path = file_handle, path
def write(self, frame: Frame) -> None: def write(self, frame: Frame) -> None:
if self._file_handle is None: if self._file_handle is None:
return return
row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values] row = [f"{frame.time:.3f}"] + [f"{v:.3f}" for v in frame.values]
@@ -65,21 +64,11 @@ class CsvRecorder:
Returns: Returns:
True on success (at least one frame written). True on success (at least one frame written).
""" """
from pygui.backend.data.data_records import is_official_csv from pygui.backend.data.data_records import is_data_row, is_official_csv
if start_frame < 0 or end_frame < start_frame: if start_frame < 0 or end_frame < start_frame:
return False return False
def _is_data_row(stripped: str) -> bool:
cols = [c.strip() for c in stripped.split(",") if c.strip()]
if not cols:
return False
try:
[float(c) for c in cols]
except ValueError:
return False
return True
with open(source_path, "r", encoding="utf-8") as f: with open(source_path, "r", encoding="utf-8") as f:
lines = f.readlines() lines = f.readlines()
@@ -99,7 +88,7 @@ class CsvRecorder:
# official format: row 0 is the sensor-name header # official format: row 0 is the sensor-name header
out.append(line) out.append(line)
continue continue
if not stripped or not _is_data_row(stripped): if not stripped or not is_data_row(stripped):
continue continue
frame_idx += 1 frame_idx += 1
if start_frame <= frame_idx <= end_frame: if start_frame <= frame_idx <= end_frame:
@@ -112,4 +101,3 @@ class CsvRecorder:
with open(file_path, "w", encoding="utf-8", newline="") as f: with open(file_path, "w", encoding="utf-8", newline="") as f:
f.writelines(out) f.writelines(out)
return True return True
+18 -6
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
+5 -9
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]
@@ -42,7 +42,7 @@ class FileBrowser(QObject):
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
@@ -235,11 +235,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()
-15
View File
@@ -1,15 +0,0 @@
from pygui.backend.license.license_manager import (
LICENSE_FILENAME_RE,
License,
parse_license_blob,
read_license_files,
)
from pygui.backend.license.license_model import LicenseModel
__all__ = [
"LICENSE_FILENAME_RE",
"License",
"LicenseModel",
"parse_license_blob",
"read_license_files",
]
+1 -1
View File
@@ -50,7 +50,7 @@ class LicenseModel(QObject):
self.refresh() self.refresh()
# ── Grid rows ───────────────────────────────────────────────────── # ── Grid rows ─────────────────────────────────────────────────────
@Property("QVariantList", notify=licensesChanged) @Property("QVariantList", notify=licensesChanged) # type: ignore[arg-type]
def licenses(self) -> list: def licenses(self) -> list:
"""Rows for the About grid: serial, mfg date, level, license date.""" """Rows for the About grid: serial, mfg date, level, license date."""
return [ return [
-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
+1 -1
View File
@@ -4,7 +4,7 @@ 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)]
@@ -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,33 @@
"""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
@@ -26,6 +26,8 @@ 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
@@ -78,7 +80,7 @@ class GraphQuickItem(QQuickPaintedItem):
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 [])]
self._auto_range() self._auto_range()
@@ -89,7 +91,7 @@ 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.sensorNamesChanged.emit() self.sensorNamesChanged.emit()
@@ -99,7 +101,7 @@ class GraphQuickItem(QQuickPaintedItem):
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 +111,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 +121,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 +131,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 +143,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 +152,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 +161,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 +170,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 +180,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 []):
@@ -275,8 +277,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)
@@ -325,8 +326,7 @@ class GraphQuickItem(QQuickPaintedItem):
x_tick_step = (num_points - 1) / max(1, n_x_ticks - 1) x_tick_step = (num_points - 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)) idx = int(round(i * x_tick_step))
x_frac = idx / (num_points - 1) x_px = index_to_pixel(idx, num_points, 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),
@@ -358,10 +358,8 @@ class GraphQuickItem(QQuickPaintedItem):
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, max_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 len(pts) < 2: if len(pts) < 2:
@@ -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")
@@ -73,7 +73,8 @@ 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: def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
@@ -84,7 +85,8 @@ def refine_field(field: np.ndarray, factor: int = REFINE_FACTOR) -> np.ndarray:
resampling across a NaN/zero-filled hole rings at the boundary, and any resampling across a NaN/zero-filled hole rings at the boundary, and any
mask derived from the coarse grid keeps its staircase when upscaled. mask derived from the coarse grid keeps its staircase when upscaled.
""" """
return _ndi_zoom(field, factor, order=3) refined: np.ndarray = _ndi_zoom(field, factor, order=3)
return refined
def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray: def ellipse_alpha(height: int, width: int, feather_px: float = 1.2) -> np.ndarray:
@@ -20,6 +20,8 @@ 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 index_to_pixel, value_to_pixel
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QML_IMPORT_NAME = "ISC.Wafer" QML_IMPORT_NAME = "ISC.Wafer"
@@ -60,7 +62,7 @@ class TrendChartItem(QQuickPaintedItem):
def data(self) -> list[float]: def data(self) -> list[float]:
return self._data return self._data
@data.setter @data.setter # type: ignore[no-redef]
def data(self, val) -> None: def data(self, val) -> None:
coerced = self._coerce_floats(val) coerced = self._coerce_floats(val)
if coerced == self._data: if coerced == self._data:
@@ -82,7 +84,7 @@ class TrendChartItem(QQuickPaintedItem):
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
@@ -94,7 +96,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
@@ -106,7 +108,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
@@ -118,7 +120,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:
@@ -201,15 +203,10 @@ class TrendChartItem(QQuickPaintedItem):
return math.floor(lo / step) * step, math.ceil(hi / step) * step return math.floor(lo / step) * step, math.ceil(hi / step) * step
def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float: def _x_to_px(self, i: int, n: int, plot_rect: QRectF) -> float:
if n <= 1: return index_to_pixel(i, n, plot_rect.left(), plot_rect.width())
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: 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 value_to_pixel(v, y_min, y_max, plot_rect.top(), plot_rect.height())
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)
@@ -107,7 +107,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(
@@ -127,7 +127,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()
@@ -138,7 +138,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()
@@ -148,7 +148,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()
@@ -159,7 +159,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()
@@ -170,7 +170,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:
@@ -182,7 +182,7 @@ 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()
@@ -192,7 +192,7 @@ class WaferMapItem(QQuickPaintedItem):
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()
@@ -202,7 +202,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()
@@ -212,7 +212,7 @@ class WaferMapItem(QQuickPaintedItem):
def thicknessData(self) -> list: def thicknessData(self) -> list:
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(val or [])
self._rebuild_thickness() self._rebuild_thickness()
@@ -223,7 +223,7 @@ class WaferMapItem(QQuickPaintedItem):
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()
@@ -233,7 +233,7 @@ class WaferMapItem(QQuickPaintedItem):
def hoveredIndex(self) -> int: def hoveredIndex(self) -> int:
return self._hovered_index return self._hovered_index
@hoveredIndex.setter @hoveredIndex.setter # type: ignore[no-redef]
def hoveredIndex(self, val: int) -> None: def hoveredIndex(self, val: int) -> None:
val = int(val) val = int(val)
if val == self._hovered_index: if val == self._hovered_index:
@@ -246,7 +246,7 @@ class WaferMapItem(QQuickPaintedItem):
def hoverScale(self) -> float: def hoverScale(self) -> float:
return self._hover_scale return self._hover_scale
@hoverScale.setter @hoverScale.setter # type: ignore[no-redef]
def hoverScale(self, val: float) -> None: def hoverScale(self, val: float) -> None:
self._hover_scale = float(val) self._hover_scale = float(val)
self.hoverScaleChanged.emit() self.hoverScaleChanged.emit()
@@ -255,32 +255,32 @@ class WaferMapItem(QQuickPaintedItem):
# 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 ─────────────────────────────────────────────────────────────
@@ -314,7 +314,7 @@ class WaferMapItem(QQuickPaintedItem):
return False return False
try: try:
result = self.grabToImage() result = self.grabToImage()
img = result.image() img = result.image() # type: ignore[attr-defined]
img.save(file_path, "PNG") img.save(file_path, "PNG")
return True return True
except Exception as e: except Exception as e:
@@ -702,11 +702,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:
@@ -714,8 +714,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",
]
+2 -1
View File
@@ -66,7 +66,8 @@ 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
# TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser) # TODO P6.3: expose this list to a new LayoutSelector.qml (4-button chooser)
+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):
+1 -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
@@ -266,7 +267,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"
+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
+28
View File
@@ -0,0 +1,28 @@
import pytest
from pygui.backend.visualization.chart_geometry import 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)
+61
View File
@@ -0,0 +1,61 @@
import pytest
from pygui.backend.data.local_settings import LocalSettings
from pygui.backend.data.local_settings_model import LocalSettingsModel
@pytest.fixture
def shared_settings():
return LocalSettings()
@pytest.fixture
def model(qapp, tmp_path, shared_settings):
return LocalSettingsModel(shared_settings, tmp_path)
def test_save_mutates_the_shared_instance_in_place(model, shared_settings):
# This is the bug candidate 1 fixes: DeviceController holds the same
# LocalSettings object, so a save here must be visible to it without
# a reload — no throwaway copy, no stale read until restart.
model.chamberId = "7"
model.waferReadTimeout = 45000
model.saveSettings()
assert shared_settings.chamber_id == "7"
assert shared_settings.wafer_read_timeout == 45000
def test_save_persists_to_disk_and_reload_round_trips(tmp_path, qapp, shared_settings):
model = LocalSettingsModel(shared_settings, tmp_path)
model.chamberId = "9"
model.waferRetries = 3
model.saveSettings()
reloaded_settings = LocalSettings.read_settings(str(tmp_path))
other_model = LocalSettingsModel(reloaded_settings, tmp_path)
other_model.loadSettings()
assert other_model.chamberId == "9"
assert other_model.waferRetries == 3
def test_revert_restores_last_saved_values(model):
model.chamberId = "2"
model.saveSettings()
model.chamberId = "unsaved-edit"
assert model.isDirty is True
model.revertChanges()
assert model.chamberId == "2"
assert model.isDirty is False
def test_invalid_chamber_id_blocks_save(model, shared_settings):
shared_settings.chamber_id = "before"
model.chamberId = ""
model.saveSettings()
assert model.saveStatus.startswith("error:")
assert shared_settings.chamber_id == "before" # unsaved, shared instance untouched
+7 -4
View File
@@ -1,7 +1,10 @@
from unittest.mock import MagicMock
import pytest import pytest
from unittest.mock import MagicMock, patch
from pygui.backend.controllers.session_controller import SessionController from pygui.backend.controllers.session_controller import SessionController
@pytest.fixture @pytest.fixture
def controller(): def controller():
return SessionController() return SessionController()
@@ -9,7 +12,7 @@ def controller():
def test_initial_default(controller): def test_initial_default(controller):
assert controller.mode == "review" assert controller.mode == "review"
assert controller.state == "idle" assert controller.state == "idle"
assert controller.recording == False assert not controller.recording
def test_playback_flow(controller): def test_playback_flow(controller):
controller.loadFile("tests/fixtures/sample_stream.csv") controller.loadFile("tests/fixtures/sample_stream.csv")
@@ -119,10 +122,10 @@ Item {
def test_recording_toggle(controller, tmp_path): def test_recording_toggle(controller, tmp_path):
csv_path = str(tmp_path / "rec" / "live_test.csv") csv_path = str(tmp_path / "rec" / "live_test.csv")
controller.startRecording(csv_path, "SN123") controller.startRecording(csv_path, "SN123")
assert controller.recording == True assert controller.recording
controller.stopRecording() controller.stopRecording()
assert controller.recording == False assert not controller.recording
# Header written with wafer serial # Header written with wafer serial
content = open(csv_path).read() content = open(csv_path).read()
-2
View File
@@ -1,2 +0,0 @@
{
"_mock_return_value":