Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ee139ad8f | |||
| 7b20ffa376 | |||
| ac4448ed44 | |||
| ca158a7946 | |||
| 637b51b999 |
+18
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||

|

|
||||||
*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`**.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### 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`):
|
|||||||

|

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

|

|
||||||
|
|||||||
+17
-349
@@ -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
@@ -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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 // 0–100, percent
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
SectionTitle {
|
||||||
|
text: "WAFER OVERLAP"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
WaferMapItem {
|
||||||
|
id: overlapMapItem
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: card.sensorLayout.length > 0 && card.overlapEnabled
|
||||||
|
sensors: card.sensorLayout
|
||||||
|
values: card.diffValues
|
||||||
|
bands: card.diffBands(overlapMapItem.values)
|
||||||
|
shape: card.waferShape
|
||||||
|
size: card.waferSize
|
||||||
|
target: 0
|
||||||
|
margin: 1.0
|
||||||
|
blend: card.blendAmount / 100
|
||||||
|
showLabels: true
|
||||||
|
ringColor: Theme.waferRingColor
|
||||||
|
axisColor: Theme.waferAxisColor
|
||||||
|
lowColor: Theme.sensorLow
|
||||||
|
inRangeColor: Theme.sensorInRange
|
||||||
|
highColor: Theme.sensorHigh
|
||||||
|
textColor: Theme.headingColor
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: card.sensorLayout.length === 0 || !card.overlapEnabled
|
||||||
|
spacing: 4
|
||||||
|
IconImage {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
source: "../icons/map.svg"
|
||||||
|
width: 24; height: 24
|
||||||
|
sourceSize.width: 24
|
||||||
|
sourceSize.height: 24
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: !card.overlapEnabled ? "Overlap map disabled"
|
||||||
|
: "No sensor map available"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Card container for the Data tab bento boxes.
|
||||||
|
Rectangle {
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Checkbox styled to match MapTab's right-rail ReadoutPanel (square accent
|
||||||
|
// indicator + check icon) instead of the platform-default CheckBox look.
|
||||||
|
CheckBox {
|
||||||
|
id: toggle
|
||||||
|
indicator: Rectangle {
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
x: toggle.leftPadding
|
||||||
|
y: parent.height / 2 - height / 2
|
||||||
|
radius: Theme.radiusXs
|
||||||
|
color: toggle.checked ? Theme.primaryAccent : "transparent"
|
||||||
|
border.width: Theme.borderThin
|
||||||
|
border.color: toggle.checked ? Theme.primaryAccent : Theme.fieldBorder
|
||||||
|
|
||||||
|
Behavior on color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durationFast } }
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
source: "../icons/check.svg"
|
||||||
|
width: 12; height: 12
|
||||||
|
sourceSize.width: 12
|
||||||
|
sourceSize.height: 12
|
||||||
|
color: Theme.panelBackground
|
||||||
|
opacity: toggle.checked ? 1.0 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: Theme.durationFast } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Text {
|
||||||
|
text: toggle.text
|
||||||
|
color: Theme.headingColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
leftPadding: toggle.indicator.width + toggle.spacing
|
||||||
|
font.pixelSize: toggle.font.pixelSize || Theme.fontXs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import ISC
|
||||||
|
|
||||||
|
// Slider styled to match MapTab's right-rail heatmap slider (circular
|
||||||
|
// accent handle that grows slightly on press).
|
||||||
|
Slider {
|
||||||
|
id: panelSlider
|
||||||
|
handle: Rectangle {
|
||||||
|
x: panelSlider.leftPadding + panelSlider.visualPosition * (panelSlider.availableWidth - width)
|
||||||
|
y: panelSlider.topPadding + panelSlider.availableHeight / 2 - height / 2
|
||||||
|
implicitWidth: 18
|
||||||
|
implicitHeight: 18
|
||||||
|
radius: 9
|
||||||
|
color: Theme.primaryAccent
|
||||||
|
scale: panelSlider.pressed ? 1.15 : 1.0
|
||||||
|
Behavior on scale { NumberAnimation { duration: Theme.durationFast; easing.type: Theme.easeStandard } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+1
-1
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# ===== Crypto Sub-package =====
|
|
||||||
from pygui.backend.crypto.crypto_helper import * # noqa: F403
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# ===== Data Sub-package =====
|
|
||||||
from pygui.backend.data.csv_file_metadata import CSVFileMetadata
|
|
||||||
from pygui.backend.data.csv_recorder import CsvRecorder
|
|
||||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
|
||||||
from pygui.backend.data.file_browser import FileBrowser
|
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
|
||||||
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"CsvRecorder", "CSVFileMetadata",
|
|
||||||
"read_data_records", "is_official_csv", "read_official_csv",
|
|
||||||
"FileBrowser",
|
|
||||||
"LocalSettings", "LocalSettingsModel",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
# ===== Backend Data Constants =====
|
# ===== 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
from pygui.backend.license.license_manager import (
|
|
||||||
LICENSE_FILENAME_RE,
|
|
||||||
License,
|
|
||||||
parse_license_blob,
|
|
||||||
read_license_files,
|
|
||||||
)
|
|
||||||
from pygui.backend.license.license_model import LicenseModel
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LICENSE_FILENAME_RE",
|
|
||||||
"License",
|
|
||||||
"LicenseModel",
|
|
||||||
"parse_license_blob",
|
|
||||||
"read_license_files",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class LicenseModel(QObject):
|
|||||||
self.refresh()
|
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 [
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# ===== Models Sub-package =====
|
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
|
||||||
from pygui.backend.models.frame import Frame
|
|
||||||
from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_data
|
|
||||||
from pygui.backend.models.frame_stats import Stats, compute_stats
|
|
||||||
from pygui.backend.models.sensor_editor import SensorEditor
|
|
||||||
from pygui.backend.models.session_model import SessionModel, SessionUpdate
|
|
||||||
from pygui.backend.models.stability_detector import StabilityDetector
|
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig, classify, resolve_bounds
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Frame", "FramePlayer", "frames_from_wafer_data",
|
|
||||||
"Stats", "compute_stats",
|
|
||||||
"SessionModel", "SessionUpdate",
|
|
||||||
"TemperatureTableModel",
|
|
||||||
"SensorEditor",
|
|
||||||
"ThresholdConfig", "classify", "resolve_bounds",
|
|
||||||
"StabilityDetector",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class TemperatureTableModel(QAbstractTableModel):
|
|||||||
# Column 0 = row index, columns 1..N = sensor values
|
# 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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# ===== Wafer Sub-package =====
|
|
||||||
from pygui.backend.wafer.family_spec import sensor_count_for
|
|
||||||
from pygui.backend.wafer.wafer_layouts import available_families, load_layout
|
|
||||||
from pygui.backend.wafer.zwafer_models import DataRecord, Sensor, ZWaferData
|
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ZWaferData", "Sensor", "DataRecord",
|
|
||||||
"ZWaferParser",
|
|
||||||
"load_layout", "available_families",
|
|
||||||
"sensor_count_for",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -66,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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
from pygui.backend.data.local_settings_model import LocalSettingsModel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def shared_settings():
|
||||||
|
return LocalSettings()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def model(qapp, tmp_path, shared_settings):
|
||||||
|
return LocalSettingsModel(shared_settings, tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_mutates_the_shared_instance_in_place(model, shared_settings):
|
||||||
|
# This is the bug candidate 1 fixes: DeviceController holds the same
|
||||||
|
# LocalSettings object, so a save here must be visible to it without
|
||||||
|
# a reload — no throwaway copy, no stale read until restart.
|
||||||
|
model.chamberId = "7"
|
||||||
|
model.waferReadTimeout = 45000
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
assert shared_settings.chamber_id == "7"
|
||||||
|
assert shared_settings.wafer_read_timeout == 45000
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_persists_to_disk_and_reload_round_trips(tmp_path, qapp, shared_settings):
|
||||||
|
model = LocalSettingsModel(shared_settings, tmp_path)
|
||||||
|
model.chamberId = "9"
|
||||||
|
model.waferRetries = 3
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
reloaded_settings = LocalSettings.read_settings(str(tmp_path))
|
||||||
|
other_model = LocalSettingsModel(reloaded_settings, tmp_path)
|
||||||
|
other_model.loadSettings()
|
||||||
|
|
||||||
|
assert other_model.chamberId == "9"
|
||||||
|
assert other_model.waferRetries == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_restores_last_saved_values(model):
|
||||||
|
model.chamberId = "2"
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
model.chamberId = "unsaved-edit"
|
||||||
|
assert model.isDirty is True
|
||||||
|
|
||||||
|
model.revertChanges()
|
||||||
|
assert model.chamberId == "2"
|
||||||
|
assert model.isDirty is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_chamber_id_blocks_save(model, shared_settings):
|
||||||
|
shared_settings.chamber_id = "before"
|
||||||
|
model.chamberId = ""
|
||||||
|
model.saveSettings()
|
||||||
|
|
||||||
|
assert model.saveStatus.startswith("error:")
|
||||||
|
assert shared_settings.chamber_id == "before" # unsaved, shared instance untouched
|
||||||
@@ -1,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()
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
{
|
|
||||||
"_mock_return_value":
|
|
||||||
Reference in New Issue
Block a user